From f1739e978f9dee1de42dd5479ec80a5d991a9bfe Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Tue, 21 Jan 2020 19:21:43 +0200 Subject: [PATCH] fix(backend-gitlab): check for shared group permissions (#3122) --- .../netlify-cms-backend-gitlab/src/API.ts | 61 +++++- .../src/__tests__/API.spec.js | 185 ++++++++++++++++-- 2 files changed, 218 insertions(+), 28 deletions(-) diff --git a/packages/netlify-cms-backend-gitlab/src/API.ts b/packages/netlify-cms-backend-gitlab/src/API.ts index e96b0d12..8cd4b476 100644 --- a/packages/netlify-cms-backend-gitlab/src/API.ts +++ b/packages/netlify-cms-backend-gitlab/src/API.ts @@ -124,6 +124,28 @@ type GitLabMergeRequest = { sha: string; }; +type GitLabRepo = { + shared_with_groups: { group_access_level: number }[] | null; + permissions: { + project_access: { access_level: number } | null; + group_access: { access_level: number } | null; + }; +}; + +type GitLabBranch = { + developers_can_push: boolean; + developers_can_merge: boolean; +}; + +export const getMaxAccess = (groups: { group_access_level: number }[]) => { + return groups.reduce((previous, current) => { + if (current.group_access_level > previous.group_access_level) { + return current; + } + return previous; + }, groups[0]); +}; + export default class API { apiRoot: string; token: string | boolean; @@ -173,17 +195,40 @@ export default class API { user = () => this.requestJSON('/user'); WRITE_ACCESS = 30; - hasWriteAccess = () => - this.requestJSON(this.repoURL).then(({ permissions }) => { - const { project_access: projectAccess, group_access: groupAccess } = permissions; - if (projectAccess && projectAccess.access_level >= this.WRITE_ACCESS) { + MAINTAINER_ACCESS = 40; + + hasWriteAccess = async () => { + const { + shared_with_groups: sharedWithGroups, + permissions, + }: GitLabRepo = await this.requestJSON(this.repoURL); + const { project_access: projectAccess, group_access: groupAccess } = permissions; + if (projectAccess && projectAccess.access_level >= this.WRITE_ACCESS) { + return true; + } + if (groupAccess && groupAccess.access_level >= this.WRITE_ACCESS) { + return true; + } + // check for group write permissions + if (sharedWithGroups && sharedWithGroups.length > 0) { + const maxAccess = getMaxAccess(sharedWithGroups); + // maintainer access + if (maxAccess.group_access_level >= this.MAINTAINER_ACCESS) { return true; } - if (groupAccess && groupAccess.access_level >= this.WRITE_ACCESS) { - return true; + // developer access + if (maxAccess.group_access_level >= this.WRITE_ACCESS) { + // check permissions to merge and push + const branch: GitLabBranch = await this.requestJSON( + `${this.repoURL}/repository/branches/${this.branch}`, + ).catch(() => ({})); + if (branch.developers_can_merge && branch.developers_can_push) { + return true; + } } - return false; - }); + } + return false; + }; readFile = async ( path: string, diff --git a/packages/netlify-cms-backend-gitlab/src/__tests__/API.spec.js b/packages/netlify-cms-backend-gitlab/src/__tests__/API.spec.js index 169d26a5..78171b75 100644 --- a/packages/netlify-cms-backend-gitlab/src/__tests__/API.spec.js +++ b/packages/netlify-cms-backend-gitlab/src/__tests__/API.spec.js @@ -1,4 +1,4 @@ -import API from '../API'; +import API, { getMaxAccess } from '../API'; global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests')); @@ -7,29 +7,174 @@ describe('GitLab API', () => { jest.resetAllMocks(); }); - test('should get preview statuses', async () => { - const api = new API({ repo: 'repo' }); + describe('hasWriteAccess', () => { + test('should return true on project access_level >= 30', async () => { + const api = new API({ repo: 'repo' }); - const mr = { sha: 'sha' }; - const statuses = [ - { name: 'deploy', status: 'success', target_url: 'deploy-url' }, - { name: 'build', status: 'pending' }, - ]; + api.requestJSON = jest + .fn() + .mockResolvedValueOnce({ permissions: { project_access: { access_level: 30 } } }); - api.getBranchMergeRequest = jest.fn(() => Promise.resolve(mr)); - api.getMergeRequestStatues = jest.fn(() => Promise.resolve(statuses)); + await expect(api.hasWriteAccess()).resolves.toBe(true); + }); - const collectionName = 'posts'; - const slug = 'title'; - await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([ - { context: 'deploy', state: 'success', target_url: 'deploy-url' }, - { context: 'build', state: 'other' }, - ]); + test('should return false on project access_level < 30', async () => { + const api = new API({ repo: 'repo' }); - expect(api.getBranchMergeRequest).toHaveBeenCalledTimes(1); - expect(api.getBranchMergeRequest).toHaveBeenCalledWith('cms/posts/title'); + api.requestJSON = jest + .fn() + .mockResolvedValueOnce({ permissions: { project_access: { access_level: 10 } } }); - expect(api.getMergeRequestStatues).toHaveBeenCalledTimes(1); - expect(api.getMergeRequestStatues).toHaveBeenCalledWith(mr, 'cms/posts/title'); + await expect(api.hasWriteAccess()).resolves.toBe(false); + }); + + test('should return true on group access_level >= 30', async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest + .fn() + .mockResolvedValueOnce({ permissions: { group_access: { access_level: 30 } } }); + + await expect(api.hasWriteAccess()).resolves.toBe(true); + }); + + test('should return false on group access_level < 30', async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest + .fn() + .mockResolvedValueOnce({ permissions: { group_access: { access_level: 10 } } }); + + await expect(api.hasWriteAccess()).resolves.toBe(false); + }); + + test('should return true on shared group access_level >= 40', async () => { + const api = new API({ repo: 'repo' }); + api.requestJSON = jest.fn().mockResolvedValueOnce({ + permissions: { project_access: null, group_access: null }, + shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 40 }], + }); + + await expect(api.hasWriteAccess()).resolves.toBe(true); + + expect(api.requestJSON).toHaveBeenCalledTimes(1); + }); + + test('should return true on shared group access_level >= 30, developers can merge and push', async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest.fn(); + api.requestJSON.mockResolvedValueOnce({ + permissions: { project_access: null, group_access: null }, + shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 30 }], + }); + api.requestJSON.mockResolvedValueOnce({ + developers_can_merge: true, + developers_can_push: true, + }); + + await expect(api.hasWriteAccess()).resolves.toBe(true); + }); + + test('should return false on shared group access_level < 30,', async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest.fn(); + api.requestJSON.mockResolvedValueOnce({ + permissions: { project_access: null, group_access: null }, + shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 20 }], + }); + api.requestJSON.mockResolvedValueOnce({ + developers_can_merge: true, + developers_can_push: true, + }); + + await expect(api.hasWriteAccess()).resolves.toBe(false); + }); + + test("should return false on shared group access_level >= 30, developers can't merge", async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest.fn(); + api.requestJSON.mockResolvedValueOnce({ + permissions: { project_access: null, group_access: null }, + shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 30 }], + }); + api.requestJSON.mockResolvedValueOnce({ + developers_can_merge: false, + developers_can_push: true, + }); + + await expect(api.hasWriteAccess()).resolves.toBe(false); + }); + + test("should return false on shared group access_level >= 30, developers can't push", async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest.fn(); + api.requestJSON.mockResolvedValueOnce({ + permissions: { project_access: null, group_access: null }, + shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 30 }], + }); + api.requestJSON.mockResolvedValueOnce({ + developers_can_merge: true, + developers_can_push: false, + }); + + await expect(api.hasWriteAccess()).resolves.toBe(false); + }); + + test('should return false on shared group access_level >= 30, error getting branch', async () => { + const api = new API({ repo: 'repo' }); + + api.requestJSON = jest.fn(); + api.requestJSON.mockResolvedValueOnce({ + permissions: { project_access: null, group_access: null }, + shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 30 }], + }); + api.requestJSON.mockRejectedValue(new Error('Not Found')); + + await expect(api.hasWriteAccess()).resolves.toBe(false); + }); + }); + + describe('getStatuses', () => { + test('should get preview statuses', async () => { + const api = new API({ repo: 'repo' }); + + const mr = { sha: 'sha' }; + const statuses = [ + { name: 'deploy', status: 'success', target_url: 'deploy-url' }, + { name: 'build', status: 'pending' }, + ]; + + api.getBranchMergeRequest = jest.fn(() => Promise.resolve(mr)); + api.getMergeRequestStatues = jest.fn(() => Promise.resolve(statuses)); + + const collectionName = 'posts'; + const slug = 'title'; + await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([ + { context: 'deploy', state: 'success', target_url: 'deploy-url' }, + { context: 'build', state: 'other' }, + ]); + + expect(api.getBranchMergeRequest).toHaveBeenCalledTimes(1); + expect(api.getBranchMergeRequest).toHaveBeenCalledWith('cms/posts/title'); + + expect(api.getMergeRequestStatues).toHaveBeenCalledTimes(1); + expect(api.getMergeRequestStatues).toHaveBeenCalledWith(mr, 'cms/posts/title'); + }); + }); + + describe('getMaxAccess', () => { + it('should return group with max access level', () => { + const groups = [ + { group_access_level: 10 }, + { group_access_level: 5 }, + { group_access_level: 100 }, + { group_access_level: 1 }, + ]; + expect(getMaxAccess(groups)).toBe(groups[2]); + }); }); });