1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00

feature: Add action specific user permissions

This commit is contained in:
Benjamin Ludewig 2018-12-19 10:36:56 +01:00 committed by Ivar Conradi Østhus
parent e256db29a5
commit df7509e381
15 changed files with 312 additions and 57 deletions

45
lib/permissions.js Normal file
View File

@ -0,0 +1,45 @@
'use strict';
const ADMIN = 'ADMIN';
const REVIVE_FEATURE = 'REVIVE_FEATURE';
const CREATE_FEATURE = 'CREATE_FEATURE';
const UPDATE_FEATURE = 'UPDATE_FEATURE';
const DELETE_FEATURE = 'DELETE_FEATURE';
const CREATE_STRATEGY = 'CREATE_STRATEGY';
const UPDATE_STRATEGY = 'UPDATE_STRATEGY';
const DELETE_STRATEGY = 'DELETE_STRATEGY';
const UPDATE_APPLICATION = 'UPDATE_APPLICATION';
function requirePerms(prms) {
return (req, res, next) => {
for (const permission of prms) {
if (
req.user &&
req.user.permissions &&
(req.user.permissions.indexOf(ADMIN) !== -1 ||
req.user.permissions.indexOf(permission) !== -1)
) {
return next();
}
}
return res
.status(403)
.json({
message: 'Missing permissions to perform this action.',
})
.end();
};
}
module.exports = {
requirePerms,
ADMIN,
REVIVE_FEATURE,
CREATE_FEATURE,
UPDATE_FEATURE,
DELETE_FEATURE,
CREATE_STRATEGY,
UPDATE_STRATEGY,
DELETE_STRATEGY,
UPDATE_APPLICATION,
};

78
lib/permissions.test.js Normal file
View File

@ -0,0 +1,78 @@
'use strict';
const test = require('ava');
const store = require('./../test/fixtures/store');
const { requirePerms } = require('./permissions');
const supertest = require('supertest');
const getApp = require('./app');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup(preRouterHook) {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
extendedPermissions: true,
preRouterHook(_app) {
preRouterHook(_app);
_app.get(
`${base}/protectedResource`,
requirePerms(['READ']),
(req, res) => {
res.status(200)
.json({ message: 'OK' })
.end();
}
);
},
});
return {
base,
request: supertest(app),
};
}
test('should return 403 when missing permission', t => {
t.plan(0);
const { base, request } = getSetup(() => {});
return request.get(`${base}/protectedResource`).expect(403);
});
test('should allow access with correct permissions', t => {
const { base, request } = getSetup(app => {
app.use((req, res, next) => {
req.user = { email: 'some@email.com', permissions: ['READ'] };
next();
});
});
return request
.get(`${base}/protectedResource`)
.expect(200)
.expect(res => {
t.is(res.body.message, 'OK');
});
});
test('should allow access with admin permissions', t => {
const { base, request } = getSetup(app => {
app.use((req, res, next) => {
req.user = { email: 'some@email.com', permissions: ['ADMIN'] };
next();
});
});
return request
.get(`${base}/protectedResource`)
.expect(200)
.expect(res => {
t.is(res.body.message, 'OK');
});
});

View File

