Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds LDAP group support #4407

Merged
merged 7 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/admin/sso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
60 changes: 59 additions & 1 deletion docs/admin/sso/ldap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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-<team>-<role>`. 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/)
127 changes: 127 additions & 0 deletions forge/ee/lib/sso/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 49 additions & 7 deletions frontend/src/pages/admin/Settings/SSO/createEditProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
<template #description>Supplied by your Identity Provider</template>
<template #input><textarea v-model="input.options.cert" class="font-mono w-full" placeholder="---BEGIN CERTIFICATE---&#10;loremipsumdolorsitamet&#10;consecteturadipiscinge&#10;---END CERTIFICATE---&#10;" rows="6" /></template>
</FormRow>
<FormRow v-model="input.options.groupMapping" type="checkbox">Manage roles using group assertions</FormRow>
<!-- <FormRow v-model="input.options.groupMapping" type="checkbox">Manage roles using group assertions</FormRow>
<div v-if="input.options.groupMapping" class="pl-4 space-y-6">
<FormRow v-model="input.options.groupAssertionName" :error="groupAssertionNameError">
Group Assertion Name
Expand All @@ -66,7 +66,7 @@
</FormRow>
<FormRow v-model="input.options.groupAdmin" type="checkbox">Manage Admin roles using group assertions</FormRow>
<FormRow v-if="input.options.groupAdmin" v-model="input.options.groupAdminName" :error="groupAdminNameError" class="pl-4">Admin Users SAML Group name</FormRow>
</div>
</div> -->
</template>
<template v-else-if="input.type === 'ldap'">
<FormRow v-model="input.options.server">
Expand Down Expand Up @@ -94,6 +94,38 @@
<FormRow v-model="input.options.tlsVerifyServer" type="checkbox">Verify Server Certificate</FormRow>
</div>
</template>
<FormRow v-model="input.options.groupMapping" type="checkbox">Manage roles using group assertions</FormRow>
<div v-if="input.options.groupMapping" class="pl-4 space-y-6">
<div v-if="input.type === 'saml'">
<FormRow v-model="input.options.groupAssertionName" :error="groupAssertionNameError">
Group Assertion Name
<template #description>The name of the SAML Assertion containing group membership details</template>
</FormRow>
</div>
<div v-else-if="input.type === 'ldap'">
<FormRow v-model="input.options.groupsDN" :error="groupsDNError">
Group DN
<template #description>The name of the base object to search for groups</template>
</FormRow>
</div>
<FormRow v-model="input.options.groupAllTeams" :options="[{ value:true, label: 'Apply to all teams' }, { value:false, label: 'Apply to selected teams' }]">
Team Scope
<template #description>Should this apply to all teams on the platform, or just a restricted list of teams</template>
</FormRow>
<FormRow v-if="input.options.groupAllTeams === false" v-model="input.options.groupTeams" class="pl-4">
<template #description>A list of team <b>slugs</b> that will managed by this configuration - one per line</template>
<template #input><textarea v-model="input.options.groupTeams" class="font-mono w-full" rows="6" /></template>
</FormRow>
<FormRow v-if="input.options.groupAllTeams === false" v-model="input.options.groupOtherTeams" type="checkbox" class="pl-4">
Allow users to be in other teams
<template #description>
If enabled, users can be members of any teams not listed above and their membership/roles are not managed
by this SSO configuration.
</template>
</FormRow>
<FormRow v-model="input.options.groupAdmin" type="checkbox">Manage Admin roles using group assertions</FormRow>
<FormRow v-if="input.options.groupAdmin" v-model="input.options.groupAdminName" :error="groupAdminNameError" class="pl-4">Admin Users SAML Group name</FormRow>
</div>
<FormRow v-model="input.options.provisionNewUsers" type="checkbox">Allow Provisioning of New Users on first login</FormRow>
<ff-button :disabled="!formValid" @click="updateProvider()">
Update configuration
Expand Down Expand Up @@ -139,7 +171,10 @@ export default {
active: false,
options: {
provisionNewUsers: false,
groupMapping: false
groupAssertionName: '',
groupsDN: '',
groupMapping: false,
groupAdminName: ''
}
},
errors: {},
Expand All @@ -156,18 +191,24 @@ export default {
},
isGroupOptionsValid () {
return !this.input.options.groupMapping || (
this.isGroupAssertionNameValid
// && this.isGroupAdminNameValid
(this.input.options.type === 'saml' ? this.isGroupAssertionNameValid : this.isGroupsDNValid) &&
this.isGroupAdminNameValid
)
},
isGroupAssertionNameValid () {
return this.input.options.groupAssertionName.length > 0
return this.input.options.groupAssertionName && this.input.options.groupAssertionName.length > 0
},
groupAssertionNameError () {
return !this.isGroupAssertionNameValid ? 'Group Assertion name is required' : ''
},
isGroupsDNValid () {
return this.input.options.groupsDN && this.input.options.groupsDN.length > 0
},
groupsDNError () {
return !this.isGroupsDNValid ? 'Group DN is required' : ''
},
isGroupAdminNameValid () {
return !this.input.options.groupAdmin || this.input.options.groupAdminName.length > 0
return !this.input.options.groupAdmin || (this.input.options.groupAdminName && this.input.options.groupAdminName.length > 0)
},
groupAdminNameError () {
return !this.isGroupAdminNameValid ? 'Admin Group name is required' : ''
Expand Down Expand Up @@ -221,6 +262,7 @@ export default {
if (!opts.options.groupMapping) {
// Remove any group-related config
delete opts.options.groupAssertionName
delete opts.options.groupsDN
delete opts.options.groupAllTeams
delete opts.options.groupTeams
delete opts.options.groupAdmin
Expand Down
Loading