From 8cca5d33164287952bcfcddb3c9cce8c269dc3ee Mon Sep 17 00:00:00 2001 From: Brian Gough Date: Tue, 20 Jun 2023 08:38:59 +0100 Subject: [PATCH] Merge pull request #13366 from overleaf/bg-group-policy Add permission system for managed users GitOrigin-RevId: 9d7b38c594cc77204dbee22c92263d002fc8778f --- .../Authorization/PermissionsManager.js | 293 ++++++++++++++ services/web/app/src/models/GroupPolicy.js | 26 ++ services/web/app/src/models/Subscription.js | 1 + services/web/app/src/models/User.js | 8 + .../Authorization/PermissionsManagerTests.js | 366 ++++++++++++++++++ 5 files changed, 694 insertions(+) create mode 100644 services/web/app/src/Features/Authorization/PermissionsManager.js create mode 100644 services/web/app/src/models/GroupPolicy.js create mode 100644 services/web/test/unit/src/Authorization/PermissionsManagerTests.js diff --git a/services/web/app/src/Features/Authorization/PermissionsManager.js b/services/web/app/src/Features/Authorization/PermissionsManager.js new file mode 100644 index 0000000000..6116f10c94 --- /dev/null +++ b/services/web/app/src/Features/Authorization/PermissionsManager.js @@ -0,0 +1,293 @@ +/** + * This module exports functions for managing permissions and policies. + * + * It provides a way to: + * + * - Register capabilities and policies + * - Associate policies with custom validators + * - Apply collections of policies to a user + * - Check whether a user has a given capability + * - Check whether a user complies with a given policy + * + * Capabilities: boolean values that represent whether a user is allowed to + * perform a certain action or not. The capabilities are represented as a Set. + * For example, to delete their account a user would need the + * `delete-own-account` capability. A user starts with a set of default + * capabilities that let them do all the things they can currently do in + * Overleaf. + * + * Policy: a rule which specifies which capabilities will be removed from a user + * when the policy is applied. + * + * For example, a policy `userCannotDeleteOwnAccount` is represented as + * `{'delete-own-account' : false}` meaning that the `delete-own-account` + * capability will be removed. A policy can remove more than one capability, and + * more than one policy could apply to a user. + * + * Validator: a function that takes a user and returns a boolean indicating + * whether the user satisfies the policy or not. For example, a validator for + * the `userCannotAddSecondaryEmail` policy would check whether the user has + * more than one email address. + * + * Group Policies: a collection of policies with a setting indicating whether + * they are enforced or not. Used to place restrictions on managed users in a + * group. + * + * For example, a group policy could be + * + * { + * "userCannotDeleteOwnAccount": true, // enforced + * "userCannotAddSecondaryEmail": false // not enforced + * } + */ + +const { callbackify } = require('util') + +const POLICY_TO_CAPABILITY_MAP = new Map() +const POLICY_TO_VALIDATOR_MAP = new Map() +const DEFAULT_PERMISSIONS = new Map() + +/** + * Throws an error if the given capability is not registered. + * + * @private + * @param {string} capability - The name of the capability to check. + * @throws {Error} If the capability is not registered. + */ +function ensureCapabilityExists(capability) { + if (!DEFAULT_PERMISSIONS.has(capability)) { + throw new Error(`unknown capability: ${capability}`) + } +} + +/** + * Registers a new capability with the given name and options. + * + * @param {string} name - The name of the capability to register. + * @param {Object} options - The options for the capability. + * @param {boolean} options.default - The default value for the capability + * (required). + * @throws {Error} If the default value is not a boolean or if the capability is + * already registered. + */ +function registerCapability(name, options) { + // check that the default value is a boolean + const defaultValue = options?.default + if (typeof defaultValue !== 'boolean') { + throw new Error('default value must be a boolean') + } + if (DEFAULT_PERMISSIONS.has(name)) { + throw new Error(`capability already registered: ${name}`) + } + DEFAULT_PERMISSIONS.set(name, defaultValue) +} + +/** + * Registers a new policy with the given name, capabilities, and options. + * + * @param {string} name - The name of the policy to register. + * @param {Object} capabilities - The capabilities for the policy. + * @param {Object} [options] - The options for the policy. + * @param {Function?} [options.validator] - The optional validator function for the + * policy. + * @throws {Error} If the policy is already registered or if a capability is not + * a boolean or is unknown. + */ +function registerPolicy(name, capabilities, options = {}) { + const { validator } = options + // check that the only options provided are capabilities and validators + // FIXME: maybe use a schema validator here? + if (POLICY_TO_CAPABILITY_MAP.has(name)) { + throw new Error(`policy already registered: ${name}`) + } + // check that all the entries in the capability set exist and are booleans + for (const [capabilityName, capabilityValue] of Object.entries( + capabilities + )) { + // check that the capability exists (look in the default permissions) + if (!DEFAULT_PERMISSIONS.has(capabilityName)) { + throw new Error(`unknown capability: ${capabilityName}`) + } + // check that the value is a boolean + if (typeof capabilityValue !== 'boolean') { + throw new Error( + `capability value must be a boolean: ${capabilityName} = ${capabilityValue}` + ) + } + } + // set the policy capabilities + POLICY_TO_CAPABILITY_MAP.set(name, new Map(Object.entries(capabilities))) + + // set the policy validator (if present) + if (validator) { + POLICY_TO_VALIDATOR_MAP.set(name, validator) + } +} + +/** + * Returns an array of policy names that are enforced based on the provided + * group policy object. + * + * @private + * @param {Object} groupPolicy - The group policy object to check. + * @returns {Array} An array of policy names that are enforced. + */ +function getEnforcedPolicyNames(groupPolicy = {}) { + return Object.keys(groupPolicy).filter( + policyName => groupPolicy[policyName] !== false + ) // filter out the policies that are not enforced +} + +/** + * Returns the value of the specified capability for the given policy. + * + * @private + * @param {string} policyName - The name of the policy to retrieve the + * capability value from. + * @param {string} capability - The name of the capability to retrieve the value + * for. + * @returns {boolean | undefined} The value of the capability for the policy, or + * undefined if the policy or capability is not found. + */ +function getCapabilityValueFromPolicy(policyName, capability) { + return POLICY_TO_CAPABILITY_MAP.get(policyName)?.get(capability) +} + +/** + * Returns the default value for the specified capability. + * + * @private + * @param {string} capability - The name of the capability to retrieve the + * default value for. + * @returns {boolean | undefined} The default value for the capability, or + * undefined if the capability is not found. + */ +function getDefaultPermission(capability) { + return DEFAULT_PERMISSIONS.get(capability) +} + +function getValidatorFromPolicy(policyName) { + return POLICY_TO_VALIDATOR_MAP.get(policyName) +} + +/** + * Returns a set of default capabilities based on the DEFAULT_PERMISSIONS map. + * + * @private + * @returns {Set} A set of default capabilities. + */ +function getDefaultCapabilities() { + const defaultCapabilities = new Set() + for (const [ + capabilityName, + capabilityValue, + ] of DEFAULT_PERMISSIONS.entries()) { + if (capabilityValue === true) { + defaultCapabilities.add(capabilityName) + } + } + return defaultCapabilities +} + +/** + * Applies a given policy to a set of capabilities, to remove those capabilities + * which are not allowed by the policy. + * + * @private + * @param {Set} capabilitySet - The set of capabilities to apply the policy to. + * @param {string} policyName - The name of the policy to apply. + * @throws {Error} If the policy is unknown. + */ +function applyPolicy(capabilitySet, policyName) { + const policyCapabilities = POLICY_TO_CAPABILITY_MAP.get(policyName) + if (!policyCapabilities) { + throw new Error(`unknown policy: ${policyName}`) + } + for (const [ + capabilityName, + capabilityValue, + ] of policyCapabilities.entries()) { + if (capabilityValue !== true) { + capabilitySet.delete(capabilityName) + } + } +} + +/** + * Returns a set of capabilities that a user has based on their group policy. + * + * @param {Object} groupPolicy - The group policy object to check. + * @returns {Set} A set of capabilities that the user has, based on their group + * policy. + * @throws {Error} If the policy is unknown. + */ +function getUserCapabilities(groupPolicy) { + const userCapabilities = getDefaultCapabilities() + const enforcedPolicyNames = getEnforcedPolicyNames(groupPolicy) + for (const enforcedPolicyName of enforcedPolicyNames) { + applyPolicy(userCapabilities, enforcedPolicyName) + } + return userCapabilities +} + +/** + * Checks if a user has permission for a given capability based on their group + * policy. + * + * @param {Object} groupPolicy - The group policy object for the user. + * @param {string} capability - The name of the capability to check permission + * for. + * @returns {boolean} True if the user has permission for the capability, false + * otherwise. + * @throws {Error} If the capability does not exist. + */ +function hasPermission(groupPolicy, capability) { + ensureCapabilityExists(capability) + // look through all the entries in the group policy and see if any of them apply to the capability + const results = getEnforcedPolicyNames(groupPolicy).map(userPolicyName => + getCapabilityValueFromPolicy(userPolicyName, capability) + ) + // if there are no results, return the default permission + if (results.length === 0) { + return getDefaultPermission(capability) + } + // only allow the permission if all the results are true, otherwise deny it + return results.every(result => result === true) +} + +/** + * Asynchronously checks which policies a user complies with using the + * applicable validators. Each validator is an async function that takes a user + * and returns a boolean. + * + * @param {Object} user - The user object to check. + * @param {Object} groupPolicy - The group policy object to check. + * @returns {Promise} A promise that resolves with a Map object containing + * the validation status for each enforced policy. The keys of the Map are the + * enforced policy names, and the values are booleans indicating whether the + * user complies with the policy. + */ +async function getUserValidationStatus(user, groupPolicy) { + // find all the enforced policies for the user + const enforcedPolicyNames = getEnforcedPolicyNames(groupPolicy) + // for each enforced policy, we have a list of capabilities with expected values + // some of those capabilities have validators + // we need to run the validators and the result to see if if the user is complies with the policy + const userValidationStatus = new Map() + for (const enforcedPolicyName of enforcedPolicyNames) { + const validator = getValidatorFromPolicy(enforcedPolicyName) + if (validator) { + userValidationStatus.set(enforcedPolicyName, await validator(user)) + } + } + return userValidationStatus +} + +module.exports = { + registerCapability, + registerPolicy, + hasPermission, + getUserCapabilities, + getUserValidationStatus: callbackify(getUserValidationStatus), + promises: { getUserValidationStatus }, +} diff --git a/services/web/app/src/models/GroupPolicy.js b/services/web/app/src/models/GroupPolicy.js new file mode 100644 index 0000000000..08907564a0 --- /dev/null +++ b/services/web/app/src/models/GroupPolicy.js @@ -0,0 +1,26 @@ +const mongoose = require('../infrastructure/Mongoose') + +const { Schema } = mongoose + +const GroupPolicySchema = new Schema( + { + // User can't delete their own account + userCannotDeleteOwnAccount: Boolean, + + // User can't add a secondary email address, or affiliation + userCannotAddSecondaryEmail: Boolean, + + // User can't have an active (currently auto-renewing) personal subscription, nor can they start one + userCannotHaveSubscription: Boolean, + + // User can't choose to leave the group subscription they are managed by + userCannotLeaveManagingGroupSubscription: Boolean, + + // User can't have Google/Twitter/ORCID SSO active on their account, nor can they link it to their account + userCannotHaveThirdPartySSO: Boolean, + }, + { minimize: false } +) + +exports.GroupPolicy = mongoose.model('GroupPolicy', GroupPolicySchema) +exports.GroupPolicySchema = GroupPolicySchema diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js index 0720d5be27..5e17f2f099 100644 --- a/services/web/app/src/models/Subscription.js +++ b/services/web/app/src/models/Subscription.js @@ -21,6 +21,7 @@ const SubscriptionSchema = new Schema( }, }, member_ids: [{ type: ObjectId, ref: 'User' }], + groupPolicy: { type: ObjectId, ref: 'GroupPolicy' }, invited_emails: [String], teamInvites: [TeamInviteSchema], recurlySubscription_id: String, diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index a0da92672b..a59c08de19 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -31,6 +31,14 @@ const UserSchema = new Schema( role: { type: String, default: '' }, institution: { type: String, default: '' }, hashedPassword: String, + enrollment: { + // sso: { type: Boolean, default: false }, + managedBy: { + type: ObjectId, + ref: 'Subscription', + }, + enrolledAt: { type: Date }, + }, isAdmin: { type: Boolean, default: false }, staffAccess: { publisherMetrics: { type: Boolean, default: false }, diff --git a/services/web/test/unit/src/Authorization/PermissionsManagerTests.js b/services/web/test/unit/src/Authorization/PermissionsManagerTests.js new file mode 100644 index 0000000000..ce90a4db7a --- /dev/null +++ b/services/web/test/unit/src/Authorization/PermissionsManagerTests.js @@ -0,0 +1,366 @@ +const { expect } = require('chai') +const modulePath = + '../../../../app/src/Features/Authorization/PermissionsManager.js' +const SandboxedModule = require('sandboxed-module') + +describe('PermissionsManager', function () { + beforeEach(function () { + this.PermissionsManager = SandboxedModule.require(modulePath, { + requires: {}, + }) + this.PermissionsManager.registerCapability('capability1', { + default: true, + }) + this.PermissionsManager.registerCapability('capability2', { + default: true, + }) + this.PermissionsManager.registerCapability('capability3', { + default: true, + }) + this.PermissionsManager.registerCapability('capability4', { + default: false, + }) + }) + + describe('hasPermission', function () { + describe('when no policies apply to the user', function () { + it('should return true if default permission is true', function () { + const groupPolicy = {} + const capability = 'capability1' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.true + }) + + it('should return false if the default permission is false', function () { + const groupPolicy = {} + const capability = 'capability4' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.false + }) + }) + + describe('when a policy applies to the user', function () { + it('should return true if the user has the capability after the policy is applied', function () { + this.PermissionsManager.registerPolicy('policy', { + capability1: true, + capability2: false, + }) + const groupPolicy = { + policy: true, + } + const capability = 'capability1' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.true + }) + + it('should return false if the user does not have the capability after the policy is applied', function () { + this.PermissionsManager.registerPolicy('policy', { + capability1: true, + capability2: false, + }) + const groupPolicy = { + policy: true, + } + const capability = 'capability2' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.false + }) + + it('should return the default permission if the policy does not apply to the capability', function () { + this.PermissionsManager.registerPolicy('policy', { + capability1: true, + capability2: false, + }) + const groupPolicy = { + policy: true, + } + const capability = 'capability3' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.false + }) + + it('should return the default permission if the policy is not enforced', function () { + this.PermissionsManager.registerPolicy('policy', { + capability1: true, + capability2: false, + }) + const groupPolicy = { + policy: false, + } + const capability1 = 'capability1' + const result1 = this.PermissionsManager.hasPermission( + groupPolicy, + capability1 + ) + const capability2 = 'capability2' + const result2 = this.PermissionsManager.hasPermission( + groupPolicy, + capability2 + ) + expect(result1).to.be.true + expect(result2).to.be.true + }) + }) + + describe('when multiple policies apply to the user', function () { + it('should return true if all policies allow the capability', function () { + this.PermissionsManager.registerPolicy('policy1', { + capability1: true, + capability2: true, + }) + + this.PermissionsManager.registerPolicy('policy2', { + capability1: true, + capability2: true, + }) + const groupPolicy = { + policy1: true, + policy2: true, + } + const capability = 'capability1' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.true + }) + + it('should return false if any policy denies the capability', function () { + this.PermissionsManager.registerPolicy('policy1', { + capability1: true, + capability2: true, + }) + + this.PermissionsManager.registerPolicy('policy2', { + capability1: false, + capability2: true, + }) + const groupPolicy = { + policy1: true, + policy2: true, + } + const capability = 'capability1' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.false + }) + + it('should return the default permssion when the applicable policy is not enforced', function () { + this.PermissionsManager.registerPolicy('policy1', { + capability1: true, + capability2: true, + }) + + this.PermissionsManager.registerPolicy('policy2', { + capability1: false, + capability2: true, + }) + const groupPolicy = { + policy1: true, + policy2: false, + } + const capability = 'capability1' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.true + }) + + it('should return the default permission if the policies do not restrict to the capability', function () { + this.PermissionsManager.registerPolicy('policy', { + capability1: true, + capability2: false, + }) + const groupPolicy = { + policy: true, + } + const capability = 'capability3' + const result = this.PermissionsManager.hasPermission( + groupPolicy, + capability + ) + expect(result).to.be.false + }) + }) + }) + + describe('getUserCapabilities', function () { + it('should return the default capabilities when no group policy is provided', function () { + const groupPolicy = {} + const capabilities = + this.PermissionsManager.getUserCapabilities(groupPolicy) + expect(capabilities).to.deep.equal( + new Set(['capability1', 'capability2', 'capability3']) + ) + }) + + it('should return a reduced capability set when a group policy is provided', function () { + this.PermissionsManager.registerPolicy('policy', { + capability1: true, + capability2: false, + }) + const groupPolicy = { + policy: true, + } + const capabilities = + this.PermissionsManager.getUserCapabilities(groupPolicy) + expect(capabilities).to.deep.equal( + new Set(['capability1', 'capability3']) + ) + }) + + it('should return a reduced capability set when multiple group policies are provided', function () { + this.PermissionsManager.registerPolicy('policy1', { + capability1: true, + capability2: false, + }) + this.PermissionsManager.registerPolicy('policy2', { + capability1: false, + capability2: true, + }) + + const groupPolicy = { + policy1: true, + policy2: true, + } + const capabilities = + this.PermissionsManager.getUserCapabilities(groupPolicy) + expect(capabilities).to.deep.equal(new Set(['capability3'])) + }) + + it('should return an empty capability set when group policies remove all permissions', function () { + this.PermissionsManager.registerPolicy('policy1', { + capability1: true, + capability2: false, + }) + this.PermissionsManager.registerPolicy('policy2', { + capability1: false, + capability2: true, + }) + this.PermissionsManager.registerPolicy('policy3', { + capability1: true, + capability2: true, + capability3: false, + }) + const groupPolicy = { + policy1: true, + policy2: true, + policy3: true, + } + const capabilities = + this.PermissionsManager.getUserCapabilities(groupPolicy) + expect(capabilities).to.deep.equal(new Set()) + }) + }) + + describe('getUserValidationStatus', function () { + it('should return the status for the policy when the user conforms', async function () { + this.PermissionsManager.registerPolicy( + 'policy', + {}, + { + validator: async user => { + return user.prop === 'allowed' + }, + } + ) + const groupPolicy = { + policy: true, + } + const user = { prop: 'allowed' } + const result = + await this.PermissionsManager.promises.getUserValidationStatus( + user, + groupPolicy + ) + expect(result).to.deep.equal(new Map([['policy', true]])) + }) + + it('should return the status for the policy when the user does not conform', async function () { + this.PermissionsManager.registerPolicy( + 'policy', + {}, + { + validator: async user => { + return user.prop === 'allowed' + }, + } + ) + const groupPolicy = { + policy: true, + } + const user = { prop: 'not allowed' } + const result = + await this.PermissionsManager.promises.getUserValidationStatus( + user, + groupPolicy + ) + expect(result).to.deep.equal(new Map([['policy', false]])) + }) + it('should return the status for multiple policies according to whether the user conforms', async function () { + this.PermissionsManager.registerPolicy( + 'policy1', + {}, + { + validator: async user => { + return user.prop === 'allowed' + }, + } + ) + this.PermissionsManager.registerPolicy( + 'policy2', + {}, + { + validator: async user => { + return user.prop === 'other' + }, + } + ) + this.PermissionsManager.registerPolicy( + 'policy3', + {}, + { + validator: async user => { + return user.prop === 'allowed' + }, + } + ) + + const groupPolicy = { + policy1: true, + policy2: true, + policy3: false, // this policy is not enforced + } + const user = { prop: 'allowed' } + const result = + await this.PermissionsManager.promises.getUserValidationStatus( + user, + groupPolicy + ) + expect(result).to.deep.equal( + new Map([ + ['policy1', true], + ['policy2', false], + ]) + ) + }) + }) +})