diff --git a/docs/docs/system-administration/authentication/01-SAML/index.md b/docs/docs/system-administration/authentication/01-SAML/index.md index 68dbb78ebf54..5cc0105014ab 100644 --- a/docs/docs/system-administration/authentication/01-SAML/index.md +++ b/docs/docs/system-administration/authentication/01-SAML/index.md @@ -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 diff --git a/frontend/common/services/useSamlConfiguration.ts b/frontend/common/services/useSamlConfiguration.ts new file mode 100644 index 000000000000..f917082fe718 --- /dev/null +++ b/frontend/common/services/useSamlConfiguration.ts @@ -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 +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 612d468b6c94..82fdcb1ee1d6 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -4,9 +4,9 @@ import { FeatureState, FeatureStateValue, ImportStrategy, - APIKey, Approval, MultivariateOption, + SAMLConfiguration, Segment, Tag, ProjectFlag, @@ -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 } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 565de886a58e..74f4fc7ee0f0 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -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 @@ -664,5 +672,12 @@ export type Res = { identityFeatureStates: PagedResponse cloneidentityFeatureStates: IdentityFeatureState featureStates: PagedResponse + samlConfiguration: SAMLConfiguration + samlConfigurations: PagedResponse + samlMetadata: { + entity_id: string + response_url: string + metadata_xml: string + } // END OF TYPES } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 11f4ef198554..984933bc46f3 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -378,6 +378,10 @@ const Utils = Object.assign({}, require('./base/_utils'), { valid = isEnterprise break } + case 'SAML': { + valid = isEnterprise + break + } default: valid = true break diff --git a/frontend/web/components/JSONUpload.tsx b/frontend/web/components/JSONUpload.tsx index 9878e38e619a..adeecb9a6442 100644 --- a/frontend/web/components/JSONUpload.tsx +++ b/frontend/web/components/JSONUpload.tsx @@ -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) => void + onChange: (file: File, json: Record | string) => void } const JSONUpload: FC = ({ onChange, value }) => { diff --git a/frontend/web/components/SamlTab.tsx b/frontend/web/components/SamlTab.tsx new file mode 100644 index 000000000000..39354aa829f2 --- /dev/null +++ b/frontend/web/components/SamlTab.tsx @@ -0,0 +1,145 @@ +import React, { FC } from 'react' +import Button from './base/forms/Button' + +import Icon from './Icon' +import PanelSearch from './PanelSearch' +import PageTitle from './PageTitle' + +import { + useDeleteSamlConfigurationMutation, + useGetSamlConfigurationsQuery, +} from 'common/services/useSamlConfiguration' +import CreateSAML from './modals/CreateSAML' +import Switch from './Switch' +import { SAMLConfiguration } from 'common/types/responses' + +export type SamlTabType = { + organisationId: number +} +const SamlTab: FC = ({ organisationId }) => { + const { data } = useGetSamlConfigurationsQuery({ + organisation_id: organisationId, + }) + const [deleteSamlConfiguration] = useDeleteSamlConfigurationMutation() + const openCreateSAML = ( + title: string, + organisationId: number, + name?: string, + ) => { + openModal( + title, + , + 'p-0 side-modal', + ) + } + + return ( +
+ { + openCreateSAML('Create SAML configuration', organisationId) + }} + > + {'Create a SAML Configuration'} + + } + /> + + + + samlConf.name.toLowerCase().indexOf(search) > -1 + } + header={ + + +
SAML Name
+
+
+ Allow IDP Initiated +
+
+ } + items={data?.results || []} + renderRow={(samlConf: SAMLConfiguration) => ( + { + openCreateSAML( + 'Update SAML configuration', + organisationId, + samlConf.name, + ) + }} + space + className='list-item clickable cursor-pointer' + key={samlConf.name} + > + +
{samlConf.name}
+
+
+ +
+
+ + +
+
, + ) + e.stopPropagation() + e.preventDefault() + }} + className='btn btn-with-icon' + > + + + + + )} + /> + + + ) +} + +export default SamlTab diff --git a/frontend/web/components/ValueEditor.js b/frontend/web/components/ValueEditor.js index 5eb00f03b286..e8ae54954d8b 100644 --- a/frontend/web/components/ValueEditor.js +++ b/frontend/web/components/ValueEditor.js @@ -121,6 +121,10 @@ class ValueEditor extends Component { } componentDidMount() { + if (this.props.language) { + this.setState({ language: this.props.language }) + this.renderValidation(this.props.language) + } if (!this.props.value) return try { const v = JSON.parse(this.props.value) @@ -150,72 +154,74 @@ class ValueEditor extends Component { this.props.className, )} > - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'txt' }) - }} - className={cx('txt', { active: this.state.language === 'txt' })} - > - .txt - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'json' }) - }} - className={cx('json', { active: this.state.language === 'json' })} - > - .json {this.state.language === 'json' && this.renderValidation()} - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'xml' }) - }} - className={cx('xml', { active: this.state.language === 'xml' })} - > - .xml {this.state.language === 'xml' && this.renderValidation()} - - { - e.preventDefault() - e.stopPropagation() + {!this.props.onlyOneLang && ( + + { + e.preventDefault() + e.stopPropagation() + this.setState({ language: 'txt' }) + }} + className={cx('txt', { active: this.state.language === 'txt' })} + > + .txt + + { + e.preventDefault() + e.stopPropagation() + this.setState({ language: 'json' }) + }} + className={cx('json', { active: this.state.language === 'json' })} + > + .json {this.state.language === 'json' && this.renderValidation()} + + { + e.preventDefault() + e.stopPropagation() + this.setState({ language: 'xml' }) + }} + className={cx('xml', { active: this.state.language === 'xml' })} + > + .xml {this.state.language === 'xml' && this.renderValidation()} + + { + e.preventDefault() + e.stopPropagation() - this.setState({ language: 'ini' }) - }} - className={cx('ini', { active: this.state.language === 'ini' })} - > - .toml {this.state.language === 'ini' && this.renderValidation()} - - { - e.preventDefault() - e.stopPropagation() - this.setState({ language: 'yaml' }) - }} - className={cx('yaml', { active: this.state.language === 'yaml' })} - > - .yaml {this.state.language === 'yaml' && this.renderValidation()} - - { - const res = Clipboard.setString(this.props.value) - toast( - res ? 'Clipboard set' : 'Could not set clipboard :(', - res ? '' : 'danger', - ) - }} - className={cx('txt primary')} - > - - copy - - + this.setState({ language: 'ini' }) + }} + className={cx('ini', { active: this.state.language === 'ini' })} + > + .toml {this.state.language === 'ini' && this.renderValidation()} + + { + e.preventDefault() + e.stopPropagation() + this.setState({ language: 'yaml' }) + }} + className={cx('yaml', { active: this.state.language === 'yaml' })} + > + .yaml {this.state.language === 'yaml' && this.renderValidation()} + + { + const res = Clipboard.setString(this.props.value) + toast( + res ? 'Clipboard set' : 'Could not set clipboard :(', + res ? '' : 'danger', + ) + }} + className={cx('txt primary')} + > + + copy + + + )} {E2E ? (