diff --git a/bundles.json b/bundles.json index 2d900e8529..fe211af285 100644 --- a/bundles.json +++ b/bundles.json @@ -14,6 +14,7 @@ "platform/forms", "platform/persistence/queue", "platform/persistence/elastic", + "platform/policy", "example/generator" ] diff --git a/example/policy/bundle.json b/example/policy/bundle.json new file mode 100644 index 0000000000..cec350ffd0 --- /dev/null +++ b/example/policy/bundle.json @@ -0,0 +1,12 @@ +{ + "name": "Example Policy", + "description": "Provides an example of using policies to prohibit actions.", + "extensions": { + "policies": [ + { + "implementation": "ExamplePolicy.js", + "category": "action" + } + ] + } +} \ No newline at end of file diff --git a/example/policy/src/ExamplePolicy.js b/example/policy/src/ExamplePolicy.js new file mode 100644 index 0000000000..ba9fd68812 --- /dev/null +++ b/example/policy/src/ExamplePolicy.js @@ -0,0 +1,26 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + function ExamplePolicy() { + return { + /** + * Disallow the Remove action on objects whose name contains + * "foo." + */ + allow: function (action, context) { + var domainObject = (context || {}).domainObject, + model = (domainObject && domainObject.getModel()) || {}, + name = model.name || "", + metadata = action.getMetadata() || {}; + return metadata.key !== 'remove' || name.indexOf('foo') < 0; + } + }; + } + + return ExamplePolicy; + } +); \ No newline at end of file diff --git a/platform/policy/README.md b/platform/policy/README.md new file mode 100644 index 0000000000..a35a9a8d3e --- /dev/null +++ b/platform/policy/README.md @@ -0,0 +1,93 @@ +# Overview + +This bundle provides support for policy in Open MCT Web. Policy can be +used to limit the applicability of certain actions, or more broadly, +to provide an extension point for arbitrary decisions. + +# Services + +This bundle introduces the `policyService`, which may be consulted for +various decisions which are intended to be open for extension. + +The `policyService` has a single method, `allow`, which takes three +arguments and returns a boolean value (true if policy says this decision +should be allowed, false if not): + +* `category`: A string identifying which kind of decision is being made. + Typically, this will be a non-plural form of an extension type that is + being filtered down; for instance, to check whether or not a given + action should be returned by an `actionService`, one would use the + `action` category of extension. +* `candidate`: An object representing the thing which shall or shall not + be allowed. Usually, this will be an instance of an extension of the + category defined above. + * This does need to be the case; additional + policies which are not specific to any extension may also be defined + and consulted using unique `category` identifiers. In this case, the + type of the object delivered for the candidate may be unique to the + policy type. +* `context`: An object representing the context in which the decision is + occurring. Its contents are specific to each policy category. +* `callback`: Optional; a function to call if the policy decision is + rejected. This function will be called with the `message` string + (which may be undefined) of whichever individual policy caused the + operation to fail. + +_Design rationale_: Returning a boolean here limits the amount of +information that can be conveyed by a policy decision, but has the +benefit of simplicity. In MCT on the desktop, the policy service +returned a more complex object with both a boolean status and a string +message; the string message was used rarely (by only around 15% of +policy user code) and as such is made optional in the call itself here. + +_Design rationale_: Returning a boolean instead of a promise here implies +that policy decisions must occur synchronously. This limits the logic +which can be involved in a policy decision, but broadens its applicability; +policy is meant to be used by a variety of other services to separate out +a certain category of business logic, and a synchronous response means +that this capability may be utilized by both synchronous and asynchronous +services. Additionally, policies will often be used in loops (e.g. to filter +down a set of applicable actions) where latency will have the result of +harming the user experience (e.g. the user right-clicks and gets stuck +waiting for a bunch of policy decisions to complete before a menu showing +available actions can appear.) + +The `policyService` is a composite service; it may be modified by adding +decorators, aggregators, etc. + +## Service Components + +The policy service is most often used by decorators for other composite +services. For instance, this bundle contains a decorator for `actionService` +which filters down the applicable actions exposed by that service based +on policy. + +# Policy Categories + +This bundle introduces `action` as a policy category. Policies of this +category shall take action instances as their candidate argument, and +action contexts as their context argument. + +# Extensions + +This bundle introduces the `policies` category of extension. An extension +of this category should have both an implementation, as well as the following +metadata: + +* `category`: A string identifying which kind of policy decision this + effects. +* `message`: Optional; a human-readable string describing the policy + decision when it fails. + +An extension of this category must also have an implementation which +takes no arguments to its constructor and provides a single method, +`allow`, which takes two arguments, `candidate` and `context` (see +descriptions above under documentation for `actionService`) and returns +a boolean indicating whether or not it allows the policy decision. + +Policy decisions require consensus among all policies; that is, if a +single policy returns false, then the policy decision as a whole returns +false. As a consequence, policies should be written in a permissive +manner; that is, they should be designed to prohibit behavior under a +specific set of conditions (by returning false), and allow any behavior +which does not match those conditions (by returning true.) \ No newline at end of file diff --git a/platform/policy/bundle.json b/platform/policy/bundle.json new file mode 100644 index 0000000000..ea836ca732 --- /dev/null +++ b/platform/policy/bundle.json @@ -0,0 +1,21 @@ +{ + "name": "Policy Service", + "description": "Provides support for extension-driven decisions.", + "sources": "src", + "extensions": { + "components": [ + { + "type": "decorator", + "provides": "actionService", + "implementation": "PolicyActionDecorator.js", + "depends": [ "policyService" ] + }, + { + "type": "provider", + "provides": "policyService", + "implementation": "PolicyProvider.js", + "depends": [ "policies[]" ] + } + ] + } +} \ No newline at end of file diff --git a/platform/policy/src/PolicyActionDecorator.js b/platform/policy/src/PolicyActionDecorator.js new file mode 100644 index 0000000000..1057dca905 --- /dev/null +++ b/platform/policy/src/PolicyActionDecorator.js @@ -0,0 +1,37 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Filters out actions based on policy. + * @param {PolicyService} policyService the service which provides + * policy decisions + * @param {ActionService} actionService the service to decorate + */ + function PolicyActionDecorator(policyService, actionService) { + return { + /** + * Get actions which are applicable in this context. + * These will be filters to remove any actions which + * are deemed inapplicable by policy. + * @param context the context in which the action will occur + * @returns {Action[]} applicable actions + */ + getActions: function (context) { + // Check if an action is allowed by policy. + function allow(action) { + return policyService.allow('action', action, context); + } + + // Look up actions, filter out the disallowed ones. + return actionService.getActions(context).filter(allow); + } + }; + } + + return PolicyActionDecorator; + } +); \ No newline at end of file diff --git a/platform/policy/src/PolicyProvider.js b/platform/policy/src/PolicyProvider.js new file mode 100644 index 0000000000..5e35a58515 --- /dev/null +++ b/platform/policy/src/PolicyProvider.js @@ -0,0 +1,85 @@ +/*global define*/ + +define( + [], + function () { + "use strict"; + + /** + * Provides an implementation of `policyService` which consults + * various policy extensions to determine whether or not a specific + * decision should be allowed. + * @constructor + */ + function PolicyProvider(policies) { + var policyMap = {}; + + // Instantiate a policy. Mostly just a constructor call, but + // we also track the message (which was provided as metadata + // along with the constructor) so that we can expose this later. + function instantiate(Policy) { + var policy = Object.create(new Policy()); + policy.message = Policy.message; + return policy; + } + + // Add a specific policy to the map for later lookup, + // according to its category. Note that policy extensions are + // provided as constructors, so they are instantiated here. + function addToMap(Policy) { + var category = (Policy || {}).category; + if (category) { + // Create a new list for that category if needed... + policyMap[category] = policyMap[category] || []; + // ...and put an instance of this policy in that list. + policyMap[category].push(instantiate(Policy)); + } + } + + // Populate the map for subsequent lookup + policies.forEach(addToMap); + + return { + /** + * Check whether or not a certain decision is allowed by + * policy. + * @param {string} category a machine-readable identifier + * for the kind of decision being made + * @param candidate the object about which the decision is + * being made + * @param context the context in which the decision occurs + * @param {Function} [callback] callback to invoke with a + * string message describing the reason a decision + * was disallowed (if its disallowed) + * @returns {boolean} true if the decision is allowed, + * otherwise false. + */ + allow: function (category, candidate, context, callback) { + var policyList = policyMap[category] || [], + i; + + // Iterate through policies. We do this instead of map or + // forEach so that we can return immediately if a policy + // chooses to disallow this decision. + for (i = 0; i < policyList.length; i += 1) { + // Consult the policy... + if (!policyList[i].allow(candidate, context)) { + // ...it disallowed, so pass its message to + // the callback (if any) + if (callback) { + callback(policyList[i].message); + } + // And return the failed result. + return false; + } + } + + // No policy disallowed this decision. + return true; + } + }; + } + + return PolicyProvider; + } +); \ No newline at end of file diff --git a/platform/policy/test/PolicyActionDecoratorSpec.js b/platform/policy/test/PolicyActionDecoratorSpec.js new file mode 100644 index 0000000000..d372a1a110 --- /dev/null +++ b/platform/policy/test/PolicyActionDecoratorSpec.js @@ -0,0 +1,79 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/PolicyActionDecorator"], + function (PolicyActionDecorator) { + "use strict"; + + describe("The policy action decorator", function () { + var mockPolicyService, + mockActionService, + testContext, + testActions, + decorator; + + beforeEach(function () { + mockPolicyService = jasmine.createSpyObj( + 'policyService', + ['allow'] + ); + mockActionService = jasmine.createSpyObj( + 'actionService', + ['getActions'] + ); + + // Content of actions should be irrelevant to this + // decorator, so just give it some objects to pass + // around. + testActions = [ + { someKey: "a" }, + { someKey: "b" }, + { someKey: "c" } + ]; + testContext = { someKey: "some value" }; + + mockActionService.getActions.andReturn(testActions); + mockPolicyService.allow.andReturn(true); + + decorator = new PolicyActionDecorator( + mockPolicyService, + mockActionService + ); + }); + + it("delegates to its decorated action service", function () { + decorator.getActions(testContext); + expect(mockActionService.getActions) + .toHaveBeenCalledWith(testContext); + }); + + it("provides actions from its decorated action service", function () { + // Mock policy service allows everything by default, + // so everything should be returned + expect(decorator.getActions(testContext)) + .toEqual(testActions); + }); + + it("consults the policy service for each candidate action", function () { + decorator.getActions(testContext); + testActions.forEach(function (testAction) { + expect(mockPolicyService.allow).toHaveBeenCalledWith( + 'action', + testAction, + testContext + ); + }); + }); + + it("filters out policy-disallowed actions", function () { + // Disallow the second action + mockPolicyService.allow.andCallFake(function (cat, candidate, ctxt) { + return candidate.someKey !== 'b'; + }); + expect(decorator.getActions(testContext)) + .toEqual([ testActions[0], testActions[2] ]); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/policy/test/PolicyProviderSpec.js b/platform/policy/test/PolicyProviderSpec.js new file mode 100644 index 0000000000..71f3c67012 --- /dev/null +++ b/platform/policy/test/PolicyProviderSpec.js @@ -0,0 +1,85 @@ +/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/ + +define( + ["../src/PolicyProvider"], + function (PolicyProvider) { + "use strict"; + + describe("The policy provider", function () { + var testPolicies, + mockPolicies, + mockPolicyConstructors, + testCandidate, + testContext, + provider; + + beforeEach(function () { + testPolicies = [ + { category: "a", message: "some message", result: true }, + { category: "a", result: true }, + { category: "a", result: true }, + { category: "b", message: "some message", result: true }, + { category: "b", result: true }, + { category: "b", result: true } + ]; + mockPolicies = testPolicies.map(function (p) { + var mockPolicy = jasmine.createSpyObj("policy", ['allow']); + mockPolicy.allow.andCallFake(function () { return p.result; }); + return mockPolicy; + }); + mockPolicyConstructors = testPolicies.map(function (p, i) { + var mockPolicyConstructor = jasmine.createSpy(); + mockPolicyConstructor.andReturn(mockPolicies[i]); + mockPolicyConstructor.message = p.message; + mockPolicyConstructor.category = p.category; + return mockPolicyConstructor; + }); + + testCandidate = { someKey: "some value" }; + testContext = { someOtherKey: "some other value" }; + + provider = new PolicyProvider(mockPolicyConstructors); + }); + + it("has an allow method", function () { + expect(provider.allow).toEqual(jasmine.any(Function)); + }); + + it("consults all relevant policies", function () { + provider.allow("a", testCandidate, testContext); + expect(mockPolicies[0].allow) + .toHaveBeenCalledWith(testCandidate, testContext); + expect(mockPolicies[1].allow) + .toHaveBeenCalledWith(testCandidate, testContext); + expect(mockPolicies[2].allow) + .toHaveBeenCalledWith(testCandidate, testContext); + expect(mockPolicies[3].allow) + .not.toHaveBeenCalled(); + expect(mockPolicies[4].allow) + .not.toHaveBeenCalled(); + expect(mockPolicies[5].allow) + .not.toHaveBeenCalled(); + }); + + it("allows what all policies allow", function () { + expect(provider.allow("a", testCandidate, testContext)) + .toBeTruthy(); + }); + + it("disallows what any one policy disallows", function () { + testPolicies[1].result = false; + expect(provider.allow("a", testCandidate, testContext)) + .toBeFalsy(); + }); + + it("provides a message for policy failure, when available", function () { + var mockCallback = jasmine.createSpy(); + testPolicies[0].result = false; + expect(provider.allow("a", testCandidate, testContext, mockCallback)) + .toBeFalsy(); + expect(mockCallback).toHaveBeenCalledWith(testPolicies[0].message); + }); + + }); + } +); \ No newline at end of file diff --git a/platform/policy/test/suite.json b/platform/policy/test/suite.json new file mode 100644 index 0000000000..8706198dc6 --- /dev/null +++ b/platform/policy/test/suite.json @@ -0,0 +1,4 @@ +[ + "PolicyActionDecorator", + "PolicyProvider" +] \ No newline at end of file