Merge pull request #16945 from overleaf/ab-fix-sso-managed-users-enrollment

[web] Fix managed users enrollment clearing out SSO linking status

GitOrigin-RevId: b2083b48df1782c426794f16e2cdd767b217256c
This commit is contained in:
Jessica Lawshe 2024-02-07 10:28:05 -06:00 committed by Copybot
parent 2833a8ce61
commit 03aaee84a3
4 changed files with 17 additions and 593 deletions

View file

@ -1,84 +0,0 @@
const { SSOConfig } = require('../../models/SSOConfig')
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
const UserUpdater = require('../User/UserUpdater')
const SAMLIdentityManager = require('../User/SAMLIdentityManager')
const { User } = require('../../models/User')
const Errors = require('../Errors/Errors')
const GroupUtils = require('./GroupUtils')
async function checkUserCanEnrollInSubscription(userId, subscription) {
const ssoConfig = await SSOConfig.findById(subscription?.ssoConfig).exec()
if (!ssoConfig?.enabled) {
throw new Errors.SAMLGroupSSODisabledError()
}
const userIsMember = subscription.member_ids.some(
memberId => memberId.toString() === userId.toString()
)
if (!userIsMember) {
throw new Errors.SAMLGroupSSOLoginIdentityNotFoundError()
}
const user = await User.findOne({ _id: userId }, { enrollment: 1 }).exec()
const userIsEnrolled = user.enrollment?.sso?.some(
enrollment => enrollment.groupId.toString() === subscription._id.toString()
)
if (userIsEnrolled) {
throw new Errors.SAMLIdentityExistsError()
}
}
async function enrollInSubscription(
userId,
subscription,
externalUserId,
userIdAttribute,
auditLog
) {
await checkUserCanEnrollInSubscription(userId, subscription)
const providerId = GroupUtils.getProviderId(subscription._id)
const userBySamlIdentifier = await SAMLIdentityManager.getUser(
providerId,
externalUserId,
userIdAttribute
)
if (userBySamlIdentifier) {
throw new Errors.SAMLIdentityExistsError()
}
const samlIdentifiers = {
externalUserId,
userIdAttribute,
providerId,
}
await UserUpdater.promises.updateUser(userId, {
$push: {
samlIdentifiers,
'enrollment.sso': {
groupId: subscription._id,
linkedAt: new Date(),
primary: true,
},
},
})
await UserAuditLogHandler.promises.addEntry(
userId,
'group-sso-link',
auditLog.initiatorId,
auditLog.ipAddress,
samlIdentifiers
)
}
module.exports = {
promises: {
checkUserCanEnrollInSubscription,
enrollInSubscription,
},
}

View file

