Skip to content

Commit

Permalink
feat: Add UI for configuring SAML in Flagsmith (#4055)
Browse files Browse the repository at this point in the history
Co-authored-by: Matthew Elwell <matthew.elwell@flagsmith.com>
  • Loading branch information
novakzaballa and matthewelwell authored Jun 14, 2024
1 parent 245ad60 commit d2c2aba
Show file tree
Hide file tree
Showing 11 changed files with 766 additions and 100 deletions.
43 changes: 11 additions & 32 deletions docs/docs/system-administration/authentication/01-SAML/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,45 +8,24 @@ SAML authentication requires an [Enterprise subscription](https://flagsmith.com/

:::

## Setup (SaaS)
## Setup

To enable SAML authentication for your Flagsmith organisation, you must send your identity provider metadata XML
document to [support@flagsmith.com](mailto:support@flagsmith.com).
To enable SAML authentication for your Flagsmith organisation, you have to go to your organisations settings, and in the
SAML tab, you'll be able to configure it.

Once Flagsmith has configured your identity provider, we will send you a service provider metadata XML document or an
Assertion Consumer Service (ACS) URL to use with your identity provider.
In the UI, you will be able to configure the following fields.

## Setup (self-hosted)
**Name:** (**Required**) A short name for the organisation, used as the input when clicking "Single Sign-on" at login
(note this is unique across all tenants and will form part of the URL so should only be alphanumeric + '-,\_').

To enable SAML for your Flagsmith organisation in a self-hosted environment, you will need access the
[Django admin interface](/deployment/configuration/django-admin).
**Frontend URL**: (**Required**) This should be the base URL of the Flagsmith dashboard.

In the Django admin interface, click on the "SAML Configurations" option in the menu on the left. To create a new SAML
configuration, click on "Add SAML Configuration" in the top right corner.
**Allow IdP initiated**: This field determines whether logins can be initiated from the IdP.

You should see a screen similar to the following:
**IdP metadata xml**: The metadata from the IdP.

![SAML Auth Setup](/img/saml-auth-setup.png)

From the drop down next to **Organisation**, select the organisation that you want to configure for SAML authentication.

Next to **Organisation name**, add a URI-safe name that uniquely identifies the organisation. Users will need to provide
this name when selecting the "Single Sign-On" option at the Flagsmith login screen.

Next to **Frontend URL**, add the URL where your Flagsmith frontend is running. Users will be redirected to this URL
when they authenticate using SAML.

Copy your identity provider's XML metadata document into the **IdP metadata XML** field, or leave it blank and come back
to this step later if you do not have it.

If you want to enable IdP-initiated SSO, check the box next to **Allow IdP-initiated (unsolicited) login**. If you are
unsure, leave this box unchecked.

Hit the **Save** button to create the SAML configuration.

Once your SAML configuration is created, you can download your Flagsmith service provider metadata by going back to the
list of SAML configurations in the Django admin interface and clicking "Download" on the SAML configuration you just
created.
Once you have configured your identity provider, you can download the service provider metadata XML document with the
button "Download Service Provider Metadata".

### Assertion Consumer Service URL

Expand Down
191 changes: 191 additions & 0 deletions frontend/common/services/useSamlConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { Res } from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import Utils from 'common/utils/utils'

export const samlConfigurationService = service
.enhanceEndpoints({
addTagTypes: ['SamlConfiguration', 'samlConfigurations'],
})
.injectEndpoints({
endpoints: (builder) => ({
createSamlConfiguration: builder.mutation<
Res['samlConfiguration'],
Req['createSamlConfiguration']
>({
invalidatesTags: [
{ id: 'LIST', type: 'SamlConfiguration' },
{ id: 'LIST', type: 'samlConfigurations' },
],
query: (query: Req['createSamlConfiguration']) => ({
body: query,
method: 'POST',
url: `auth/saml/configuration/`,
}),
}),
deleteSamlConfiguration: builder.mutation<
Res['samlConfiguration'],
Req['deleteSamlConfiguration']
>({
invalidatesTags: [
{ id: 'LIST', type: 'SamlConfiguration' },
{ id: 'LIST', type: 'samlConfigurations' },
],
query: (query: Req['deleteSamlConfiguration']) => ({
body: query,
method: 'DELETE',
url: `auth/saml/configuration/${query.name}/`,
}),
}),
getSamlConfiguration: builder.query<
Res['samlConfiguration'],
Req['getSamlConfiguration']
>({
providesTags: (res) => [{ id: res?.name, type: 'SamlConfiguration' }],
query: (query: Req['getSamlConfiguration']) => ({
url: `auth/saml/configuration/${query.name}/`,
}),
}),
getSamlConfigurationMetadata: builder.query<
Res['samlMetadata'],
Req['getSamlConfigurationMetadata']
>({
providesTags: (res) => [
{ id: res?.entity_id, type: 'SamlConfiguration' },
],
query: (query: Req['getSamlConfigurationMetadata']) => ({
headers: { Accept: 'application/xml' },
url: `auth/saml/${query.name}/metadata/`,
}),
}),
getSamlConfigurations: builder.query<
Res['samlConfigurations'],
Req['getSamlConfigurations']
>({
providesTags: [{ id: 'LIST', type: 'samlConfigurations' }],
query: (query: Req['getSamlConfigurations']) => ({
url: `auth/saml/configuration/?${Utils.toParam({
organisation: query.organisation_id,
})}`,
}),
}),
updateSamlConfiguration: builder.mutation<
Res['samlConfiguration'],
Req['updateSamlConfiguration']
>({
invalidatesTags: (res) => [
{ id: 'LIST', type: 'SamlConfiguration' },
{ id: 'LIST', type: 'samlConfigurations' },
{ id: res?.name, type: 'SamlConfiguration' },
],
query: (query: Req['updateSamlConfiguration']) => ({
body: query.body,
method: 'PUT',
url: `auth/saml/configuration/${query.name}/`,
}),
}),
// END OF ENDPOINTS
}),
})

export async function createSamlConfiguration(
store: any,
data: Req['createSamlConfiguration'],
options?: Parameters<
typeof samlConfigurationService.endpoints.createSamlConfiguration.initiate
>[1],
) {
return store.dispatch(
samlConfigurationService.endpoints.createSamlConfiguration.initiate(
data,
options,
),
)
}
export async function deleteSamlConfiguration(
store: any,
data: Req['deleteSamlConfiguration'],
options?: Parameters<
typeof samlConfigurationService.endpoints.deleteSamlConfiguration.initiate
>[1],
) {
return store.dispatch(
samlConfigurationService.endpoints.deleteSamlConfiguration.initiate(
data,
options,
),
)
}
export async function getSamlConfiguration(
store: any,
data: Req['getSamlConfiguration'],
options?: Parameters<
typeof samlConfigurationService.endpoints.getSamlConfiguration.initiate
>[1],
) {
return store.dispatch(
samlConfigurationService.endpoints.getSamlConfiguration.initiate(
data,
options,
),
)
}
export async function getSamlConfigurations(
store: any,
data: Req['getSamlConfigurations'],
options?: Parameters<
typeof samlConfigurationService.endpoints.getSamlConfigurations.initiate
>[1],
) {
return store.dispatch(
samlConfigurationService.endpoints.getSamlConfigurations.initiate(
data,
options,
),
)
}
export async function getSamlConfigurationMetadata(
store: any,
data: Req['getSamlConfigurationMetadata'],
options?: Parameters<
typeof samlConfigurationService.endpoints.getSamlConfigurationMetadata.initiate
>[1],
) {
return store.dispatch(
samlConfigurationService.endpoints.getSamlConfigurationMetadata.initiate(
data,
options,
),
)
}
export async function updateSamlConfiguration(
store: any,
data: Req['updateSamlConfiguration'],
options?: Parameters<
typeof samlConfigurationService.endpoints.updateSamlConfiguration.initiate
>[1],
) {
return store.dispatch(
samlConfigurationService.endpoints.updateSamlConfiguration.initiate(
data,
options,
),
)
}
// END OF FUNCTION_EXPORTS

export const {
useCreateSamlConfigurationMutation,
useDeleteSamlConfigurationMutation,
useGetSamlConfigurationMetadataQuery,
useGetSamlConfigurationQuery,
useGetSamlConfigurationsQuery,
useUpdateSamlConfigurationMutation,
// END OF EXPORTS
} = samlConfigurationService

/* Usage examples:
const { data, isLoading } = useGetSamlConfigurationQuery({ id: 2 }, {}) //get hook
const [createSamlConfiguration, { isLoading, data, isSuccess }] = useCreateSamlConfigurationMutation() //create hook
samlConfigurationService.endpoints.getSamlConfiguration.select({id: 2})(store.getState()) //access data from any function
*/
8 changes: 7 additions & 1 deletion frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
FeatureState,
FeatureStateValue,
ImportStrategy,
APIKey,
Approval,
MultivariateOption,
SAMLConfiguration,
Segment,
Tag,
ProjectFlag,
Expand Down Expand Up @@ -476,5 +476,11 @@ export type Req = {
feature?: number
}
getFeatureSegment: { id: string }
getSamlConfiguration: { name: string }
getSamlConfigurations: { organisation_id: number }
getSamlConfigurationMetadata: { name: string }
updateSamlConfiguration: { name: string; body: SAMLConfiguration }
deleteSamlConfiguration: { name: string }
createSamlConfiguration: SAMLConfiguration
// END OF TYPES
}
15 changes: 15 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,14 @@ export type MetadataModelField = {
is_required_for: isRequiredFor[]
}

export type SAMLConfiguration = {
organisation: number
name: string
frontend_url: string
idp_metadata_xml?: string
allow_idp_initiated?: boolean
}

export type Res = {
segments: PagedResponse<Segment>
segment: Segment
Expand Down Expand Up @@ -664,5 +672,12 @@ export type Res = {
identityFeatureStates: PagedResponse<FeatureState>
cloneidentityFeatureStates: IdentityFeatureState
featureStates: PagedResponse<FeatureState>
samlConfiguration: SAMLConfiguration
samlConfigurations: PagedResponse<SAMLConfiguration>
samlMetadata: {
entity_id: string
response_url: string
metadata_xml: string
}
// END OF TYPES
}
4 changes: 4 additions & 0 deletions frontend/common/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,10 @@ const Utils = Object.assign({}, require('./base/_utils'), {
valid = isEnterprise
break
}
case 'SAML': {
valid = isEnterprise
break
}
default:
valid = true
break
Expand Down
4 changes: 2 additions & 2 deletions frontend/web/components/JSONUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import DropIcon from './svg/DropIcon'
import Button from './base/forms/Button'
import { useDropzone } from 'react-dropzone'

type DropAreaType = {
export type DropAreaType = {
value: File | null
onChange: (file: File, json: Record<string, any>) => void
onChange: (file: File, json: Record<string, any> | string) => void
}

const JSONUpload: FC<DropAreaType> = ({ onChange, value }) => {
Expand Down
Loading

0 comments on commit d2c2aba

Please sign in to comment.