diff --git a/services/web/app/src/Features/Authorization/PermissionsController.js b/services/web/app/src/Features/Authorization/PermissionsController.js index e8d4aff809..c9ffac3c7c 100644 --- a/services/web/app/src/Features/Authorization/PermissionsController.js +++ b/services/web/app/src/Features/Authorization/PermissionsController.js @@ -8,6 +8,7 @@ const { const { assertUserPermissions } = require('./PermissionsManager').promises const Modules = require('../../infrastructure/Modules') const { expressify } = require('@overleaf/promise-utils') +const Features = require('../../infrastructure/Features') /** * Function that returns middleware to add an `assertPermission` function to the request object to check if the user has a specific capability. @@ -80,6 +81,9 @@ function requirePermission(...requiredCapabilities) { throw new Error('invalid required capabilities') } const doRequest = async function (req, res, next) { + if (!Features.hasFeature('saas')) { + return next() + } if (!req.user) { return next(new Error('no user')) } diff --git a/services/web/app/src/Features/Authorization/PermissionsManager.js b/services/web/app/src/Features/Authorization/PermissionsManager.js index 49c19369ce..a20280a941 100644 --- a/services/web/app/src/Features/Authorization/PermissionsManager.js +++ b/services/web/app/src/Features/Authorization/PermissionsManager.js @@ -63,6 +63,25 @@ function ensureCapabilityExists(capability) { } } +/** + * Validates an group policy object + * + * @param {Object} policies - An object containing policy names and booleans + * as key-value entries. + * @throws {Error} if the `policies` object contains a policy that is not + * registered, or the policy value is not a boolean + */ +function validatePolicies(policies) { + for (const [policy, value] of Object.entries(policies)) { + if (!POLICY_TO_CAPABILITY_MAP.has(policy)) { + throw new Error(`unknown policy: ${policy}`) + } + if (typeof value !== 'boolean') { + throw new Error(`policy value must be a boolean: ${policy} = ${value}`) + } + } +} + /** * Registers a new capability with the given name and options. * @@ -439,6 +458,7 @@ async function checkUserListPermissions(userList, capabilities) { } module.exports = { + validatePolicies, registerCapability, registerPolicy, registerAllowedProperty, diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index a9c0287259..71de3cae02 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -801,7 +801,8 @@ const _ProjectController = { isTokenMember, isInvitedMember ), - chatEnabled: Features.hasFeature('chat'), + chatEnabled: + Features.hasFeature('chat') && req.capabilitySet.has('chat'), projectHistoryBlobsEnabled: Features.hasFeature( 'project-history-blobs' ), diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index 027c874272..281406645b 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -149,7 +149,8 @@ async function projectListPage(req, res, next) { // TODO use helper function if (!user.enrollment?.managedBy) { groupSubscriptionsPendingEnrollment = subscriptions.filter( - subscription => subscription.groupPlan && subscription.groupPolicy + subscription => + subscription.groupPlan && subscription.managedUsersEnabled ) } } catch (error) { diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index 3d0efddff4..ee64085d0f 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js @@ -31,10 +31,12 @@ const SubscriptionLocator = { .exec() }, - async getMemberSubscriptions(userOrId) { + async getMemberSubscriptions(userOrId, populate = []) { const userId = SubscriptionLocator._getUserId(userOrId) + // eslint-disable-next-line no-restricted-syntax return await Subscription.find({ member_ids: userId }) .populate('admin_id', 'email') + .populate(populate) .exec() }, diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index f6d700496c..df5abb1b12 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -96,7 +96,7 @@ async function viewInvite(req, res, next) { personalSubscription.recurlySubscription_id && personalSubscription.recurlySubscription_id !== '' - if (subscription?.groupPolicy) { + if (subscription?.managedUsersEnabled) { if (!subscription.populated('groupPolicy')) { // eslint-disable-next-line no-restricted-syntax await subscription.populate('groupPolicy') diff --git a/services/web/app/src/models/GroupPolicy.js b/services/web/app/src/models/GroupPolicy.js index ffbc3a9ada..e975834008 100644 --- a/services/web/app/src/models/GroupPolicy.js +++ b/services/web/app/src/models/GroupPolicy.js @@ -24,6 +24,9 @@ const GroupPolicySchema = new Schema( // User can't use any of our AI features, such as the compile-assistant userCannotUseAIFeatures: Boolean, + + // User can't use the chat feature + userCannotUseChat: Boolean, }, { minimize: false } ) diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 4791797959..b200bd5206 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -1059,12 +1059,14 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { '/project/:project_id/messages', AuthorizationMiddleware.blockRestrictedUserFromProject, AuthorizationMiddleware.ensureUserCanReadProject, + PermissionsController.requirePermission('chat'), ChatController.getMessages ) webRouter.post( '/project/:project_id/messages', AuthorizationMiddleware.blockRestrictedUserFromProject, AuthorizationMiddleware.ensureUserCanReadProject, + PermissionsController.requirePermission('chat'), RateLimiterMiddleware.rateLimit(rateLimiters.sendChatMessage), ChatController.sendMessage ) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 6c3fac675a..177d596cfd 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -27,6 +27,7 @@ import { GetProjectsResponseBody } from '../../../types/project/dashboard/api' import { Tag } from '../../../app/src/Features/Tags/types' import { Institution } from '../../../types/institution' import { + GroupPolicy, ManagedGroupSubscription, MemberGroupSubscription, } from '../../../types/subscription/dashboard/subscription' @@ -99,6 +100,7 @@ export interface Meta { 'ol-groupId': string 'ol-groupName': string 'ol-groupPlans': GroupPlans + 'ol-groupPolicy': GroupPolicy 'ol-groupSSOActive': boolean 'ol-groupSSOTestResult': GroupSSOTestResult 'ol-groupSettingsEnabledFor': string[] diff --git a/services/web/frontend/stylesheets/modules/group-settings.less b/services/web/frontend/stylesheets/modules/group-settings.less index 0e6935d737..5800a51dfb 100644 --- a/services/web/frontend/stylesheets/modules/group-settings.less +++ b/services/web/frontend/stylesheets/modules/group-settings.less @@ -112,7 +112,7 @@ h3.group-settings-title { } } -.below-managed-users { +.below-settings-section { border-top: 1px solid @gray-lighter; padding-top: 25px; margin-top: 25px; diff --git a/services/web/migrations/20250121114712_add_chat_policy_to_group_policy.mjs b/services/web/migrations/20250121114712_add_chat_policy_to_group_policy.mjs new file mode 100644 index 0000000000..6387127e81 --- /dev/null +++ b/services/web/migrations/20250121114712_add_chat_policy_to_group_policy.mjs @@ -0,0 +1,17 @@ +const tags = ['saas'] + +const migrate = async client => { + const { db } = client + await db.grouppolicies.updateMany({}, { $set: { userCannotUseChat: false } }) +} + +const rollback = async client => { + const { db } = client + await db.grouppolicies.updateMany({}, { $unset: { userCannotUseChat: '' } }) +} + +export default { + tags, + migrate, + rollback, +} diff --git a/services/web/test/acceptance/src/AuthorizationTests.mjs b/services/web/test/acceptance/src/AuthorizationTests.mjs index 0fa61d3295..bd79641f91 100644 --- a/services/web/test/acceptance/src/AuthorizationTests.mjs +++ b/services/web/test/acceptance/src/AuthorizationTests.mjs @@ -678,6 +678,11 @@ describe('Authorization', function () { }) it('should allow an anonymous user chat messages access', function (done) { + // chat access for anonymous users is a CE/SP-only feature, although currently broken + // https://github.com/overleaf/internal/issues/10944 + if (Features.hasFeature('saas')) { + this.skip() + } expectChatAccess(this.anon, this.projectId, done) }) diff --git a/services/web/test/unit/src/Authorization/PermissionsManagerTests.js b/services/web/test/unit/src/Authorization/PermissionsManagerTests.js index bbf499d147..faec52d79e 100644 --- a/services/web/test/unit/src/Authorization/PermissionsManagerTests.js +++ b/services/web/test/unit/src/Authorization/PermissionsManagerTests.js @@ -64,6 +64,48 @@ describe('PermissionsManager', function () { ] }) + describe('validatePolicies', function () { + it('accepts empty object', function () { + expect(() => this.PermissionsManager.validatePolicies({})).not.to.throw + }) + + it('accepts object with registered policies', function () { + expect(() => + this.PermissionsManager.validatePolicies({ + openPolicy: true, + restrictivePolicy: false, + }) + ).not.to.throw + }) + + it('accepts object with policies containing non-boolean values', function () { + expect(() => + this.PermissionsManager.validatePolicies({ + openPolicy: 1, + }) + ).to.throw('policy value must be a boolean: openPolicy = 1') + expect(() => + this.PermissionsManager.validatePolicies({ + openPolicy: undefined, + }) + ).to.throw('policy value must be a boolean: openPolicy = undefined') + expect(() => + this.PermissionsManager.validatePolicies({ + openPolicy: null, + }) + ).to.throw('policy value must be a boolean: openPolicy = null') + }) + + it('throws error on object with policies that are not registered', function () { + expect(() => + this.PermissionsManager.validatePolicies({ + openPolicy: true, + unregisteredPolicy: false, + }) + ).to.throw('unknown policy: unregisteredPolicy') + }) + }) + describe('hasPermission', function () { describe('when no policies apply to the user', function () { it('should return true if default permission is true', function () { diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index ca3801efd9..9591947eac 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -1132,6 +1132,40 @@ describe('ProjectController', function () { }) }) }) + + describe('chatEnabled flag', function () { + it('should be set to false when the feature is disabled', function (done) { + this.Features.hasFeature = sinon.stub().withArgs('chat').returns(false) + + this.res.render = (pageName, opts) => { + expect(opts.chatEnabled).to.be.false + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + + it('should be set to false when the feature is enabled but the capability is not available', function (done) { + this.Features.hasFeature = sinon.stub().withArgs('chat').returns(false) + this.req.capabilitySet = new Set() + + this.res.render = (pageName, opts) => { + expect(opts.chatEnabled).to.be.false + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + + it('should be set to true when the feature is enabled and the capability is available', function (done) { + this.Features.hasFeature = sinon.stub().withArgs('chat').returns(true) + this.req.capabilitySet = new Set(['chat']) + + this.res.render = (pageName, opts) => { + expect(opts.chatEnabled).to.be.true + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + }) }) describe('userProjectsJson', function () {