@ -138,6 +138,23 @@ class Subscription {
}) })
} }
linkGroupSSO(user, externalUserId, userIdAttribute, auditLog, callback) {
SubscriptionModel.findById(this._id).exec((error, subscription) => {
if (error) {
return callback(error)
}
Modules.hooks.fire(
'linkUserToGroupSSO',
user._id,
subscription,
externalUserId,
userIdAttribute,
auditLog,
callback
)
})
}
expectDeleted(deleterData, callback) { expectDeleted(deleterData, callback) {
DeletedSubscriptionModel.find( DeletedSubscriptionModel.find(
{ 'subscription._id': this._id }, { 'subscription._id': this._id },

View file

@ -1,300 +0,0 @@
import GroupSettingsSSORoot from '../../../../../../modules/group-settings/frontend/js/components/sso/group-settings-sso-root'
import { SSOConfigurationProvider } from '../../../../../../modules/group-settings/frontend/js/context/sso-configuration-context'
import { singleLineCertificates } from '../../../../../../modules/group-settings/test/data/certificates'
function GroupSettingsSSOComponent() {
return (
<div style={{ padding: '25px', width: '600px' }}>
<SSOConfigurationProvider>
<GroupSettingsSSORoot />
</SSOConfigurationProvider>
</div>
)
}
const GROUP_ID = '123abc'
describe('GroupSettingsSSO', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache = new Map()
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
})
})
it('renders sso settings in group management', function () {
cy.mount(<GroupSettingsSSOComponent />)
cy.get('.group-settings-sso').within(() => {
cy.contains('Single Sign-On (SSO)')
cy.contains('Enable SSO')
})
})
describe('GroupSettingsSSOEnable', function () {
it('renders without sso configuration', function () {
cy.mount(<GroupSettingsSSOComponent />)
cy.contains('Enable SSO')
cy.contains(
'Set up single sign-on for your group. This sign in method will be optional for group members unless Managed Users is enabled.'
)
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('not.be.checked')
cy.get('.invisible-input').should('be.disabled')
})
})
it('renders with sso configuration not validated', function () {
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [
{
value: singleLineCertificates[0],
},
{ value: singleLineCertificates[1] },
],
userIdAttribute: 'email',
enabled: false,
validated: false,
},
}).as('sso')
cy.mount(<GroupSettingsSSOComponent />)
cy.wait('@sso')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('not.be.checked')
cy.get('.invisible-input').should('be.disabled')
})
})
it('renders with sso configuration validated and not enabled', function () {
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [
{ value: singleLineCertificates[0] },
{ value: singleLineCertificates[1] },
],
userIdAttribute: 'email',
validated: true,
enabled: false,
},
}).as('sso')
cy.mount(<GroupSettingsSSOComponent />)
cy.wait('@sso')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('not.be.checked')
cy.get('.invisible-input').should('not.be.disabled')
})
})
it('renders with sso configuration validated and enabled', function () {
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [
{ value: singleLineCertificates[0] },
{ value: singleLineCertificates[1] },
],
userIdAttribute: 'email',
validated: true,
enabled: true,
},
}).as('sso')
cy.mount(<GroupSettingsSSOComponent />)
cy.wait('@sso')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('be.checked')
cy.get('.invisible-input').should('not.be.disabled')
})
})
it('updates the configuration, and checks the draft configuration message', function () {
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [{ value: singleLineCertificates[0] }],
userIdAttribute: 'email',
validated: true,
enabled: false,
},
}).as('sso')
cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [{ value: singleLineCertificates[1] }],
userIdAttribute: 'email',
validated: false,
enabled: false,
},
}).as('ssoUpdated')
cy.mount(<GroupSettingsSSOComponent />)
cy.wait('@sso')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('not.be.checked')
cy.get('.invisible-input').should('not.be.disabled')
})
cy.findByRole('button', { name: 'View configuration' }).click()
cy.findByRole('button', { name: 'Edit' }).click()
cy.findByRole('button', { name: 'Next' }).click()
cy.wait('@ssoUpdated')
cy.findByText('Your configuration has not been finalized.')
})
describe('sso enable modal', function () {
beforeEach(function () {
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [{ value: singleLineCertificates[0] }],
userIdAttribute: 'email',
enabled: false,
},
}).as('sso')
cy.mount(<GroupSettingsSSOComponent />)
cy.wait('@sso')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').click({ force: true })
})
})
it('render enable modal correctly', function () {
// enable modal
cy.get('.modal-dialog').within(() => {
cy.contains('Enable single sign-on')
cy.contains('What happens when SSO is enabled?')
})
})
it('close enable modal if Cancel button is clicked', function () {
cy.get('.modal-dialog').within(() => {
cy.findByRole('button', { name: 'Cancel' }).click()
})
cy.get('.modal-dialog').should('not.exist')
})
it('enables SSO if Enable SSO button is clicked and shows success banner', function () {
cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/enableSSO`, {
statusCode: 200,
}).as('enableSSO')
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [{ value: singleLineCertificates[0] }],
userIdAttribute: 'email',
validated: true,
enabled: true,
},
}).as('sso')
cy.get('.modal-dialog').within(() => {
cy.findByRole('button', { name: 'Enable SSO' }).click()
})
cy.get('.modal-dialog').should('not.exist')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('be.checked')
cy.get('.invisible-input').should('not.be.disabled')
})
cy.findByText('SSO is enabled')
})
})
describe('SSO disable modal', function () {
beforeEach(function () {
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: [{ value: singleLineCertificates[0] }],
userIdAttribute: 'email',
validated: true,
enabled: true,
},
}).as('sso')
cy.mount(<GroupSettingsSSOComponent />)
cy.wait('@sso')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').click({ force: true })
})
})
it('render disable modal correctly', function () {
// disable modal
cy.get('.modal-dialog').within(() => {
cy.contains('Disable single sign-on')
cy.contains(
'Youre about to disable single sign-on for all group members.'
)
})
})
it('close disable modal if Cancel button is clicked', function () {
cy.get('.modal-dialog').within(() => {
cy.findByRole('button', { name: 'Cancel' }).click()
})
cy.get('.modal-dialog').should('not.exist')
})
it('disables SSO if Disable SSO button is clicked and shows success banner', function () {
cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/disableSSO`, {
statusCode: 200,
}).as('disableSSO')
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
statusCode: 200,
body: {
entryPoint: 'entrypoint',
certificates: ['cert'],
userIdAttribute: 'email',
validated: true,
enabled: false,
},
}).as('sso')
cy.get('.modal-dialog').within(() => {
cy.findByRole('button', { name: 'Disable SSO' }).click()
})
cy.get('.modal-dialog').should('not.exist')
cy.get('.switch-input').within(() => {
cy.get('.invisible-input').should('not.be.checked')
})
cy.findByText('SSO is disabled')
})
})
})
})

View file

