Просмотр исходного кода

Merge pull request #13366 from overleaf/bg-group-policy

Add permission system for managed users

GitOrigin-RevId: 9d7b38c594cc77204dbee22c92263d002fc8778f
Brian Gough 3 лет назад
Родитель
Сommit
8cca5d3316

+ 293 - 0
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<Map>} 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 },
+}

+ 26 - 0
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

+ 1 - 0
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,

+ 8 - 0
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 },

+ 366 - 0
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],
+        ])
+      )
+    })
+  })
+})