Skip to content
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
15 changes: 15 additions & 0 deletions docs/admin/sso/ldap.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ be done with care.
An optional prefix and suffix can be include in the group name to support LDAP providers that have existing naming policies. The SSO configuration can be configured with the lengths of these values so they will be stripped off before the group name is validated.

For example, if an organisation requires all groups to begin with `acme-org-`, a prefix length of `9` can be set and the group `acme-org-ff-development-owner` will be handled as `ff-development-owner`.

### Application Level Groups

In addition to being able to add Users to Teams using groups, Role overrides for specific Applications within those groups can also be controlled.

The User must have a membership of the Team the Application belongs to for an Application override to take effect. If the User is not a member of that Team, the override is ignored.

Groups for Application overrides use a similar pattern to Team Groups and use the same prefix and suffix length modifiers. They take the form `ff-<team>[<application>]-<role>`, where `<application>` can be the Application name or id and `<role>` must be one of the valid roles listed above, but can also be `none` to remove access from an Application within the Team. Any unrecognised role is ignored.

For example, given a Team called `development` and an Application called `test`, owner-level access to the Team would be granted by membership of a group named `ff-development-owner`, and an override to `viewer` for the Application `test` would be granted by `ff-development[test]-viewer`.

If multiple groups grant different roles for the same Application, the highest role is applied.

Application overrides applied this way are managed by the SSO provider: they cannot be edited in the FlowFuse UI, and if the corresponding group is removed the override is cleared the next time the User logs in.

## Managing Admin users

The SSO Configuration can be configured to manage the admin users of the platform by enabling the
Expand Down
17 changes: 16 additions & 1 deletion docs/admin/sso/saml.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,24 @@ be done with care.
An optional prefix and suffix can be include in the group name to support SAML providers that have existing naming policies. The SSO configuration can be configured with the lengths of these values so they will be stripped off before the group name is validated.

For example, if an organisation requires all groups to begin with `acme-org-`, a prefix length of `9` can be set and the group `acme-org-ff-development-owner` will be handled as `ff-development-owner`.

### Application Level Groups

In addition to being able to add Users to Teams using groups, Role overrides for specific Applications within those groups can also be controlled.

The User must have a membership of the Team the Application belongs to for an Application override to take effect. If the User is not a member of that Team, the override is ignored.

Groups for Application overrides use a similar pattern to Team Groups and use the same prefix and suffix length modifiers. They take the form `ff-<team>[<application>]-<role>`, where `<application>` can be the Application name or id and `<role>` must be one of the valid roles listed above, but can also be `none` to remove access from an Application within the Team. Any unrecognised role is ignored.

For example, given a Team called `development` and an Application called `test`, owner-level access to the Team would be granted by membership of a group named `ff-development-owner`, and an override to `viewer` for the Application `test` would be granted by `ff-development[test]-viewer`.

If multiple groups grant different roles for the same Application, the highest role is applied.

Application overrides applied this way are managed by the SSO provider: they cannot be edited in the FlowFuse UI, and if the corresponding group is removed the override is cleared the next time the User logs in.

## Managing Admin users

The SSO Configuration can be configured to managed the admin users of the platform by enabling the
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.