@ -1,209 +0,0 @@
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const chai = require('chai')
const { expect } = chai
const { ObjectId } = require('mongodb')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const MODULE_PATH = '../../../../app/src/Features/Subscription/GroupSSOHandler'
describe('GroupSSOHandler', function () {
beforeEach(function () {
this.user = { _id: new ObjectId(), enrollment: { sso: [] } }
this.subscription = {
_id: new ObjectId().toString(),
admin_id: new ObjectId(),
member_ids: [this.user._id],
}
this.samlIdentifier = {
externalUserId: 'user@external.com',
userIdAttribute: 'email',
providerId: `ol-group-subscription-id:${this.subscription._id.toString()}`,
}
this.auditLog = {
initiatorId: 'test-initiator-id',
ipAddress: '127.0.0.1',
}
this.ssoConfigData = {
entryPoint: 'https://example.com/saml',
certificates: ['abc'],
userIdAttribute: 'nameId',
enabled: true,
}
this.SSOConfig = {
findById: sinon.stub().returns({
exec: sinon.stub().resolves(this.ssoConfigData),
}),
}
this.UserAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}
this.UserUpdater = {
promises: {
updateUser: sinon.stub().resolves(),
},
}
this.SAMLIdentityManager = {
getUser: sinon.stub().resolves(),
}
this.User = {
findOne: sinon.stub().returns({
exec: sinon.stub().resolves(this.user),
}),
}
this.GroupSSOHandler = SandboxedModule.require(MODULE_PATH, {
requires: {
'../../models/SSOConfig': { SSOConfig: this.SSOConfig },
'../User/UserAuditLogHandler': this.UserAuditLogHandler,
'../User/UserUpdater': this.UserUpdater,
'../User/SAMLIdentityManager': this.SAMLIdentityManager,
'../../models/User': {
User: this.User,
},
},
})
})
describe('checkUserCanEnrollInSubscription', function () {
it('should throw an error if the subscription is not found', async function () {
this.SSOConfig.findById.returns({
exec: sinon.stub().resolves(undefined),
})
await expect(
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
this.user._id,
this.subscription
)
).to.be.rejectedWith(Errors.SAMLGroupSSODisabledError)
})
it('should throw an error if SSO is not enabled for the group', async function () {
const disabledSSOConfig = { ...this.ssoConfig, enabled: false }
this.SSOConfig.findById.returns({
exec: sinon.stub().resolves(disabledSSOConfig),
})
await expect(
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
this.user._id,
this.subscription
)
).to.be.rejectedWith(Errors.SAMLGroupSSODisabledError)
})
it('should throw an error if the user is not a member of the group', async function () {
const testSubscription = {
...this.subscription,
member_ids: [],
}
await expect(
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
this.user._id,
testSubscription
)
).to.be.rejectedWith(Errors.SAMLGroupSSOLoginIdentityNotFoundError)
})
it('should throw an error if the user is already enrolled to the group', async function () {
const testUser = {
...this.user,
enrollment: {
sso: [{ groupId: this.subscription._id }],
},
}
this.User.findOne.returns({
exec: sinon.stub().resolves(testUser),
})
await expect(
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
this.user._id,
this.subscription
)
).to.be.rejectedWith(Errors.SAMLIdentityExistsError)
})
it('should resolve if the user can be enrolled to the group', async function () {
await expect(
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
this.user._id,
this.subscription
)
).to.be.fulfilled
})
})
describe('enrollInSubscription', function () {
it('should throw an error if the user cannot be enrolled', async function () {
const disabledSSOConfig = { ...this.ssoConfig, enabled: false }
this.SSOConfig.findById.returns({
exec: sinon.stub().resolves(disabledSSOConfig),
})
await expect(
this.GroupSSOHandler.promises.enrollInSubscription(
this.user._id,
this.subscription,
this.samlIdentifier.externalUserId,
this.samlIdentifier.userIdAttribute,
this.auditLog
)
).to.be.rejectedWith(Errors.SAMLGroupSSODisabledError)
})
it('should throw an error if an identical SAML identity for the subscription/user already exists', async function () {
this.SAMLIdentityManager.getUser.resolves(this.user)
await expect(
this.GroupSSOHandler.promises.enrollInSubscription(
this.user._id,
this.subscription,
this.samlIdentifier.externalUserId,
this.samlIdentifier.userIdAttribute,
this.auditLog
)
).to.be.rejectedWith(Errors.SAMLIdentityExistsError)
})
it("should add an entry the user's SSO enrollment and samlIdentifiers lists", async function () {
await this.GroupSSOHandler.promises.enrollInSubscription(
this.user._id,
this.subscription,
this.samlIdentifier.externalUserId,
this.samlIdentifier.userIdAttribute,
this.auditLog
)
expect(this.UserUpdater.promises.updateUser).to.have.been.calledWith(
this.user._id,
{
$push: {
samlIdentifiers: this.samlIdentifier,
'enrollment.sso': {
groupId: this.subscription._id,
linkedAt: sinon.match.instanceOf(Date),
primary: true,
},
},
}
)
})
it('should add an entry to the user audit log', async function () {
await this.GroupSSOHandler.promises.enrollInSubscription(
this.user._id,
this.subscription,
this.samlIdentifier.externalUserId,
this.samlIdentifier.userIdAttribute,
this.auditLog
)
expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.user._id,
'group-sso-link',
this.auditLog.initiatorId,
this.auditLog.ipAddress,
this.samlIdentifier
)
})
})
})