Skip to content

Commit

Permalink
fix: Restrict updating/deleting of shared but not owned credentials (#…
Browse files Browse the repository at this point in the history
…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).
  • Loading branch information
valya authored Dec 7, 2023
1 parent 3ba7deb commit 42e828d
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 36 deletions.
49 changes: 41 additions & 8 deletions packages/cli/src/credentials/credentials.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/permissions/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export const ownerPermissions: Scope[] = [
'auditLogs:manage',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/test/integration/credentials.ee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand Down
93 changes: 70 additions & 23 deletions packages/cli/test/integration/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ 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';

import { randomCredentialPayload, randomName, randomString } from './shared/random';
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';
Expand All @@ -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;
Expand All @@ -36,6 +37,7 @@ beforeAll(async () => {

owner = await createUser({ globalRole: globalOwnerRole });
member = await createUser({ globalRole: globalMemberRole });
secondMember = await createUser({ globalRole: globalMemberRole });

saveCredential = affixRoleToSaveCredential(credentialOwnerRole);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -269,41 +291,30 @@ 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();

const response = await authOwnerAgent
.patch(`/credentials/${savedCredential.id}`)
.send(patchPayload);

expect(response.statusCode).toBe(200);

const { id, name, type, nodesAccess, data: encryptedData } = response.body.data;

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);
expect(response.statusCode).toBe(404);

const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id });
const credential = await Container.get(CredentialsRepository).findOneByOrFail({
id: savedCredential.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({
relations: ['credentials'],
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 () => {
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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');
Expand Down

0 comments on commit 42e828d

Please sign in to comment.