@ -5,15 +5,16 @@ const Controller = require('../controller');
const logger = require('../../logger')('/admin-api/archive.js');
const { FEATURE_REVIVED } = require('../../event-type');
const extractUser = require('../../extract-user');
const { REVIVE_FEATURE } = require('../../permissions');
class ArchiveController extends Controller {
constructor({ featureToggleStore, eventStore }) {
super();
constructor(extendedPerms, { featureToggleStore, eventStore }) {
super(extendedPerms);
this.featureToggleStore = featureToggleStore;
this.eventStore = eventStore;
this.get('/features', this.getArchivedFeatures);
this.post('/revive/:name', this.reviveFeatureToggle);
this.post('/revive/:name', this.reviveFeatureToggle, REVIVE_FEATURE);
}
async getArchivedFeatures(req, res) {

View File

@ -2,8 +2,10 @@
const test = require('ava');
const store = require('./../../../test/fixtures/store');
const permissions = require('../../../test/fixtures/permissions');
const supertest = require('supertest');
const getApp = require('../../app');
const { REVIVE_FEATURE } = require('../../permissions');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
@ -11,14 +13,18 @@ const eventBus = new EventEmitter();
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const perms = permissions();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
extendedPermissions: true,
preRouterHook: perms.hook,
});
return {
base,
perms,
archiveStore: stores.featureToggleStore,
eventStore: stores.eventStore,
request: supertest(app),
@ -60,7 +66,8 @@ test('should get archived toggles via admin', t => {
test('should revive toggle', t => {
t.plan(0);
const name = 'name1';
const { request, base, archiveStore } = getSetup();
const { request, base, archiveStore, perms } = getSetup();
perms.withPerms(REVIVE_FEATURE);
archiveStore.addArchivedFeature({
name,
strategies: [{ name: 'default' }],
@ -72,7 +79,8 @@ test('should revive toggle', t => {
test('should create event when reviving toggle', async t => {
t.plan(4);
const name = 'name1';
const { request, base, archiveStore, eventStore } = getSetup();
const { request, base, archiveStore, eventStore, perms } = getSetup();
perms.withPerms(REVIVE_FEATURE);
archiveStore.addArchivedFeature({
name,
strategies: [{ name: 'default' }],

View File

@ -11,22 +11,27 @@ const {
const NameExistsError = require('../../error/name-exists-error');
const { handleErrors } = require('./util');
const extractUser = require('../../extract-user');
const {
UPDATE_FEATURE,
DELETE_FEATURE,
CREATE_FEATURE,
} = require('../../permissions');
const { featureShema, nameSchema } = require('./feature-schema');
const version = 1;
class FeatureController extends Controller {
constructor({ featureToggleStore, eventStore }) {
super();
constructor(extendedPerms, { featureToggleStore, eventStore }) {
super(extendedPerms);
this.featureToggleStore = featureToggleStore;
this.eventStore = eventStore;
this.get('/', this.getAllToggles);
this.post('/', this.createToggle);
this.post('/', this.createToggle, CREATE_FEATURE);
this.get('/:featureName', this.getToggle);
this.put('/:featureName', this.updateToggle);
this.delete('/:featureName', this.deleteToggle);
this.put('/:featureName', this.updateToggle, UPDATE_FEATURE);
this.delete('/:featureName', this.deleteToggle, DELETE_FEATURE);
this.post('/validate', this.validate);
this.post('/:featureName/toggle', this.toggle);
this.post('/:featureName/toggle', this.toggle, UPDATE_FEATURE);
}
async getAllToggles(req, res) {

View File

@ -2,8 +2,10 @@
const test = require('ava');
const store = require('./../../../test/fixtures/store');
const permissions = require('../../../test/fixtures/permissions');
const supertest = require('supertest');
const getApp = require('../../app');
const { UPDATE_FEATURE, CREATE_FEATURE } = require('../../permissions');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
@ -11,14 +13,18 @@ const eventBus = new EventEmitter();
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = store.createStores();
const perms = permissions();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
extendedPermissions: true,
preRouterHook: perms.hook,
});
return {
base,
perms,
featureToggleStore: stores.featureToggleStore,
request: supertest(app),
};
@ -72,7 +78,8 @@ test('should add version numbers for /features', t => {
test('should require at least one strategy when creating a feature toggle', t => {
t.plan(0);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_FEATURE);
return request
.post(`${base}/api/admin/features`)
@ -83,7 +90,8 @@ test('should require at least one strategy when creating a feature toggle', t =>
test('should be allowed to use new toggle name', t => {
t.plan(0);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_FEATURE);
return request
.post(`${base}/api/admin/features/validate`)
@ -136,7 +144,8 @@ test('should not be allowed to reuse archived toggle name', t => {
test('should require at least one strategy when updating a feature toggle', t => {
t.plan(0);
const { request, featureToggleStore, base } = getSetup();
const { request, featureToggleStore, base, perms } = getSetup();
perms.withPerms(UPDATE_FEATURE);
featureToggleStore.addFeature({
name: 'ts',
strategies: [{ name: 'default' }],
@ -151,7 +160,8 @@ test('should require at least one strategy when updating a feature toggle', t =>
test('valid feature names should pass validation', t => {
t.plan(0);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_FEATURE);
const validNames = [
'com.example',
@ -179,7 +189,8 @@ test('valid feature names should pass validation', t => {
test('invalid feature names should not pass validation', t => {
t.plan(0);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_FEATURE);
const invalidNames = [
'some example',
@ -207,7 +218,8 @@ test('invalid feature names should not pass validation', t => {
// Make sure current UI works. Should align on joi errors in future.
test('invalid feature names should have error msg', t => {
t.plan(1);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_FEATURE);
const name = 'ØÆ`';

View File

@ -14,13 +14,17 @@ class AdminApi extends Controller {
super();
const stores = config.stores;
const perms = config.extendedPermissions;
this.app.get('/', this.index);
this.app.use('/features', new FeatureController(stores).router);
this.app.use('/archive', new ArchiveController(stores).router);
this.app.use('/strategies', new StrategyController(stores).router);
this.app.use('/features', new FeatureController(perms, stores).router);
this.app.use('/archive', new ArchiveController(perms, stores).router);
this.app.use(
'/strategies',
new StrategyController(perms, stores).router
);
this.app.use('/events', new EventController(stores).router);
this.app.use('/metrics', new MetricsController(stores).router);
this.app.use('/metrics', new MetricsController(perms, stores).router);
this.app.use('/user', new UserController().router);
}

View File

@ -0,0 +1,22 @@
'use strict';
const joi = require('joi');
const { nameType } = require('./util');
const applicationSchema = joi
.object()
.options({ stripUnknown: false })
.keys({
appName: nameType,
sdkVersion: joi.string().optional(),
strategies: joi
.array()
.required()
.items(joi.string(), joi.any().strip()),
description: joi.string().optional(),
url: joi.string().optional(),
color: joi.string().optional(),
icon: joi.string().optional(),
});
module.exports = applicationSchema;

View File

@ -1,18 +1,24 @@
'use strict';
const joi = require('joi');
const Controller = require('../controller');
const logger = require('../../logger')('/admin-api/metrics.js');
const ClientMetrics = require('../../client-metrics');
const schema = require('./metrics-schema');
const { UPDATE_APPLICATION } = require('../../permissions');
class MetricsController extends Controller {
constructor({
clientMetricsStore,
clientInstanceStore,
clientApplicationsStore,
strategyStore,
featureToggleStore,
}) {
super();
constructor(
extendedPerms,
{
clientMetricsStore,
clientInstanceStore,
clientApplicationsStore,
strategyStore,
featureToggleStore,
}
) {
super(extendedPerms);
this.metrics = new ClientMetrics(clientMetricsStore);
this.clientInstanceStore = clientInstanceStore;
this.clientApplicationsStore = clientApplicationsStore;
@ -23,7 +29,11 @@ class MetricsController extends Controller {
this.get('/seen-apps', this.getSeenApps);
this.get('/feature-toggles', this.getFeatureToggles);
this.get('/feature-toggles/:name', this.getFeatureToggle);
this.post('/applications/:appName', this.createApplication);
this.post(
'/applications/:appName',
this.createApplication,
UPDATE_APPLICATION
);
this.get('/applications/', this.getApplications);
this.get('/applications/:appName', this.getApplication);
}
@ -67,14 +77,19 @@ class MetricsController extends Controller {
});
}
// Todo: add joi-schema validation
async createApplication(req, res) {
const input = Object.assign({}, req.body, {
appName: req.params.appName,
});
const { value: applicationData, error } = joi.validate(input, schema);
if (error) {
logger.warn('Invalid application data posted', error);
return res.status(400).json(error);
}
try {
await this.clientApplicationsStore.upsert(input);
await this.clientApplicationsStore.upsert(applicationData);
res.status(202).end();
} catch (err) {
logger.error(err);

View File

@ -2,23 +2,29 @@
const test = require('ava');
const store = require('./../../../test/fixtures/store');
const permissions = require('../../../test/fixtures/permissions');
const supertest = require('supertest');
const getApp = require('../../app');
const { UPDATE_APPLICATION } = require('../../permissions');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup() {
const stores = store.createStores();
const perms = permissions();
const app = getApp({
baseUriPath: '',
stores,
eventBus,
extendedPermissions: true,
preRouterHook: perms.hook,
});
return {
request: supertest(app),
stores,
perms,
};
}
@ -125,11 +131,12 @@ test('should return applications', t => {
test('should store application', t => {
t.plan(0);
const { request } = getSetup();
const { request, perms } = getSetup();
const appName = '123!23';
perms.withPerms(UPDATE_APPLICATION);
return request
.post(`/api/admin/metrics/applications/${appName}`)
.send({ appName })
.send({ appName, strategies: ['default'] })
.expect(202);
});

View File

@ -8,19 +8,24 @@ const NameExistsError = require('../../error/name-exists-error');
const extractUser = require('../../extract-user');
const strategySchema = require('./strategy-schema');
const { handleErrors } = require('./util');
const {
DELETE_STRATEGY,
CREATE_STRATEGY,
UPDATE_STRATEGY,
} = require('../../permissions');
const version = 1;
class StrategyController extends Controller {
constructor({ strategyStore, eventStore }) {
super();
constructor(extendedPerms, { strategyStore, eventStore }) {
super(extendedPerms);
this.strategyStore = strategyStore;
this.eventStore = eventStore;
this.get('/', this.getAllStratgies);
this.get('/:name', this.getStrategy);
this.delete('/:name', this.removeStrategy);
this.post('/', this.createStrategy);
this.put('/:strategyName', this.updateStrategy);
this.delete('/:name', this.removeStrategy, DELETE_STRATEGY);
this.post('/', this.createStrategy, CREATE_STRATEGY);
this.put('/:strategyName', this.updateStrategy, UPDATE_STRATEGY);
}
async getAllStratgies(req, res) {

View File

@ -2,25 +2,35 @@
const test = require('ava');
const store = require('./../../../test/fixtures/store');
const permissions = require('../../../test/fixtures/permissions');
const supertest = require('supertest');
const getApp = require('../../app');
const {
DELETE_STRATEGY,
CREATE_STRATEGY,
UPDATE_STRATEGY,
} = require('../../permissions');
const { EventEmitter } = require('events');
const eventBus = new EventEmitter();
function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
const perms = permissions();
const stores = store.createStores();
const app = getApp({
baseUriPath: base,
stores,
eventBus,
extendedPermissions: true,
preRouterHook: perms.hook,
});
return {
base,
strategyStore: stores.strategyStore,
request: supertest(app),
perms,
};
}
@ -39,7 +49,8 @@ test('add version numbers for /stategies', t => {
test('require a name when creating a new stratey', t => {
t.plan(1);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_STRATEGY);
return request
.post(`${base}/api/admin/strategies`)
@ -52,7 +63,8 @@ test('require a name when creating a new stratey', t => {
test('require parameters array when creating a new stratey', t => {
t.plan(1);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_STRATEGY);
return request
.post(`${base}/api/admin/strategies`)
@ -65,7 +77,8 @@ test('require parameters array when creating a new stratey', t => {
test('create a new stratey with empty parameters', t => {
t.plan(0);
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(CREATE_STRATEGY);
return request
.post(`${base}/api/admin/strategies`)
@ -75,7 +88,8 @@ test('create a new stratey with empty parameters', t => {
test('not be possible to override name', t => {
t.plan(0);
const { request, base, strategyStore } = getSetup();
const { request, base, strategyStore, perms } = getSetup();
perms.withPerms(CREATE_STRATEGY);
strategyStore.addStrategy({ name: 'Testing', parameters: [] });
return request
@ -87,7 +101,8 @@ test('not be possible to override name', t => {
test('update strategy', t => {
t.plan(0);
const name = 'AnotherStrat';
const { request, base, strategyStore } = getSetup();
const { request, base, strategyStore, perms } = getSetup();
perms.withPerms(UPDATE_STRATEGY);
strategyStore.addStrategy({ name, parameters: [] });
return request
@ -96,10 +111,11 @@ test('update strategy', t => {
.expect(200);
});
test('not update uknown strategy', t => {
test('not update unknown strategy', t => {
t.plan(0);
const name = 'UnknownStrat';
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(UPDATE_STRATEGY);
return request
.put(`${base}/api/admin/strategies/${name}`)
@ -110,7 +126,8 @@ test('not update uknown strategy', t => {
test('validate format when updating strategy', t => {
t.plan(0);
const name = 'AnotherStrat';
const { request, base, strategyStore } = getSetup();
const { request, base, strategyStore, perms } = getSetup();
perms.withPerms(UPDATE_STRATEGY);
strategyStore.addStrategy({ name, parameters: [] });
return request
@ -122,7 +139,8 @@ test('validate format when updating strategy', t => {
test('editable=false will stop delete request', t => {
t.plan(0);
const name = 'default';
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(DELETE_STRATEGY);
return request.delete(`${base}/api/admin/strategies/${name}`).expect(500);
});
@ -130,7 +148,8 @@ test('editable=false will stop delete request', t => {
test('editable=false will stop edit request', t => {
t.plan(0);
const name = 'default';
const { request, base } = getSetup();
const { request, base, perms } = getSetup();
perms.withPerms(UPDATE_STRATEGY);
return request
.put(`${base}/api/admin/strategies/${name}`)
@ -141,7 +160,8 @@ test('editable=false will stop edit request', t => {
test('editable=true will allow delete request', t => {
t.plan(0);
const name = 'deleteStrat';
const { request, base, strategyStore } = getSetup();
const { request, base, strategyStore, perms } = getSetup();
perms.withPerms(DELETE_STRATEGY);
strategyStore.addStrategy({ name, parameters: [] });
return request
@ -153,7 +173,8 @@ test('editable=true will allow delete request', t => {
test('editable=true will allow edit request', t => {
t.plan(0);
const name = 'editStrat';
const { request, base, strategyStore } = getSetup();
const { request, base, strategyStore, perms } = getSetup();
perms.withPerms(UPDATE_STRATEGY);
strategyStore.addStrategy({ name, parameters: [] });
return request

View File

@ -1,28 +1,42 @@
'use strict';
const { Router } = require('express');
const { requirePerms } = require('./../permissions');
/**
* Base class for Controllers to standardize binding to express Router.
*/
class Controller {
constructor() {
constructor(extendedPerms) {
const router = Router();
this.app = router;
this.extendedPerms = extendedPerms;
}
get(path, handler) {
get(path, handler, ...perms) {
if (this.extendedPerms && perms.length > 0) {
this.app.get(path, requirePerms(perms), handler.bind(this));
}
this.app.get(path, handler.bind(this));
}
post(path, handler) {
post(path, handler, ...perms) {
if (this.extendedPerms && perms.length > 0) {
this.app.post(path, requirePerms(perms), handler.bind(this));
}
this.app.post(path, handler.bind(this));
}
put(path, handler) {
put(path, handler, ...perms) {
if (this.extendedPerms && perms.length > 0) {
this.app.put(path, requirePerms(perms), handler.bind(this));
}
this.app.put(path, handler.bind(this));
}
delete(path, handler) {
delete(path, handler, ...perms) {
if (this.extendedPerms && perms.length > 0) {
this.app.delete(path, requirePerms(perms), handler.bind(this));
}
this.app.delete(path, handler.bind(this));
}

View File

@ -4,7 +4,7 @@ const gravatar = require('gravatar');
const Joi = require('joi');
module.exports = class User {
constructor({ name, email, imageUrl } = {}) {
constructor({ name, email, permissions, imageUrl } = {}) {
Joi.assert(
email,
Joi.string()
@ -14,6 +14,7 @@ module.exports = class User {
);
this.email = email;
this.name = name;
this.permissions = permissions;
this.imageUrl =
imageUrl || gravatar.url(email, { s: '42', d: 'retro' });
}

17
test/fixtures/permissions.js vendored Normal file
View File

@ -0,0 +1,17 @@
'use strict';
module.exports = () => {
let _perms = [];
return {
hook(app) {
app.use((req, res, next) => {
if (req.user) req.user.permissions = _perms;
else req.user = { email: 'unknown', permissions: _perms };
next();
});
},
withPerms(...prms) {
_perms = prms;
},
};
};