From 5c743fabc2e1813430ba696faa95c6c121d38bce Mon Sep 17 00:00:00 2001 From: dzdidi Date: Sun, 28 Jan 2024 15:33:57 +0000 Subject: [PATCH] ACL draft integration Signed-off-by: dzdidi --- src/acl.js | 96 +++++++++++++++++++++++++++++++++++++++++++++++- src/home.js | 10 ++--- src/rpc.js | 83 ++++++++++++++++++++++++++++++----------- test/acl.test.js | 59 +++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 28 deletions(-) create mode 100644 test/acl.test.js diff --git a/src/acl.js b/src/acl.js index 4216d19..4becc35 100644 --- a/src/acl.js +++ b/src/acl.js @@ -1,6 +1,7 @@ const home = require('./home') +const fs = require('fs') -const roles = { +const ROLES = { admin: { description: 'Read and write to all branches', }, @@ -12,9 +13,100 @@ const roles = { }, } const DEFAULT_ACL = { - visibibility: 'public', // public|private + visibility: 'public', // public|private protectedBranches: ['master'], ACL: {} } +function getUserRole (repoName, user) { + const acl = getACL(repoName) + return acl.ACL[user] +} +function getAdmins (repoName) { + const acl = getACL(repoName) + return Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'admin') +} + +function getContributors (repoName) { + const acl = getACL(repoName) + const contributors = Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'contributor') + const admins = getAdmins(repoName) + return [...contributors, ...admins].filter((user, i, arr) => arr.indexOf(user) === i) +} + +function getViewers (repoName) { + const acl = getACL(repoName) + const viewers = Object.keys(acl.ACL).filter(user => acl.ACL[user] === 'viewer') + const contributors = getContributors(repoName) + const admins = getAdmins(repoName) + + return [...viewers, ...contributors, ...admins].filter((user, i, arr) => arr.indexOf(user) === i) +} + +function grantAccessToUser (repoName, user, role) { + if (!ROLES[role]) throw new Error(`Role ${role} does not exist`) + if (!Object.keys(ROLES).includes(role)) throw new Error(`Role ${role} is not allowed`) + + const acl = getACL(repoName) + acl.ACL[user] = role + setACL(repoName, acl) +} + +function addProtectedBranch (repoName, branch) { + const acl = getACL(repoName) + acl.protectedBranches.push(branch) + setACL(repoName, acl) +} + +function removeProtectedBranch (repoName, branch) { + const acl = getACL(repoName) + acl.protectedBranches = acl.protectedBranches.filter(b => b !== branch) + setACL(repoName, acl) +} + +function makeRepoPublic (repoName) { + const acl = getACL(repoName) + acl.visibility = 'public' + setACL(repoName, acl) +} + +function getRepoVisibility (repoName) { + const acl = getACL(repoName) + return acl.visibility +} + +function makeRepoPrivate (repoName) { + const acl = getACL(repoName) + acl.visibility = 'private' + setACL(repoName, acl) +} + +function setACL (repoName, acl = DEFAULT_ACL) { + acl = { ...DEFAULT_ACL, ...acl } + if (['public', 'private'].indexOf(acl.visibility) === -1) throw new Error('Visibility must be public or private') + + const content = JSON.stringify(acl, null, 2) + fs.writeFileSync(home.getACLFilePath(repoName), content) + + return acl +} + +function getACL (repoName) { + return JSON.parse(fs.readFileSync(home.getACLFilePath(repoName), 'utf8') || JSON.stringify(DEFAULT_ACL)) +} + +module.exports = { + getUserRole, + grantAccessToUser, + makeRepoPublic, + makeRepoPrivate, + getRepoVisibility, + setACL, + getACL, + addProtectedBranch, + removeProtectedBranch, + getAdmins, + getContributors, + getViewers, +} diff --git a/src/home.js b/src/home.js index e2d8162..37d6036 100644 --- a/src/home.js +++ b/src/home.js @@ -14,6 +14,10 @@ function shareAppFolder (name) { fs.openSync(`${APP_HOME}/${name}/.git-daemon-export-ok`, 'w') } +function getACLFilePath (name) { + if (!fs.existsSync(`${APP_HOME}/${name}/.git-daemon-export-ok`)) throw new Error('Repo is not shared') + return `${APP_HOME}/${name}/.git-daemon-export-ok` +} function unshareAppFolder (name) { fs.unlinkSync(`${APP_HOME}/${name}/.git-daemon-export-ok`) @@ -27,10 +31,6 @@ function isShared (name) { return fs.existsSync(`${APP_HOME}/${name}/.git-daemon-export-ok`) } -function getACL (name) { - if (!fs.existsSync(`${APP_HOME}/${name}/.git-daemon-export-ok`)) throw new Error('Repo is not shared') -} - function list (sharedOnly) { const repos = fs.readdirSync(APP_HOME) if (!sharedOnly) return repos.filter(r => !r.startsWith('.') && isInitialized(r)) @@ -125,5 +125,5 @@ module.exports = { getDaemonPid, isDaemonRunning, removeDaemonPid, - getACL, + getACLFilePath } diff --git a/src/rpc.js b/src/rpc.js index b089e19..c8d754b 100755 --- a/src/rpc.js +++ b/src/rpc.js @@ -2,6 +2,7 @@ const ProtomuxRPC = require('protomux-rpc') const { spawn } = require('child_process') const home = require('./home') const auth = require('./auth') +const acl = require('./acl') module.exports = class RPC { constructor (announcedRefs, repositories, drives) { @@ -32,26 +33,47 @@ module.exports = class RPC { } async getReposHandler (publicKey, req) { - const { branch, url } = await this.parseReq(publicKey, req, 'r') + const { branch, url, userId } = await this.parseReq(publicKey, req) const res = {} for (const repoName in this.repositories) { // TODO: add only public repos and those which are shared with the peer // Alternatively return only requested repo - res[repoName] = this.drives[repoName].key.toString('hex') + const isPublic = (acl.getACL(repoName).visibility === 'public') + if (isPublic || acl.getViewers(repoName).includes(userId)) { + res[repoName] = this.drives[repoName].key.toString('hex') + } } return Buffer.from(JSON.stringify(res)) } async getRefsHandler (publicKey, req) { - const { repoName, branch, url } = await this.parseReq(publicKey, req, 'r') + const { repoName, branch, url, userId } = await this.parseReq(publicKey, req) const res = this.repositories[repoName] - return Buffer.from(JSON.stringify(res)) + const isPublic = (acl.getACL(repoName).visibility === 'public') + if (isPublic || acl.getViewers(repoName).includes(userId)) { + return Buffer.from(JSON.stringify(res)) + } else { + throw new Error('You are not allowed to access this repo') + } } async pushHandler (publicKey, req) { - const { url, repoName, branch } = await this.parseReq(publicKey, req, 'w') + const { url, repoName, branch, userId } = await this.parseReq(publicKey, req) + const isContributor = acl.getContributors(repoName).includes(userId) + + if (!isContributor) { + throw new Error('You are not allowed to push to this repo') + } + + const isProtectedBranch = acl.getACL(repoName).protectedBranches.includes(branch) + const isAdmin = acl.getAdmins(repoName).includes(userId) + + if (isProtectedBranch && !isAdmin) { + throw new Error('You are not allowed to push to this branch') + } + return await new Promise((resolve, reject) => { const env = { ...process.env, GIT_DIR: home.getCodePath(repoName) } const child = spawn('git', ['fetch', url, `${branch}:${branch}`], { env }) @@ -67,7 +89,20 @@ module.exports = class RPC { } async forcePushHandler (publicKey, req) { - const { url, repoName, branch } = await this.parseReq(publicKey, req, 'w') + const { url, repoName, branch, userId } = await this.parseReq(publicKey, req) + const isContributor = acl.getContributors(repoName).includes(userId) + + if (!isContributor) { + throw new Error('You are not allowed to push to this repo') + } + + const isProtectedBranch = acl.getACL(repoName).protectedBranches.includes(branch) + const isAdmin = acl.getAdmins(repoName).includes(userId) + + if (isProtectedBranch && !isAdmin) { + throw new Error('You are not allowed to push to this branch') + } + return await new Promise((resolve, reject) => { const env = { ...process.env, GIT_DIR: home.getCodePath(repoName) } const child = spawn('git', ['fetch', url, `${branch}:${branch}`, '--force'], { env }) @@ -83,7 +118,19 @@ module.exports = class RPC { } async deleteBranchHandler (publicKey, req) { - const { url, repoName, branch } = await this.parseReq(publicKey, req, 'w') + const { url, repoName, branch, userId } = await this.parseReq(publicKey, req) + const isContributor = acl.getContributors(repoName).includes(userId) + + if (!isContributor) { + throw new Error('You are not allowed to push to this repo') + } + + const isProtectedBranch = acl.getACL(repoName).protectedBranches.includes(branch) + const isAdmin = acl.getAdmins(repoName).includes(userId) + + if (isProtectedBranch && !isAdmin) { + throw new Error('You are not allowed to push to this branch') + } return await new Promise((resolve, reject) => { const env = { ...process.env, GIT_DIR: home.getCodePath(repoName) } const child = spawn('git', ['branch', '-D', branch], { env }) @@ -104,25 +151,19 @@ module.exports = class RPC { const parsed = { repoName: request.body.url?.split('/')?.pop(), branch: request.body.data?.split('#')[0], - url: request.body.url + url: request.body.url, + userId: await this.authenticate(publicKey, request), } - if (!process.env.GIT_PEAR_AUTH) return parsed + return parsed + } + async authenticate (publicKey, req) { + if (!process.env.GIT_PEAR_AUTH) return publicKey + if (process.env.GIT_PEAR_AUTH === 'naitive') return publicKey if (process.env.GIT_PEAR_AUTH !== 'naitive' && !request.header) { throw new Error('You are not allowed to access this repo') } - let userId - if (process.env.GIT_PEAR_AUTH === 'naitive') { - userId = publicKey - } else { - userId = (await auth.getId({ ...request.body, payload: request.header })).userId - } - const aclObj = home.getACL(parsed.repoName) - const userACL = aclObj[userId] || aclObj['*'] - if (!userACL) throw new Error('You are not allowed to access this repo') - - - return parsed + return (await auth.getId({ ...request.body, payload: request.header })).userId } } diff --git a/test/acl.test.js b/test/acl.test.js new file mode 100644 index 0000000..a52ef81 --- /dev/null +++ b/test/acl.test.js @@ -0,0 +1,59 @@ +const test = require('brittle') +const acl = require('../src/acl') + +test('acl', async t => { + const repoName = 'foo' + + t.test('setACL', async t => { + const aclObj = acl.setACL(repoName) + t.is(aclObj.visibility, 'public') + t.is(aclObj.protectedBranches.length, 1) + t.is(aclObj.protectedBranches[0], 'master') + t.is(Object.keys(aclObj.ACL).length, 0) + }) + + test('getACL', async t => { + const aclObj = acl.setACL(repoName) + t.alike(acl.getACL(repoName), aclObj) + }) + + test('makeRepoPublic', async t => { + acl.makeRepoPublic(repoName) + t.is(acl.getRepoVisibility(repoName), 'public') + }) + + test('makeRepoPrivate', async t => { + acl.makeRepoPrivate(repoName) + t.is(acl.getRepoVisibility(repoName), 'private') + }) + + test('grantAccessToUser', async t => { + acl.grantAccessToUser(repoName, 'user1', 'admin') + t.alike(acl.getUserRole(repoName, 'user1'), 'admin') + }) + + test('addProtectedBranch', async t => { + acl.addProtectedBranch(repoName, 'branch1') + t.is(acl.getACL(repoName).protectedBranches.length, 2) + }) + + test('removeProtectedBranch', async t => { + acl.removeProtectedBranch(repoName, 'branch1') + t.is(acl.getACL(repoName).protectedBranches.length, 1) + }) + + test('getAdmins', async t => { + acl.grantAccessToUser(repoName, 'user2', 'admin') + t.alike(acl.getAdmins(repoName), ['user1', 'user2']) + }) + + test('getContributors', async t => { + acl.grantAccessToUser(repoName, 'user3', 'contributor') + t.alike(acl.getContributors(repoName), ['user3', 'user1', 'user2']) + }) + + test('getViewers', async t => { + acl.grantAccessToUser(repoName, 'user4', 'viewer') + t.alike(acl.getViewers(repoName), ['user4', 'user3', 'user1', 'user2']) + }) +})