diff --git a/docs/admin/sso/README.md b/docs/admin/sso/README.md index 06b624b14a..5ed03df2c6 100644 --- a/docs/admin/sso/README.md +++ b/docs/admin/sso/README.md @@ -38,7 +38,7 @@ address in User Settings. LDAP based SSO allows the FlowFuse platform to authenticate users against a directory service provider, such as OpenLDAP. - When logging in, the users credentials are passed to the serivce provider to verify. + When logging in, the users credentials are passed to the service provider to verify. - [Configuring LDAP SSO](ldap.md) diff --git a/docs/admin/sso/ldap.md b/docs/admin/sso/ldap.md index 65df72d5df..6ec1829491 100644 --- a/docs/admin/sso/ldap.md +++ b/docs/admin/sso/ldap.md @@ -69,4 +69,62 @@ option in the SOO configuration. When creating the user, the platform will use information provided by the LDAP provider to create the username. The user will be directed to their settings page where they -can modify their user details to their preferred values. \ No newline at end of file +can modify their user details to their preferred values. + +## Managing Team Membership with LDAP Groups + +LDAP implementations can also be used to group users + +To enable this option, select the `Manage roles using group assertions` in the SSO configuration. + +The following configuration options should then be set: + +- `Group DN` - this is the base DN to be used to search for group membership. +- `Team Scope` - this determines what teams can be managed using this configuration. There are two options: + - `Apply to all teams` - this will allow the SAML groups to manage all teams on the platform. This is + suitable for a self-hosted installation of FlowFuse with a single SSO configuration for all users on + the platform. + - `Apply to selected teams` - this will restrict what teams can be managed to the provided list. This + is suitable for shared-tenancy platforms with multiple SSO configurations for different groups of users, + such as FlowFuse Cloud. + When this option is selected, an additional option is available - `Allow users to be in other teams`. This + will allow users who sign-in via this SSO configuration to be members of teams not in the list above. + Their membership of those teams will not be managed by the SSO groups. + If that option is disabled, then the user will be removed from any teams not in the list above. + +### LDAP Groups configuration + +A user's team membership is managed by what groups they are in. When the user logs in, the LDAP provider +will be queried for a list of groups they are a member of. This can be either as a `member` or `uniqueMember` of a `groupOfNames` or `groupOfUniqueNames` respectively. + +The group name is used to identify a team, using its slug property, and the user's role in the team. +The name must take the form `ff--`. For example, the group `ff-development-owner` will +container the owners of the team `development`. + +The valid roles for a user in a team are: + - `owner` + - `member` + - `viewer` + - `dashboard` + +*Note*: this uses the team slug property to identify the team. This has been chosen to simplify managing +the groups in the LDAP Provider - rather than using the team's id. However, a team's slug can be changed +by a team owner. Doing so will break the link between the group and the team membership - so should only +be done with care. + +## Managing Admin users + +The SSO Configuration can be configured to manage the admin users of the platform by enabling the +`Manage Admin roles using group assertions` option. Once enabled, the name of a group can be provided +that will be used to identify whether a user is an admin or not. + +**Note:* the platform will refuse to remove the admin flag from a user if they are the only admin +on the platform. It is *strongly* recommended to have an admin user on the system that is not +managed via SSO to ensure continued access in case of any issues with the SSO provider. + + +## Providers + +The following is the node-exhaustive list of the providers that are known to work with FlowFuse LDAP SSO. + +- [OpenLDAP](https://www.openldap.org/) diff --git a/forge/ee/lib/sso/index.js b/forge/ee/lib/sso/index.js index 95f12ec8ac..d211814a94 100644 --- a/forge/ee/lib/sso/index.js +++ b/forge/ee/lib/sso/index.js @@ -262,6 +262,10 @@ module.exports.init = async function (app) { userClient = new Client(clientOptions) try { await userClient.bind(userDN, password) + // if ldap group support enabled + if (providerConfig.options.groupMapping) { + await updateTeamMembershipLDAP(adminClient, user, userDN, providerConfig.options) + } return true } catch (err) { // Failed to bind user @@ -424,6 +428,129 @@ module.exports.init = async function (app) { } } + // LDAP Group Membership + async function updateTeamMembershipLDAP (adminClient, user, userDN, providerOpts) { + const filter = `(|(uniqueMember=${userDN})(member=${userDN}))` + const { searchEntries } = await adminClient.search(providerOpts.groupsDN, { + filter, + attributes: ['cn'] + }) + const promises = [] + let adminGroup = false + const desiredTeamMemberships = {} + const groupRegEx = /^ff-(.+)-([^-]+)$/ + for (const i in searchEntries) { + const match = groupRegEx.exec(searchEntries[i].cn) + if (match) { + app.log.debug(`Found group ${searchEntries[i].cn} for user ${user.username}`) + const teamSlug = match[1] + const teamRoleName = match[2] + const teamRole = Roles[teamRoleName] + // Check this role is a valid team role + if (TeamRoles.includes(teamRole)) { + // Check if this team is allowed to be managed for this SSO provider + // - either `groupAllTeams` is true (allowing all teams to be managed this way) + // - or `groupTeams` (array) contains the teamSlug + if (providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(teamSlug)) { + // In case we have multiple assertions for a single team, + // ensure we keep the highest level of access + desiredTeamMemberships[teamSlug] = Math.max(desiredTeamMemberships[teamSlug] || 0, teamRole) + } + } + } + if (providerOpts.groupAdmin && providerOpts.groupAdminName === searchEntries[i].cn) { + adminGroup = true + } + } + if (providerOpts.groupAdmin) { + if (user.admin && !adminGroup) { + app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: true, new: false }], user) + user.admin = false + try { + await user.save() + } catch (err) { + // did we just fail remove the last admin? + app.log.info(`Failed to remove admin from ${user.username}, as this would have been the last admin`) + } + } else if (adminGroup && !user.admin) { + app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: false, new: true }], user) + user.admin = true + await user.save() + } + } + + // Get the existing memberships and generate a slug->membership object (existingMemberships) + const existingMemberships = {} + ;((await user.getTeamMemberships(true)) || []).forEach(membership => { + // Filter out any teams that are not to be managed by this configuration. + // A team is managed by this configuration if any of the follow is true: + // - groupAllTeams is true (all teams to be managed) + // - groupTeams includes this team (this is explicitly a team to be managed) + // - groupOtherTeams is false (not allowed to be a member of other teams - so need to remove them) + if ( + providerOpts.groupAllTeams || + (providerOpts.groupTeams || []).includes(membership.Team.slug) || + !providerOpts.groupOtherTeams + ) { + existingMemberships[membership.Team.slug] = membership + } + }) + // We now have the list of desiredTeamMemberships and existingMemberships + // that are in scope of being modified + + // - Check each existing membership + // - if in desired list, update role to match and delete from desired list + // - if not in desired list, + // - if groupOtherTeams is false or, delete membership + // - else leave alone + for (const [teamSlug, membership] of Object.entries(existingMemberships)) { + if (Object.hasOwn(desiredTeamMemberships, teamSlug)) { + // This team is in the desired list + if (desiredTeamMemberships[teamSlug] !== membership.role) { + // Role has changed - update membership + const updates = new app.auditLog.formatters.UpdatesCollection() + const oldRole = app.auditLog.formatters.roleObject(membership.role) + const role = app.auditLog.formatters.roleObject(desiredTeamMemberships[teamSlug]) + updates.push('role', oldRole.role, role.role) + membership.role = desiredTeamMemberships[teamSlug] + promises.push(membership.save().then(() => { + return app.auditLog.Team.team.user.roleChanged(user, null, membership.Team, user, updates) + })) + } else { + // Role has not changed - no update needed + // console.log(`no change needed for team ${teamSlug} role ${membership.role}`) + } + // Remove from the desired list as it has been dealt with + delete desiredTeamMemberships[teamSlug] + } else { + // console.log(`removing from team ${teamSlug}`) + // This team is not in the desired list - delete the membership + promises.push(membership.destroy().then(() => { + return app.auditLog.Team.team.user.removed(user, null, membership.Team, user) + })) + } + } + // - Check remaining desired memberships + // - create membership + for (const [teamSlug, teamRole] of Object.entries(desiredTeamMemberships)) { + // This is a new team membership + promises.push(app.db.models.Team.bySlug(teamSlug).then(team => { + if (team) { + // console.log(`adding to team ${teamSlug} role ${teamRole}`) + return app.db.controllers.Team.addUser(team, user, teamRole).then(() => { + return app.auditLog.Team.team.user.added(user, null, team, user) + }) + } else { + // console.log(`team not found ${teamSlug}`) + // Unrecognised team - ignore + return null + } + })) + } + + await Promise.all(promises) + } + return { handleLoginRequest, isSSOEnabledForEmail, diff --git a/frontend/src/pages/admin/Settings/SSO/createEditProvider.vue b/frontend/src/pages/admin/Settings/SSO/createEditProvider.vue index f4cee705c7..4f0845ea7d 100644 --- a/frontend/src/pages/admin/Settings/SSO/createEditProvider.vue +++ b/frontend/src/pages/admin/Settings/SSO/createEditProvider.vue @@ -43,7 +43,7 @@