Expand Down
14 changes: 14 additions & 0 deletions forge/db/models/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,20 @@ module.exports = {
unsuspend: async function () {
this.suspended = false
await this.save()
},
hasApplication: async function (nameOrId) {
const applicationId = M.Application.decodeHashid(nameOrId)
const application = await app.db.models.Application.findOne({
where: {
Comment thread
knolleary marked this conversation as resolved.
[Op.or]: [
{ name: nameOrId },
{ id: applicationId }
],
TeamId: this.id
}
})

return application
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion forge/db/views/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ module.exports = function (app) {
$id: 'TeamMemberPermissions',
type: 'object',
properties: {
applications: { type: 'object', additionalProperties: true }
applications: { type: 'object', additionalProperties: true },
sso: { type: 'boolean' }
}
})
app.addSchema({
Expand Down
176 changes: 149 additions & 27 deletions forge/ee/lib/sso/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,116 @@ module.exports.init = async function (app) {
return groupAssertions
}

/**
* Given a group-name match (from the `ff-SLUG-ROLE` regex), check if it is an
* Application override group of the form `ff-<team>[<application>]-<role>` and,
* if so, record the desired application role in `desiredTeamApplicationroles`.
*
* @param {object} desiredTeamApplicationroles Accumulator keyed by team hashid -> { applicationHashid: role }
* @param {RegExpExecArray} match The result of running the `ff-(.+)-([^-]+)$` regex on the group name
* @param {object} providerOpts The SSO Provider configuration object
* @returns {Promise<boolean>} true if the group was an Application override group (and should not
* be treated as a plain team-role group), false otherwise.
*/
async function recordApplicationOverride (desiredTeamApplicationroles, match, providerOpts) {
const applicationRolesMatch = /(.+)\[(.+)\]/.exec(match[1])
if (!applicationRolesMatch) {
// Not an Application override group
return false
}
const teamSlug = applicationRolesMatch[1]
const applicationId = applicationRolesMatch[2]
const applicationRole = Roles[match[2]]
// From here on this is an Application override group - return true even if
// we cannot apply it, so the caller does not treat it as a team-role group.
if (applicationRole === undefined) {
// Unrecognised role name - ignore
return true
}
// Check if this team is allowed to be managed for this SSO provider before
// doing any database lookups.
if (!(providerOpts.groupAllTeams || (providerOpts.groupTeams || []).includes(teamSlug))) {
return true
}
const team = await app.db.models.Team.bySlug(teamSlug)
if (!team) {
// Unrecognised team - ignore
return true
}
const application = await team.hasApplication(applicationId)
if (!application) {
// Unrecognised application - ignore
return true
}
if (!desiredTeamApplicationroles[team.hashid]) {
desiredTeamApplicationroles[team.hashid] = {}
}
// In case we have multiple assertions for a single application,
// ensure we keep the highest level of access
desiredTeamApplicationroles[team.hashid][application.hashid] = Math.max(
desiredTeamApplicationroles[team.hashid][application.hashid] || 0,
applicationRole
)
return true
}

/**
* Apply the desired per-application role overrides to a user's team memberships
* and clear any SSO-managed overrides that are no longer desired.
*
* This is shared between the SAML and LDAP flows.
*
* @param {*} user The FF User object who is logging in
* @param {object} desiredTeamApplicationroles Accumulator keyed by team hashid -> { applicationHashid: role }
* @param {object} providerOpts The SSO Provider configuration object
*/
async function applyApplicationOverrides (user, desiredTeamApplicationroles, providerOpts) {
app.log.debug(`Desired Application Roles for ${user.username} ${JSON.stringify(desiredTeamApplicationroles)}`)
// Work out which teams currently have SSO-managed overrides so we can clear
// any that are no longer desired. Only teams in scope of this provider are
// considered.
const existingOverrideTeamIds = new Set(
((await app.db.models.TeamMember.getTeamsForUser(user.id, true)) || [])
.filter(teamMembership => {
if (!teamMembership.permissions) {
return false
}
// If the user is allowed to be a member of teams outside of SSO
// control, only manage overrides on teams this provider controls.
if (providerOpts.groupMapping && providerOpts.groupOtherTeams) {
return (providerOpts.groupTeams || []).includes(teamMembership.Team.slug)
}
return true
})
.map(teamMembership => teamMembership.Team.hashid)
)
app.log.debug(`Existing Application Roles for ${user.username} ${JSON.stringify([...existingOverrideTeamIds])}`)

// First pass - apply the desired overrides. Done sequentially so that the
// apply and clear passes cannot race on the same membership.
for (const [teamId, overrides] of Object.entries(desiredTeamApplicationroles)) {
const membership = await app.db.models.TeamMember.getTeamMembership(user.id, teamId)
if (membership) {
const newPermissions = { applications: overrides, sso: true }
await app.db.controllers.Team.changeUserTeamPermissions(teamId, user.hashid, newPermissions)
// This team has been dealt with - don't clear it in the second pass
existingOverrideTeamIds.delete(teamId)
} else {
app.log.debug(`User ${user.name} not a member of Team ${teamId} so not overriding Application access`)
}
}

// Second pass - any remaining teams had overrides that are no longer
// desired, so clear them.
for (const teamId of existingOverrideTeamIds) {
const membership = await app.db.models.TeamMember.getTeamMembership(user.id, teamId)
if (membership) {
membership.permissions = {}
await membership.save()
}
}
}

/**
* Update a user's team memberships according to the SAML Assertions
* received when they logged in.
Expand All @@ -337,8 +447,9 @@ module.exports.init = async function (app) {
}
let adminGroup = false
const desiredTeamMemberships = {}
const desiredTeamApplicationroles = {}
app.log.debug(`SAML Group Assertions for ${user.username} ${JSON.stringify(groupAssertions)}`)
groupAssertions.forEach(ga => {
for (const ga of groupAssertions) {
// Trim prefix/postfix from group name
let shortGA = ga
if (providerOpts.groupPrefixLength || providerOpts.groupSuffixLength) {
Expand All @@ -351,25 +462,28 @@ module.exports.init = async function (app) {
// Generate a slug->role object (desiredTeamMemberships)
const match = /^ff-(.+)-([^-]+)$/.exec(shortGA)
if (match) {
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)
// check for Application override groups of the form `ff-<team>[<application>]-<role>`
if (!await recordApplicationOverride(desiredTeamApplicationroles, match, providerOpts)) {
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 === ga) {
adminGroup = true
}
})
}

if (providerOpts.groupAdmin) {
if (user.admin && !adminGroup) {
Expand Down Expand Up @@ -461,6 +575,8 @@ module.exports.init = async function (app) {
}

await Promise.all(promises)
// Now all group membership has been updated, apply application overrides
await applyApplicationOverrides(user, desiredTeamApplicationroles, providerOpts)
} else {
const missingGroupAssertions = new Error(`SAML response missing ${providerOpts.groupAssertionName} assertion`)
missingGroupAssertions.code = 'unknown_sso_user'
Expand All @@ -479,6 +595,7 @@ module.exports.init = async function (app) {
const promises = []
let adminGroup = false
const desiredTeamMemberships = {}
const desiredTeamApplicationroles = {}
const groupRegEx = /^ff-(.+)-([^-]+)$/
for (const i in searchEntries) {
let shortCN = searchEntries[i].cn
Expand All @@ -490,19 +607,22 @@ module.exports.init = async function (app) {
}
const match = groupRegEx.exec(shortCN)
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)
// check for Application override groups of the form `ff-<team>[<application>]-<role>`
if (!await recordApplicationOverride(desiredTeamApplicationroles, match, providerOpts)) {
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)
}
}
}
}
Expand Down Expand Up @@ -599,6 +719,8 @@ module.exports.init = async function (app) {
}

await Promise.all(promises)
// Now all group membership has been updated, apply application overrides
await applyApplicationOverrides(user, desiredTeamApplicationroles, providerOpts)
}

return {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export default {
}
},
async directSSO (id) {
const matched = this.redirectUrlAfterLogin.match(/^\/account\/request\/([a-zA-Z0-9\-_]+)(\/editor)?$/)
const matched = this.redirectUrlAfterLogin?.match(/^\/account\/request\/([a-zA-Z0-9\-_]+)(\/editor)?$/)
window.location = `/ee/sso/login?p=${id}${this.$route.query.r ? `&r=${this.$route.query.r}` : ''}${matched?.[1] ? `&t=${matched[1]}` : ''}`
}
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/application/Settings/UserAccess.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
data-el="user-access-table"
>
<template v-if="hasPermission('team:user:change-role') || isAdminUser" #context-menu="{row}">
<ff-kebab-item data-action="edit-token" label="Edit Permissions" @click="editUserPermissions(row)" />
<ff-kebab-item v-if="row.permissions?.sso === true" label="SSO Managed" />
<ff-kebab-item v-else data-action="edit-token" label="Edit Permissions" @click="editUserPermissions(row)" />
</template>
</ff-data-table>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</span>
<RoleCompare :baseRole="data.role" :overrideRole="application.role" class="w-40" />
<span class="item action w-40 pl-5" data-action="update-role">
<PencilSquareIcon class="ff-icon ff-icon-sm ff-link" @click.prevent="onUpdateRole(application)" />
<PencilSquareIcon v-if="!data.permissions?.sso" class="ff-icon ff-icon-sm ff-link" @click.prevent="onUpdateRole(application)" />
</span>
</li>
</ul>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10836,6 +10836,7 @@ export interface components {
applications?: {
[key: string]: unknown;
};
sso?: boolean;
};
/** TeamMemberList */
TeamMemberList: ({
Expand Down
Loading
Loading