From 65f0f74390fb692d0c5aca684f19cb6b77386242 Mon Sep 17 00:00:00 2001 From: Travis Tripp Date: Fri, 8 Jul 2016 17:04:31 -0600 Subject: [PATCH] hz-dynamic-table: Column level permissions hz-dynamic-table doesn't have any way to dynamically determine whether or not to display a column. For example, in openstack dashboard, we may want to display a column only in certain policies pass check, if certain settings are enabled, or services are enabled. In addition, a generic allowed function can just be set on the column config. This patch adds this ability as a reusable service. The reusable service will make it possible for horizon to easily check common sets of permissions on a variety of objects including the hz-dynamic-table columns (in this patch). Child patches demonstrate its use on the images table. Change-Id: I9f92b69d86d830387a83c28ec5829fb3c43fc4a6 Partially-Implements: blueprint angularize-images-table Co-Authored-By: Matt Borland --- .../framework/conf/permissions.service.js | 158 ++++++++++++++++++ .../conf/permissions.service.spec.js | 119 +++++++++++++ .../table/hz-dynamic-table.directive.js | 52 +++++- .../widgets/table/hz-dynamic-table.html | 6 +- .../static/app/core/conf/conf.module.js | 138 +++++++++++++++ .../static/app/core/core.module.js | 1 + 6 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 horizon/static/framework/conf/permissions.service.js create mode 100644 horizon/static/framework/conf/permissions.service.spec.js create mode 100644 openstack_dashboard/static/app/core/conf/conf.module.js diff --git a/horizon/static/framework/conf/permissions.service.js b/horizon/static/framework/conf/permissions.service.js new file mode 100644 index 0000000000..9f71252708 --- /dev/null +++ b/horizon/static/framework/conf/permissions.service.js @@ -0,0 +1,158 @@ +/* + * (c) Copyright 2016 Hewlett Packard Enterprise Development LP + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +(function () { + 'use strict'; + + angular + .module('horizon.framework.conf') + .service('horizon.framework.conf.permissions.service', Permissions); + + Permissions.$inject = ['$q']; + + /** + * @ngdoc service + * @name horizon.framework.conf.permissions + * @module horizon.framework.conf + * + * This service provides a common set of a promise based permission checking that + * can be applied to various entities in horizon. In the most basic case, + * this code executes the allowed() function (if it exists) on + * items passed in and returns the promise which will either resolve + * if the item should be allowed or reject if it should not be allowed. + * An example of an item passed in might be a hz-dynamic-table column spec. + * See that code for an example of permissionService in use. + * + * Horizon is technically a set of application agnostic widgets and utilities + * that can be used for various applications. The OpenStack Dashboard is + * an implementation that uses these widgets. Many of these widgets, such + * as hz-dynamic-table may need to be displayed based on a variety of permissions + * that are application specific and that are more simply expressed in a declarative + * matter rather than functional code such as the allowed function. This service + * provides this capability by allowing applications to decorate this service and + * override the extendedPermissions function. + * + * In the openstack dashboard additional checks beyond the simple allowed() function + * are added to this service in the app.core.conf permissionsDecorator, + * including policies, services and settings. + */ + function Permissions($q) { + var service = { + checkAllowed: checkAllowed, + checkAll: checkAll, + extendedPermissions: extendedPermissions + }; + + return service; + + /** + * If this input configItem has an allowed function, this will execute it and return + * the promise. Otherwise it will return a resolved promise. + */ + function checkAllowed(configItem) { + if (angular.isFunction(configItem.allowed)) { + return configItem.allowed(); + } else { + return $q.when(true); + } + } + + /** + * On the given configItem, this will get configItem.allowed and get each + * additional property matching a permission defined in extended permissions. + * All of the promises will then be run using $q.all. If all promises resolve + * then this will resolve. Otherwise, this will reject. + * + * When the input to any given permission is an array, each element will be + * treated as a distinct permission and a single promise resolver will be invoked for + * each input element. + * + * @example + * + * In openstack dashboard, each of the policies in the below array will result in a distinct + * policy check and all of the policies must pass in order for this to permit (resolve) the + * permissions check. + * + * configItem.policies = [{ rules: [["identity", "identity:get_project"]] }, { }] + */ + function checkAll(configItem) { + var promises = []; + promises = promises.concat(getConfigurationPromises(configItem)); + promises.push(checkAllowed(configItem)); + return $q.all(promises); + } + + /* + * This defines all additional permissions that can be defined on the input configItem + * of checkAll. This is a function that returns an object definition where + * each property on the object is the name of a permission that can be set on + * the configItem in order have permission to access / use whatever is being requested. + * The value of each property should be a promise that can resolve a single instance of that + * permission. When the input to any given permission is an array, each element will be + * treated as a distinct input and a single promise resolver will be invoked for + * each input element. + * + * Since this is part of the horizon framework, it defaults to returning an empty object. + * However, individual applications can decorate this service and override this + * function to provide common permissions for their application. + * + * @example + * + * Openstack Dashboard may set permissions for required policies, services, or settings + * (amongst other items). To do this, the service would be decorated like the following: + * + function decorator($delegate, policy, serviceCatalog, settings) { + var permissionsService = $delegate; + + permissionsService.extendedPermissions = extendedPermissions; + + function extendedPermissions() { + return { + policies: policy.ifAllowed, + services: serviceCatalog.ifTypeEnabled, + settings: settings.ifEnabled + }; + } + + return $delegate; + } + } + */ + function extendedPermissions() { + return {}; + } + + function getConfigurationPromises(configItem) { + + var promises = []; + + angular.forEach(service.extendedPermissions, checkPermissions); + + function checkPermissions(permissionResolver, permissionName) { + var permissionInput = configItem[permissionName]; + if (angular.isArray(permissionInput)) { + angular.forEach(permissionInput, function addPermissionCheck(singlePermissionInput) { + promises.push(permissionResolver(singlePermissionInput)); + }); + } else if (angular.isDefined(permissionInput)) { + promises.push(permissionResolver(permissionInput)); + } + } + + return promises; + } + + } +})(); diff --git a/horizon/static/framework/conf/permissions.service.spec.js b/horizon/static/framework/conf/permissions.service.spec.js new file mode 100644 index 0000000000..d1104b7ad6 --- /dev/null +++ b/horizon/static/framework/conf/permissions.service.spec.js @@ -0,0 +1,119 @@ +/* + * (c) Copyright 2016 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function() { + 'use strict'; + + describe('permissions service', function() { + var service; + + beforeEach(module('horizon.framework.conf')); + beforeEach(inject(function($injector) { + service = $injector.get('horizon.framework.conf.permissions.service'); + })); + + it("is defined", function() { + expect(service).toBeDefined(); + }); + + describe("checkAllowed", function() { + + it("returns rejected promise returned by configItem.allowed", inject(function($q, $timeout) { + var deferred = $q.defer(); + deferred.reject(); + var item = {allowed: function() { return deferred.promise; }}; + service.checkAllowed(item).then(fail, pass); + $timeout.flush(); + })); + + it("returns resolved promise returned by configItem.allowed", inject(function($q, $timeout) { + var deferred = $q.defer(); + deferred.resolve({}); + var item = {allowed: function() { return deferred.promise; }}; + service.checkAllowed(item).then(pass, fail); + $timeout.flush(); + })); + + it("returns resolved promise when no configItem.allowed", inject(function($q, $timeout) { + var item = {}; + service.checkAllowed(item).then(pass, fail); + $timeout.flush(); + })); + + }); + + describe("checkAll", function() { + describe("with extended permissions", function() { + beforeEach(inject(function($q) { + var resolver = function() { return $q.defer().promise; }; + service.extendedPermissions = { perm1: resolver }; + })); + + it("with promise array, adds checks for permissions", inject(function($q) { + var input = {perm1: [$q.defer().promise]}; + service.checkAll(input).then(verifyResult); + function verifyResult(result) { + expect(angular.isArray(result)).toBe(true); + expect(result.length).toBe(1); + } + })); + + it("with promise, adds checks for permissions", inject(function($q) { + var input = {perm1: $q.defer().promise}; + service.checkAll(input).then(verifyResult); + function verifyResult(result) { + expect(angular.isArray(result)).toBe(true); + expect(result.length).toBe(1); + } + })); + + it("with no promise, adds checks for permissions", inject(function($q) { + var input = {unlisted: $q.defer().promise}; + service.checkAll(input).then(verifyResult); + function verifyResult(result) { + expect(angular.isArray(result)).toBe(true); + expect(result.length).toBe(1); + } + })); + }); + + it("without extended permissions it returns no promises", inject(function($q) { + var retval = service.checkAll({perm1: [$q.defer().promise]}); + retval.then(verifyResult); + function verifyResult(result) { + expect(angular.isArray(result)).toBe(true); + expect(result.length).toBe(0); + } + })); + }); + + describe("extendedPermissions", function() { + it("defaults to returning no permissions", function() { + expect(service.extendedPermissions()).toEqual({}); + }); + + }); + + }); + + function pass() { + expect(true).toBe(true); + } + + function fail() { + expect(true).toBe(false); + } +})(); diff --git a/horizon/static/framework/widgets/table/hz-dynamic-table.directive.js b/horizon/static/framework/widgets/table/hz-dynamic-table.directive.js index 2d80540b7c..3de351819a 100644 --- a/horizon/static/framework/widgets/table/hz-dynamic-table.directive.js +++ b/horizon/static/framework/widgets/table/hz-dynamic-table.directive.js @@ -13,14 +13,17 @@ * License for the specific language governing permissions and limitations * under the License. */ -(function() { +(function () { 'use strict'; angular .module('horizon.framework.widgets.table') .directive('hzDynamicTable', hzDynamicTable); - hzDynamicTable.$inject = ['horizon.framework.widgets.basePath']; + hzDynamicTable.$inject = [ + 'horizon.framework.widgets.basePath', + 'horizon.framework.conf.permissions.service' + ]; /** * @ngdoc directive @@ -52,8 +55,20 @@ * not provided, the default message is used. * columns {Array} of objects to describe each column. Each object * requires: 'id', 'title', 'priority' (responsive priority when table resized) - * optional: 'sortDefault', 'filters' (to apply to the column cells), - * 'template' (see hz-cell directive for details) + * optional: 'sortDefault', + * 'filters' (to apply to the column cells), + * 'template' (see hz-cell directive for details), + * 'allowed' (a promise that must resolve in order for the column to be viewed), + * + * This directive provides an extension point for applications to decorate additional declarative + * column level permissions that must be fulfilled in order for the column to be viewed. For + * example, openstack dashboard adds the following optional declarative permissions: + * 'services' (OpenStack services that must be enabled in the current region), + * 'settings' (horizon settings that must be enabled) + * 'policies' (policy rules that must be allowed) + * + * This is accomplished by decorating the 'horizon.framework.conf.permissions' service. + * See that service for more information. * * @example * @@ -66,6 +81,7 @@ * {id: 'b', title: 'B', priority: 2}, * {id: 'c', title: 'C', priority: 1, sortDefault: true}, * {id: 'd', title: 'D', priority: 2, filters: [myFilter, 'yesno']} + * {id: 'e', title: 'E', allowed: allowedPromiseFunction} * ] * }; * ``` @@ -86,7 +102,7 @@ * ``` * */ - function hzDynamicTable(basePath) { + function hzDynamicTable(basePath, permissionsService) { // : there are some configuration items which are on the directive, // and some on the "config" attribute of the directive. Those latter configuration @@ -114,6 +130,8 @@ return directive; function preLink(scope) { + //Isolate config changes we do here from propagating out. + scope.config = angular.copy(scope.config); scope.items = []; } @@ -125,6 +143,30 @@ if (angular.isUndefined(scope.config.expand)) { scope.config.expand = true; } + + setColumnPermitted(scope.config.columns); + } + + function setColumnPermitted(columns) { + + angular.forEach(columns, checkPermissions); + + function checkPermissions(column) { + if (column.permitted === true || column.permitted === false) { + // No need to check again + return; + } else { + permissionsService.checkAll(column).then(allow, disallow); + } + + function allow() { + column.permitted = true; + } + + function disallow() { + column.permitted = false; + } + } } } })(); diff --git a/horizon/static/framework/widgets/table/hz-dynamic-table.html b/horizon/static/framework/widgets/table/hz-dynamic-table.html index b92d29add9..7a7a2333dc 100644 --- a/horizon/static/framework/widgets/table/hz-dynamic-table.html +++ b/horizon/static/framework/widgets/table/hz-dynamic-table.html @@ -38,7 +38,8 @@ class="rsp-p{$ column.priority $}" st-sort="{$ column.id $}" ng-attr-st-sort-default="{$ column.sortDefault $}" - translate> + translate + ng-if="column.permitted"> {$ column.title $} @@ -69,7 +70,8 @@ + class="rsp-p{$ column.priority $}" + ng-if="column.permitted"> diff --git a/openstack_dashboard/static/app/core/conf/conf.module.js b/openstack_dashboard/static/app/core/conf/conf.module.js new file mode 100644 index 0000000000..aa51173128 --- /dev/null +++ b/openstack_dashboard/static/app/core/conf/conf.module.js @@ -0,0 +1,138 @@ +/** + * (c) Copyright 2016 Hewlett-Packard Development Company, L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. You may obtain + * a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +(function () { + 'use strict'; + + /** + * @ngdoc overview + * @ngname horizon.app.core.conf + * + * @description + * Provides features that support app configuration. + */ + angular + .module('horizon.app.core.conf', ['horizon.framework.conf']) + .config(permissionsDecorator); + + permissionsDecorator.$inject = [ + '$provide' + ]; + + /** + * @name permissionsDecorator + * @param {Object} $provide + * @description + * This decorates the horizon.framework.conf.permissions service to + * support declarative level permissions based on openstack_dashboard requirements. + * This adds the following declarative permissions: + * 'services' (OpenStack services that must be enabled in the current region), + * 'settings' (OpenStack Dashboard settings that must be enabled) + * 'policies' (policy rules that must be allowed) + * + * For example, these may all be used with the registry service and table column registrations. + * They are passed through to hz-dynamic-table which asks the permissions service to check + * the column's permissions before displaying each column. Below is a pseudo example where you + * want to show the instance compute host only if the user is an admin. So you'd create a policy + * rule for checking if the user is an admin and add that check here. Or you'd want to only + * show the volume column if Cinder (the volume service) is enabled. + * + * registry.getResourceType('OS::Nova::Server') + .tableColumns + .append({ + id: 'host', + priority: 1, + policies: [{rules: [['compute', 'compute:is_admin']]}] + }) + .append({ + id: 'volumes', + priority: 2, + services: "volume" + }) + * + * 'policies' may be set to a single object or an array of rules objects. + * + * All of the following are examples: + * + * policies = { rules: [["identity", "identity:get_project"]] } + * policies = [{ rules: [["identity", "identity:get_project"]] }, { }] + * + * 'services' may by set to a single service type or an array of service types. + * This may be done inline or may come from an object in scope. + * + * All of the following are valid examples: + * + * services="network" + * services=["network"] + * services=["network", "metering"] + * + * 'settings' Additional info. In local_settings.py allows you to specify settings such as: + * + * OPENSTACK_HYPERVISOR_FEATURES = { + * 'can_set_mount_point': True, + * 'can_set_password': False, + * } + * + * To access a specific setting, use a simplified path where a . (dot) + * separates elements in the path. So in the above example, the paths + * would be: + * + * OPENSTACK_HYPERVISOR_FEATURES.can_set_mount_point + * OPENSTACK_HYPERVISOR_FEATURES.can_set_password + * + * The `settings` attribute may be set to a single setting path + * or an array of setting paths. All of the following are examples: + * + * settings="SETTING_GROUP.my_setting_1" + * settings=["SETTING_GROUP.my_setting_1"] + * settings=["SETTING_GROUP.my_setting_1", "SETTING_GROUP.my_setting_2"] + * + * The desired setting must be listed in one of the two following locations + * in settings.py or local_settings.py in order for it to be available + * to the client side for evaluation. If it is not, it will always evaluate + * to false. + * + * REST_API_REQUIRED_SETTINGS + * REST_API_REQUIRED_SETTINGS + * + * This directive currently only supports settings that are set to + * true or false. So currently, you only need to provide the path to + * the setting. Future enhancements should allow for alternatively providing + * an object or array of objects with the path and expected value: + * {path:"SOME_setting_1", expected:"1.0"}. + */ + function permissionsDecorator($provide) { + $provide.decorator('horizon.framework.conf.permissions.service', + ['$delegate', + 'horizon.app.core.openstack-service-api.policy', + 'horizon.app.core.openstack-service-api.serviceCatalog', + 'horizon.app.core.openstack-service-api.settings', + decorator]); + + function decorator($delegate, policy, serviceCatalog, settings) { + var permissionsService = $delegate; + + permissionsService.extendedPermissions = { + policies: policy.ifAllowed, + services: serviceCatalog.ifTypeEnabled, + settings: settings.ifEnabled + }; + + return $delegate; + } + } + +})(); diff --git a/openstack_dashboard/static/app/core/core.module.js b/openstack_dashboard/static/app/core/core.module.js index 7c367275d4..bf8a52d86a 100644 --- a/openstack_dashboard/static/app/core/core.module.js +++ b/openstack_dashboard/static/app/core/core.module.js @@ -31,6 +31,7 @@ */ angular .module('horizon.app.core', [ + 'horizon.app.core.conf', 'horizon.app.core.cloud-services', 'horizon.app.core.images', 'horizon.app.core.metadata',