Skip to content

Commit 648d717

Browse files
authored
Merge pull request #4407 from FlowFuse/ldap-group-support
Adds LDAP group support
2 parents 8c97fec + 845cec1 commit 648d717

4 files changed

Lines changed: 236 additions & 9 deletions

File tree

docs/admin/sso/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ address in User Settings.
3838
LDAP based SSO allows the FlowFuse platform to authenticate users against a directory
3939
service provider, such as OpenLDAP.
4040

41-
When logging in, the users credentials are passed to the serivce provider to verify.
41+
When logging in, the users credentials are passed to the service provider to verify.
4242

4343
- [Configuring LDAP SSO](ldap.md)
4444

docs/admin/sso/ldap.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,62 @@ option in the SOO configuration.
6969

7070
When creating the user, the platform will use information provided by the LDAP provider
7171
to create the username. The user will be directed to their settings page where they
72-
can modify their user details to their preferred values.
72+
can modify their user details to their preferred values.
73+
74+
## Managing Team Membership with LDAP Groups
75+
76+
LDAP implementations can also be used to group users
77+
78+
To enable this option, select the `Manage roles using group assertions` in the SSO configuration.
79+
80+
The following configuration options should then be set:
81+
82+
- `Group DN` - this is the base DN to be used to search for group membership.
83+
- `Team Scope` - this determines what teams can be managed using this configuration. There are two options:
84+
- `Apply to all teams` - this will allow the SAML groups to manage all teams on the platform. This is
85+
suitable for a self-hosted installation of FlowFuse with a single SSO configuration for all users on
86+
the platform.
87+
- `Apply to selected teams` - this will restrict what teams can be managed to the provided list. This
88+
is suitable for shared-tenancy platforms with multiple SSO configurations for different groups of users,
89+
such as FlowFuse Cloud.
90+
When this option is selected, an additional option is available - `Allow users to be in other teams`. This
91+
will allow users who sign-in via this SSO configuration to be members of teams not in the list above.
92+
Their membership of those teams will not be managed by the SSO groups.
93+
If that option is disabled, then the user will be removed from any teams not in the list above.
94+
95+
### LDAP Groups configuration
96+
97+
A user's team membership is managed by what groups they are in. When the user logs in, the LDAP provider
98+
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.
99+
100+
The group name is used to identify a team, using its slug property, and the user's role in the team.
101+
The name must take the form `ff-<team>-<role>`. For example, the group `ff-development-owner` will
102+
container the owners of the team `development`.
103+
104+
The valid roles for a user in a team are:
105+
- `owner`
106+
- `member`
107+
- `viewer`
108+
- `dashboard`
109+
110+
*Note*: this uses the team slug property to identify the team. This has been chosen to simplify managing
111+
the groups in the LDAP Provider - rather than using the team's id. However, a team's slug can be changed
112+
by a team owner. Doing so will break the link between the group and the team membership - so should only
113+
be done with care.
114+
115+
## Managing Admin users
116+
117+
The SSO Configuration can be configured to manage the admin users of the platform by enabling the
118+
`Manage Admin roles using group assertions` option. Once enabled, the name of a group can be provided
119+
that will be used to identify whether a user is an admin or not.
120+
121+
**Note:* the platform will refuse to remove the admin flag from a user if they are the only admin
122+
on the platform. It is *strongly* recommended to have an admin user on the system that is not
123+
managed via SSO to ensure continued access in case of any issues with the SSO provider.
124+
125+
126+
## Providers
127+
128+
The following is the node-exhaustive list of the providers that are known to work with FlowFuse LDAP SSO.
129+
130+
- [OpenLDAP](https://www.openldap.org/)

forge/ee/lib/sso/index.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ module.exports.init = async function (app) {
262262
userClient = new Client(clientOptions)
263263
try {
264264
await userClient.bind(userDN, password)
265+
// if ldap group support enabled
266+
if (providerConfig.options.groupMapping) {
267+
await updateTeamMembershipLDAP(adminClient, user, userDN, providerConfig.options)
268+
}
265269
return true
266270
} catch (err) {
267271
// Failed to bind user
@@ -424,6 +428,129 @@ module.exports.init = async function (app) {
424428
}
425429
}
426430

431+
// LDAP Group Membership
432+
async function updateTeamMembershipLDAP (adminClient, user, userDN, providerOpts) {
433+
const filter = `(|(uniqueMember=${userDN})(member=${userDN}))`
434+
const { searchEntries } = await adminClient.search(providerOpts.groupsDN, {
435+
filter,
436+
attributes: ['cn']
437+
})
438+
const promises = []
439+
let adminGroup = false
440+
const desiredTeamMemberships = {}
441+
const groupRegEx = /^ff-(.+)-([^-]+)$/
442+
for (const i in searchEntries) {
443+
const match = groupRegEx.exec(searchEntries[i].cn)
444+
if (match) {
445+
app.log.debug(`Found group ${searchEntries[i].cn} for user ${user.username}`)
446+
const teamSlug = match[1]
447+
const teamRoleName = match[2]
448+
const teamRole = Roles[teamRoleName]
449+
// Check this role is a valid team role
450+
if (TeamRoles.includes(teamRole)) {
451+
// Check if this team is allowed to be managed for this SSO provider
452+
// - either `groupAllTeams` is true (allowing all teams to be managed this way)
453+
// - or `groupTeams` (array) contains the teamSlug
454+
if (providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(teamSlug)) {
455+
// In case we have multiple assertions for a single team,
456+
// ensure we keep the highest level of access
457+
desiredTeamMemberships[teamSlug] = Math.max(desiredTeamMemberships[teamSlug] || 0, teamRole)
458+
}
459+
}
460+
}
461+
if (providerOpts.groupAdmin && providerOpts.groupAdminName === searchEntries[i].cn) {
462+
adminGroup = true
463+
}
464+
}
465+
if (providerOpts.groupAdmin) {
466+
if (user.admin && !adminGroup) {
467+
app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: true, new: false }], user)
468+
user.admin = false
469+
try {
470+
await user.save()
471+
} catch (err) {
472+
// did we just fail remove the last admin?
473+
app.log.info(`Failed to remove admin from ${user.username}, as this would have been the last admin`)
474+
}
475+
} else if (adminGroup && !user.admin) {
476+
app.auditLog.User.user.updatedUser(0, null, [{ key: 'admin', old: false, new: true }], user)
477+
user.admin = true
478+
await user.save()
479+
}
480+
}
481+
482+
// Get the existing memberships and generate a slug->membership object (existingMemberships)
483+
const existingMemberships = {}
484+
;((await user.getTeamMemberships(true)) || []).forEach(membership => {
485+
// Filter out any teams that are not to be managed by this configuration.
486+
// A team is managed by this configuration if any of the follow is true:
487+
// - groupAllTeams is true (all teams to be managed)
488+
// - groupTeams includes this team (this is explicitly a team to be managed)
489+
// - groupOtherTeams is false (not allowed to be a member of other teams - so need to remove them)
490+
if (
491+
providerOpts.groupAllTeams ||
492+
(providerOpts.groupTeams || []).includes(membership.Team.slug) ||
493+
!providerOpts.groupOtherTeams
494+
) {
495+
existingMemberships[membership.Team.slug] = membership
496+
}
497+
})
498+
// We now have the list of desiredTeamMemberships and existingMemberships
499+
// that are in scope of being modified
500+
501+
// - Check each existing membership
502+
// - if in desired list, update role to match and delete from desired list
503+
// - if not in desired list,
504+
// - if groupOtherTeams is false or, delete membership
505+
// - else leave alone
506+
for (const [teamSlug, membership] of Object.entries(existingMemberships)) {
507+
if (Object.hasOwn(desiredTeamMemberships, teamSlug)) {
508+
// This team is in the desired list
509+
if (desiredTeamMemberships[teamSlug] !== membership.role) {
510+
// Role has changed - update membership
511+
const updates = new app.auditLog.formatters.UpdatesCollection()
512+
const oldRole = app.auditLog.formatters.roleObject(membership.role)
513+
const role = app.auditLog.formatters.roleObject(desiredTeamMemberships[teamSlug])
514+
updates.push('role', oldRole.role, role.role)
515+
membership.role = desiredTeamMemberships[teamSlug]
516+
promises.push(membership.save().then(() => {
517+
return app.auditLog.Team.team.user.roleChanged(user, null, membership.Team, user, updates)
518+
}))
519+
} else {
520+
// Role has not changed - no update needed
521+
// console.log(`no change needed for team ${teamSlug} role ${membership.role}`)
522+
}
523+
// Remove from the desired list as it has been dealt with
524+
delete desiredTeamMemberships[teamSlug]
525+
} else {
526+
// console.log(`removing from team ${teamSlug}`)
527+
// This team is not in the desired list - delete the membership
528+
promises.push(membership.destroy().then(() => {
529+
return app.auditLog.Team.team.user.removed(user, null, membership.Team, user)
530+
}))
531+
}
532+
}
533+
// - Check remaining desired memberships
534+
// - create membership
535+
for (const [teamSlug, teamRole] of Object.entries(desiredTeamMemberships)) {
536+
// This is a new team membership
537+
promises.push(app.db.models.Team.bySlug(teamSlug).then(team => {
538+
if (team) {
539+
// console.log(`adding to team ${teamSlug} role ${teamRole}`)
540+
return app.db.controllers.Team.addUser(team, user, teamRole).then(() => {
541+
return app.auditLog.Team.team.user.added(user, null, team, user)
542+
})
543+
} else {
544+
// console.log(`team not found ${teamSlug}`)
545+
// Unrecognised team - ignore
546+
return null
547+
}
548+
}))
549+
}
550+
551+
await Promise.all(promises)
552+
}
553+
427554
return {
428555
handleLoginRequest,
429556
isSSOEnabledForEmail,

frontend/src/pages/admin/Settings/SSO/createEditProvider.vue

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
<template #description>Supplied by your Identity Provider</template>
4444
<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>
4545
</FormRow>
46-
<FormRow v-model="input.options.groupMapping" type="checkbox">Manage roles using group assertions</FormRow>
46+
<!-- <FormRow v-model="input.options.groupMapping" type="checkbox">Manage roles using group assertions</FormRow>
4747
<div v-if="input.options.groupMapping" class="pl-4 space-y-6">
4848
<FormRow v-model="input.options.groupAssertionName" :error="groupAssertionNameError">
4949
Group Assertion Name
@@ -66,7 +66,7 @@
6666
</FormRow>
6767
<FormRow v-model="input.options.groupAdmin" type="checkbox">Manage Admin roles using group assertions</FormRow>
6868
<FormRow v-if="input.options.groupAdmin" v-model="input.options.groupAdminName" :error="groupAdminNameError" class="pl-4">Admin Users SAML Group name</FormRow>
69-
</div>
69+
</div> -->
7070
</template>
7171
<template v-else-if="input.type === 'ldap'">
7272
<FormRow v-model="input.options.server">
@@ -94,6 +94,38 @@
9494
<FormRow v-model="input.options.tlsVerifyServer" type="checkbox">Verify Server Certificate</FormRow>
9595
</div>
9696
</template>
97+
<FormRow v-model="input.options.groupMapping" type="checkbox">Manage roles using group assertions</FormRow>
98+
<div v-if="input.options.groupMapping" class="pl-4 space-y-6">
99+
<div v-if="input.type === 'saml'">
100+
<FormRow v-model="input.options.groupAssertionName" :error="groupAssertionNameError">
101+
Group Assertion Name
102+
<template #description>The name of the SAML Assertion containing group membership details</template>
103+
</FormRow>
104+
</div>
105+
<div v-else-if="input.type === 'ldap'">
106+
<FormRow v-model="input.options.groupsDN" :error="groupsDNError">
107+
Group DN
108+
<template #description>The name of the base object to search for groups</template>
109+
</FormRow>
110+
</div>
111+
<FormRow v-model="input.options.groupAllTeams" :options="[{ value:true, label: 'Apply to all teams' }, { value:false, label: 'Apply to selected teams' }]">
112+
Team Scope
113+
<template #description>Should this apply to all teams on the platform, or just a restricted list of teams</template>
114+
</FormRow>
115+
<FormRow v-if="input.options.groupAllTeams === false" v-model="input.options.groupTeams" class="pl-4">
116+
<template #description>A list of team <b>slugs</b> that will managed by this configuration - one per line</template>
117+
<template #input><textarea v-model="input.options.groupTeams" class="font-mono w-full" rows="6" /></template>
118+
</FormRow>
119+
<FormRow v-if="input.options.groupAllTeams === false" v-model="input.options.groupOtherTeams" type="checkbox" class="pl-4">
120+
Allow users to be in other teams
121+
<template #description>
122+
If enabled, users can be members of any teams not listed above and their membership/roles are not managed
123+
by this SSO configuration.
124+
</template>
125+
</FormRow>
126+
<FormRow v-model="input.options.groupAdmin" type="checkbox">Manage Admin roles using group assertions</FormRow>
127+
<FormRow v-if="input.options.groupAdmin" v-model="input.options.groupAdminName" :error="groupAdminNameError" class="pl-4">Admin Users SAML Group name</FormRow>
128+
</div>
97129
<FormRow v-model="input.options.provisionNewUsers" type="checkbox">Allow Provisioning of New Users on first login</FormRow>
98130
<ff-button :disabled="!formValid" @click="updateProvider()">
99131
Update configuration
@@ -139,7 +171,10 @@ export default {
139171
active: false,
140172
options: {
141173
provisionNewUsers: false,
142-
groupMapping: false
174+
groupAssertionName: '',
175+
groupsDN: '',
176+
groupMapping: false,
177+
groupAdminName: ''
143178
}
144179
},
145180
errors: {},
@@ -156,18 +191,24 @@ export default {
156191
},
157192
isGroupOptionsValid () {
158193
return !this.input.options.groupMapping || (
159-
this.isGroupAssertionNameValid
160-
// && this.isGroupAdminNameValid
194+
(this.input.options.type === 'saml' ? this.isGroupAssertionNameValid : this.isGroupsDNValid) &&
195+
this.isGroupAdminNameValid
161196
)
162197
},
163198
isGroupAssertionNameValid () {
164-
return this.input.options.groupAssertionName.length > 0
199+
return this.input.options.groupAssertionName && this.input.options.groupAssertionName.length > 0
165200
},
166201
groupAssertionNameError () {
167202
return !this.isGroupAssertionNameValid ? 'Group Assertion name is required' : ''
168203
},
204+
isGroupsDNValid () {
205+
return this.input.options.groupsDN && this.input.options.groupsDN.length > 0
206+
},
207+
groupsDNError () {
208+
return !this.isGroupsDNValid ? 'Group DN is required' : ''
209+
},
169210
isGroupAdminNameValid () {
170-
return !this.input.options.groupAdmin || this.input.options.groupAdminName.length > 0
211+
return !this.input.options.groupAdmin || (this.input.options.groupAdminName && this.input.options.groupAdminName.length > 0)
171212
},
172213
groupAdminNameError () {
173214
return !this.isGroupAdminNameValid ? 'Admin Group name is required' : ''
@@ -221,6 +262,7 @@ export default {
221262
if (!opts.options.groupMapping) {
222263
// Remove any group-related config
223264
delete opts.options.groupAssertionName
265+
delete opts.options.groupsDN
224266
delete opts.options.groupAllTeams
225267
delete opts.options.groupTeams
226268
delete opts.options.groupAdmin

0 commit comments

Comments
 (0)