diff --git a/docs/admin/sso/ldap.md b/docs/admin/sso/ldap.md index dc92605eb7..f4c5330df2 100644 --- a/docs/admin/sso/ldap.md +++ b/docs/admin/sso/ldap.md @@ -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-[]-`, where `` can be the Application name or id and `` 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 diff --git a/docs/admin/sso/saml.md b/docs/admin/sso/saml.md index a3a5e298e7..a3440f1a12 100644 --- a/docs/admin/sso/saml.md +++ b/docs/admin/sso/saml.md @@ -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-[]-`, where `` can be the Application name or id and `` 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. diff --git a/forge/db/models/Team.js b/forge/db/models/Team.js index 195894829c..9b5014e09e 100644 --- a/forge/db/models/Team.js +++ b/forge/db/models/Team.js @@ -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: { + [Op.or]: [ + { name: nameOrId }, + { id: applicationId } + ], + TeamId: this.id + } + }) + + return application } } } diff --git a/forge/db/views/User.js b/forge/db/views/User.js index e1fcc69281..b03afbdb03 100644 --- a/forge/db/views/User.js +++ b/forge/db/views/User.js @@ -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({ diff --git a/forge/ee/lib/sso/index.js b/forge/ee/lib/sso/index.js index c9ff17928a..89b62a8ad1 100644 --- a/forge/ee/lib/sso/index.js +++ b/forge/ee/lib/sso/index.js @@ -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-[]-` 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} 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. @@ -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) { @@ -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-[]-` + 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) { @@ -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' @@ -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 @@ -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-[]-` + 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) + } } } } @@ -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 { diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index 46dd02f9df..b8db6bcb75 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -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]}` : ''}` } } diff --git a/frontend/src/pages/application/Settings/UserAccess.vue b/frontend/src/pages/application/Settings/UserAccess.vue index f17ad550eb..1392c43482 100644 --- a/frontend/src/pages/application/Settings/UserAccess.vue +++ b/frontend/src/pages/application/Settings/UserAccess.vue @@ -5,7 +5,8 @@ data-el="user-access-table" > diff --git a/frontend/src/pages/team/Members/components/ApplicationPermissionsRow.vue b/frontend/src/pages/team/Members/components/ApplicationPermissionsRow.vue index b3af80b89e..2104e25ebc 100644 --- a/frontend/src/pages/team/Members/components/ApplicationPermissionsRow.vue +++ b/frontend/src/pages/team/Members/components/ApplicationPermissionsRow.vue @@ -17,7 +17,7 @@ - + diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index 556d738d7f..14fdab1d89 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -10836,6 +10836,7 @@ export interface components { applications?: { [key: string]: unknown; }; + sso?: boolean; }; /** TeamMemberList */ TeamMemberList: ({ diff --git a/scripts/dump-openapi.js b/scripts/dump-openapi.js index 9cdbab779d..c75e9e929c 100644 --- a/scripts/dump-openapi.js +++ b/scripts/dump-openapi.js @@ -87,6 +87,9 @@ function normaliseSchemaNames (spec) { console.error('Failed to generate OpenAPI spec:', err.message || err) process.exit(1) } finally { + await new Promise((resolve) => { + setTimeout(resolve, 3000) + }) if (server) { await server.close() } diff --git a/test/unit/forge/ee/lib/sso/index_spec.js b/test/unit/forge/ee/lib/sso/index_spec.js index 5d1174ccac..90777bd491 100644 --- a/test/unit/forge/ee/lib/sso/index_spec.js +++ b/test/unit/forge/ee/lib/sso/index_spec.js @@ -462,6 +462,113 @@ NOY6Z1oJnpttQ9gwyV8euQ3C0Wcjf3+OVQ== ;(await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id)).should.have.property('role', Roles.Member) ;(await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.BTeam.id)).should.have.property('role', Roles.Owner) }) + describe('updateTeamMembership Application Level', async function () { + it('add Application Override', async function () { + await app.sso.updateTeamMembership({ + 'ff-roles': [ + 'ff-ateam-member', + 'ff-ateam[application-1]-owner' + ] + }, app.user, { + groupAssertionName: 'ff-roles', + groupAllTeams: true, + groupPrefixLength: 0, + groupSuffixLength: 0 + }) + const teamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id) + teamMembership.should.have.property('permissions') + teamMembership.permissions.should.have.property('applications') + teamMembership.permissions.applications.should.have.property(app.application.hashid, Roles.Owner) + }) + + it('remove Application Override', async function () { + const origTeamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, app.team.id) + origTeamMembership.permissions = { applications: {}, sso: true } + origTeamMembership.permissions.applications[app.application.hashid] = Roles.Owner + await origTeamMembership.save() + await app.sso.updateTeamMembership({ + 'ff-roles': [ + 'ff-ateam-member' + ] + }, app.user, { + groupAssertionName: 'ff-roles', + groupAllTeams: true, + groupPrefixLength: 0, + groupSuffixLength: 0 + }) + const teamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id) + teamMembership.should.have.property('permissions') + teamMembership.permissions.should.be.empty() + }) + + it('should fail if not a group member', async function () { + await app.sso.updateTeamMembership({ + 'ff-roles': [ + 'ff-ateam[application-1]-owner' + ] + }, app.user, { + groupAssertionName: 'ff-roles', + groupAllTeams: true, + groupPrefixLength: 0, + groupSuffixLength: 0 + }) + const teamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id) + should(teamMembership).not.be.ok() + }) + + it('applies highest role when multiple overrides for one application', async function () { + await app.sso.updateTeamMembership({ + 'ff-roles': [ + 'ff-ateam-member', + 'ff-ateam[application-1]-viewer', + 'ff-ateam[application-1]-owner' + ] + }, app.user, { + groupAssertionName: 'ff-roles', + groupAllTeams: true, + groupPrefixLength: 0, + groupSuffixLength: 0 + }) + const teamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id) + teamMembership.permissions.applications.should.have.property(app.application.hashid, Roles.Owner) + }) + + it('ignores override for an unknown application', async function () { + await app.sso.updateTeamMembership({ + 'ff-roles': [ + 'ff-ateam-member', + 'ff-ateam[does-not-exist]-owner' + ] + }, app.user, { + groupAssertionName: 'ff-roles', + groupAllTeams: true, + groupPrefixLength: 0, + groupSuffixLength: 0 + }) + // Team membership should still be applied, with no application overrides + const teamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id) + teamMembership.should.have.property('role', Roles.Member) + should(teamMembership.permissions?.applications || {}).be.empty() + }) + + it('ignores override for an unknown team without erroring', async function () { + // A malformed/unknown team in an override group must not throw and + // must not stop regular team memberships being applied + await app.sso.updateTeamMembership({ + 'ff-roles': [ + 'ff-ateam-member', + 'ff-nosuchteam[application-1]-owner' + ] + }, app.user, { + groupAssertionName: 'ff-roles', + groupAllTeams: true, + groupPrefixLength: 0, + groupSuffixLength: 0 + }) + const teamMembership = await app.db.models.TeamMember.getTeamMembership(app.user.id, teams.ATeam.id) + teamMembership.should.have.property('role', Roles.Member) + }) + }) }) describe('find expired SSO SAML Certs', async function () { it('send email for active SSO profile with cert with less than 2 weeks life', async function () {