diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 347c44aedd9..a498fa80035 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -2230,4 +2230,43 @@ dataset: - name: type data_categories: [system.operations] - name: updated_at - data_categories: [system.operations] \ No newline at end of file + data_categories: [system.operations] + - name: openid_provider + description: 'Fides Generated Description for Table: openid_provider' + fields: + - name: authorization_url + description: 'Fides Generated Description for Column: authorization_url' + data_categories: [system.operations] + - name: client_id + description: 'Fides Generated Description for Column: client_id' + data_categories: [system.operations] + - name: client_secret + description: 'Fides Generated Description for Column: client_secret' + data_categories: [system.operations] + - name: created_at + description: 'Fides Generated Description for Column: created_at' + data_categories: [system.operations] + - name: domain + description: 'Fides Generated Description for Column: domain' + data_categories: [system.operations] + - name: id + description: 'Fides Generated Description for Column: id' + data_categories: [system.operations] + - name: identifier + description: 'Fides Generated Description for Column: identifier' + data_categories: [system.operations] + - name: name + description: 'Fides Generated Description for Column: name' + data_categories: [system.operations] + - name: provider + description: 'Fides Generated Description for Column: provider' + data_categories: [system.operations] + - name: token_url + description: 'Fides Generated Description for Column: token_url' + data_categories: [system.operations] + - name: updated_at + description: 'Fides Generated Description for Column: updated_at' + data_categories: [system.operations] + - name: user_info_url + description: 'Fides Generated Description for Column: user_info_url' + data_categories: [system.operations] diff --git a/CHANGELOG.md b/CHANGELOG.md index ae49f413219..371ec789e8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The types of changes are: ### Added - Added support for mapping a system's inegration's consentable items to privacy notices [#5156](https://github.com/ethyca/fides/pull/5156) +- Added support for SSO Login with multiple providers (Fides Plus feature) [#5134](https://github.com/ethyca/fides/pull/5134) ### Fixed - Fixed the OAuth2 configuration for the Snap integration [#5158](https://github.com/ethyca/fides/pull/5158) diff --git a/clients/admin-ui/public/images/oauth-login/custom.svg b/clients/admin-ui/public/images/oauth-login/custom.svg new file mode 100644 index 00000000000..a3b4d23e17a --- /dev/null +++ b/clients/admin-ui/public/images/oauth-login/custom.svg @@ -0,0 +1,3 @@ + + + diff --git a/clients/admin-ui/public/images/oauth-login/github.svg b/clients/admin-ui/public/images/oauth-login/github.svg new file mode 100644 index 00000000000..37fa923df33 --- /dev/null +++ b/clients/admin-ui/public/images/oauth-login/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin-ui/public/images/oauth-login/google.svg b/clients/admin-ui/public/images/oauth-login/google.svg new file mode 100644 index 00000000000..0b0dfe3a45f --- /dev/null +++ b/clients/admin-ui/public/images/oauth-login/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/clients/admin-ui/public/images/oauth-login/okta.svg b/clients/admin-ui/public/images/oauth-login/okta.svg new file mode 100644 index 00000000000..0653992a716 --- /dev/null +++ b/clients/admin-ui/public/images/oauth-login/okta.svg @@ -0,0 +1,7 @@ + + + okta + + + + diff --git a/clients/admin-ui/src/features/auth/auth.slice.ts b/clients/admin-ui/src/features/auth/auth.slice.ts index 74a2df834c4..92378f720fd 100644 --- a/clients/admin-ui/src/features/auth/auth.slice.ts +++ b/clients/admin-ui/src/features/auth/auth.slice.ts @@ -9,6 +9,7 @@ import { RoleRegistryEnum, ScopeRegistryEnum } from "~/types/api"; import { LoginRequest, LoginResponse, + LoginWithOIDCRequest, LogoutRequest, LogoutResponse, } from "./types"; @@ -58,6 +59,13 @@ const authApi = baseApi.injectEndpoints({ }), invalidatesTags: () => ["Auth"], }), + loginWithOIDC: build.mutation({ + query: (data) => ({ + url: `plus/openid-provider/${data.provider}/callback?code=${data.code}`, + method: "GET", + }), + invalidatesTags: () => ["Auth"], + }), logout: build.mutation({ query: () => ({ url: "logout", @@ -85,6 +93,7 @@ const authApi = baseApi.injectEndpoints({ export const { useLoginMutation, + useLoginWithOIDCMutation, useLogoutMutation, useAcceptInviteMutation, useGetRolesToScopesMappingQuery, diff --git a/clients/admin-ui/src/features/auth/types.ts b/clients/admin-ui/src/features/auth/types.ts index 4490b63ce91..bff00b97fdc 100644 --- a/clients/admin-ui/src/features/auth/types.ts +++ b/clients/admin-ui/src/features/auth/types.ts @@ -5,6 +5,11 @@ export interface LoginRequest { password: string; } +export interface LoginWithOIDCRequest { + provider: string; + code: string; +} + export interface LoginResponse { user_data: User; token_data: { diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index 0e87da306bf..e3ec934b0a8 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -62,6 +62,7 @@ export const baseApi = createApi({ "Configuration Settings", "TCF Purpose Override", "Consent Reporting", + "OpenID Provider", ], endpoints: () => ({}), }); diff --git a/clients/admin-ui/src/features/common/nav/v2/routes.ts b/clients/admin-ui/src/features/common/nav/v2/routes.ts index 5a49edc22fa..9f97048cd8c 100644 --- a/clients/admin-ui/src/features/common/nav/v2/routes.ts +++ b/clients/admin-ui/src/features/common/nav/v2/routes.ts @@ -55,3 +55,6 @@ export const GLOBAL_CONSENT_CONFIG_ROUTE = "/settings/consent"; export const MESSAGING_ROUTE = "/messaging"; export const MESSAGING_ADD_TEMPLATE_ROUTE = "/messaging/add-template"; export const MESSAGING_EDIT_ROUTE = "/messaging/[id]"; + +// OpenID Authentication group +export const OPENID_AUTHENTICATION_ROUTE = "/settings/openid-authentication"; diff --git a/clients/admin-ui/src/features/openid-authentication/AddSSOProviderModal.tsx b/clients/admin-ui/src/features/openid-authentication/AddSSOProviderModal.tsx new file mode 100644 index 00000000000..25a9e0d7b7e --- /dev/null +++ b/clients/admin-ui/src/features/openid-authentication/AddSSOProviderModal.tsx @@ -0,0 +1,15 @@ +import { UseDisclosureReturn } from "fidesui"; + +import AddModal from "~/features/configure-consent/AddModal"; +import SSOProviderForm from "~/features/openid-authentication/SSOProviderForm"; + +const AddSSOProviderModal = ({ + isOpen, + onClose, +}: Pick) => ( + + + +); + +export default AddSSOProviderModal; diff --git a/clients/admin-ui/src/features/openid-authentication/EditSSOProviderModal.tsx b/clients/admin-ui/src/features/openid-authentication/EditSSOProviderModal.tsx new file mode 100644 index 00000000000..2bfc3d6675a --- /dev/null +++ b/clients/admin-ui/src/features/openid-authentication/EditSSOProviderModal.tsx @@ -0,0 +1,19 @@ +import AddModal from "~/features/configure-consent/AddModal"; +import SSOProviderForm from "~/features/openid-authentication/SSOProviderForm"; +import { OpenIDProvider } from "~/types/api/models/OpenIDProvider"; + +const EditSSOProviderModal = ({ + isOpen, + onClose, + openIDProvider, +}: { + isOpen: boolean; + onClose: () => void; + openIDProvider: OpenIDProvider; +}) => ( + + + +); + +export default EditSSOProviderModal; diff --git a/clients/admin-ui/src/features/openid-authentication/SSOProvider.tsx b/clients/admin-ui/src/features/openid-authentication/SSOProvider.tsx new file mode 100644 index 00000000000..e16adc76738 --- /dev/null +++ b/clients/admin-ui/src/features/openid-authentication/SSOProvider.tsx @@ -0,0 +1,108 @@ +import { + Box, + Button, + ConfirmationModal, + Image, + Text, + useDisclosure, + useToast, +} from "fidesui"; + +import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; +import { errorToastParams, successToastParams } from "~/features/common/toast"; +import EditSSOProviderModal from "~/features/openid-authentication/EditSSOProviderModal"; +import { useDeleteOpenIDProviderMutation } from "~/features/openid-authentication/openprovider.slice"; +import { OpenIDProvider } from "~/types/api/models/OpenIDProvider"; + +const SSOProvider = ({ + openIDProvider, +}: { + openIDProvider: OpenIDProvider; +}) => { + const { onOpen, isOpen, onClose } = useDisclosure(); + const { isOpen: deleteIsOpen, onClose: onDeleteClose } = useDisclosure(); + const toast = useToast(); + + const [deleteOpenIDProviderMutation] = useDeleteOpenIDProviderMutation(); + + const handleDelete = async () => { + const result = await deleteOpenIDProviderMutation(openIDProvider.id); + if (isErrorResult(result)) { + toast(errorToastParams(getErrorMessage(result.error))); + onDeleteClose(); + return; + } + + toast(successToastParams(`OpenID Provider deleted successfully`)); + + onDeleteClose(); + }; + + return ( + + + {`${openIDProvider.provider} + + + {openIDProvider.name} + + + {openIDProvider.identifier} + + + + + + + + + + You are about to permanently remove this SSO provider. Are you sure + you would like to continue? + + } + /> + + ); +}; + +export default SSOProvider; diff --git a/clients/admin-ui/src/features/openid-authentication/SSOProviderForm.tsx b/clients/admin-ui/src/features/openid-authentication/SSOProviderForm.tsx new file mode 100644 index 00000000000..ae83e655cf5 --- /dev/null +++ b/clients/admin-ui/src/features/openid-authentication/SSOProviderForm.tsx @@ -0,0 +1,224 @@ +import { SerializedError } from "@reduxjs/toolkit"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; +import { Box, Button, Stack, useToast } from "fidesui"; +import { Form, Formik, FormikHelpers } from "formik"; +import { useMemo } from "react"; +import * as Yup from "yup"; + +import { CustomSelect, CustomTextInput } from "~/features/common/form/inputs"; +import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; +import { errorToastParams, successToastParams } from "~/features/common/toast"; +import { + useCreateOpenIDProviderMutation, + useUpdateOpenIDProviderMutation, +} from "~/features/openid-authentication/openprovider.slice"; +import { OpenIDProvider } from "~/types/api"; + +interface SSOProviderFormProps { + openIDProvider?: OpenIDProvider; + onSuccess?: (openIDProvider: OpenIDProvider) => void; + onClose: () => void; +} + +export interface SSOProviderFormValues extends OpenIDProvider {} + +export const defaultInitialValues: SSOProviderFormValues = { + id: "", + identifier: "", + name: "", + provider: "", + client_id: "", + client_secret: "", +}; + +export const transformOrganizationToFormValues = ( + openIDProvider: OpenIDProvider, +): SSOProviderFormValues => ({ ...openIDProvider }); + +const SSOProviderFormValidationSchema = Yup.object().shape({ + provider: Yup.string().required().label("Provider"), + name: Yup.string().required().label("Name"), + client_id: Yup.string().required().label("Client ID"), + client_secret: Yup.string().required().label("Client Secret"), +}); + +const SSOProviderForm = ({ + openIDProvider, + onSuccess, + onClose, +}: SSOProviderFormProps) => { + const [createOpenIDProviderMutationTrigger] = + useCreateOpenIDProviderMutation(); + const [updateOpenIDProviderMutation] = useUpdateOpenIDProviderMutation(); + + const initialValues = useMemo( + () => + openIDProvider + ? transformOrganizationToFormValues(openIDProvider) + : defaultInitialValues, + [openIDProvider], + ); + + const toast = useToast(); + + const handleSubmit = async ( + values: SSOProviderFormValues, + formikHelpers: FormikHelpers, + ) => { + const handleResult = ( + result: + | { data: object } + | { error: FetchBaseQueryError | SerializedError }, + ) => { + if (isErrorResult(result)) { + const errorMsg = getErrorMessage( + result.error, + "An unexpected error occurred while editing the OpenID Provider. Please try again.", + ); + toast(errorToastParams(errorMsg)); + } else { + toast(successToastParams("OpenID Provider configuration saved.")); + onClose(); + formikHelpers.resetForm({}); + if (onSuccess) { + onSuccess(values); + } + } + }; + if (initialValues.id) { + const result = await updateOpenIDProviderMutation(values); + handleResult(result); + } else { + const result = await createOpenIDProviderMutationTrigger(values); + handleResult(result); + } + }; + + const PROVIDER_OPTIONS = [ + { label: "Google", value: "google" }, + { label: "Okta", value: "okta" }, + { label: "Custom", value: "custom" }, + ]; + + const renderOktaProviderExtraFields = () => ( + + ); + + const renderCustomProviderExtraFields = () => ( + <> + + + + + ); + + return ( + + {({ dirty, isValid, values }) => ( +
+ + + + + + + {values.provider === "okta" && renderOktaProviderExtraFields()} + {values.provider === "custom" && renderCustomProviderExtraFields()} + + + + + +
+ )} +
+ ); +}; + +export default SSOProviderForm; diff --git a/clients/admin-ui/src/features/openid-authentication/SSOProvidersSection.tsx b/clients/admin-ui/src/features/openid-authentication/SSOProvidersSection.tsx new file mode 100644 index 00000000000..8227a04c9b0 --- /dev/null +++ b/clients/admin-ui/src/features/openid-authentication/SSOProvidersSection.tsx @@ -0,0 +1,47 @@ +import { Box, Button, Heading, Text, useDisclosure } from "fidesui"; + +import AddSSOProviderModal from "~/features/openid-authentication/AddSSOProviderModal"; +import { useGetAllOpenIDProvidersQuery } from "~/features/openid-authentication/openprovider.slice"; +import SSOProvider from "~/features/openid-authentication/SSOProvider"; +import { OpenIDProvider } from "~/types/api/models/OpenIDProvider"; + +const SSOProvidersSection = () => { + const { onOpen, isOpen, onClose } = useDisclosure(); + const { data: openidProviders } = useGetAllOpenIDProvidersQuery(); + + const renderItems: () => JSX.Element[] | undefined = () => + openidProviders?.map((item: OpenIDProvider) => ( + + )); + + return ( + + + SSO Providers + {openidProviders && openidProviders.length < 5 && ( + + )} + + + Use this area to add and manage SSO providers for you organization. + Select “Add SSO provider” to add a new provider. + + {renderItems()} + + + ); +}; + +export default SSOProvidersSection; diff --git a/clients/admin-ui/src/features/openid-authentication/openprovider.slice.ts b/clients/admin-ui/src/features/openid-authentication/openprovider.slice.ts new file mode 100644 index 00000000000..e26f94edf4c --- /dev/null +++ b/clients/admin-ui/src/features/openid-authentication/openprovider.slice.ts @@ -0,0 +1,57 @@ +import { baseApi } from "~/features/common/api.slice"; +import { OpenIDProvider } from "~/types/api"; + +interface OpenIDProviderDeleteResponse { + message: string; + resource: OpenIDProvider; +} + +const openIDProviderApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + getAllOpenIDProvidersSimple: build.query({ + query: () => ({ url: `plus/openid-provider/simple` }), + providesTags: ["OpenID Provider"], + }), + getAllOpenIDProviders: build.query({ + query: () => ({ url: `plus/openid-provider` }), + providesTags: ["OpenID Provider"], + }), + createOpenIDProvider: build.mutation< + OpenIDProvider, + OpenIDProvider | unknown + >({ + query: (body) => ({ + url: `plus/openid-provider`, + method: "POST", + body, + }), + invalidatesTags: ["OpenID Provider"], + }), + deleteOpenIDProvider: build.mutation({ + query: (key) => ({ + url: `plus/openid-provider/${key}`, + method: "DELETE", + }), + invalidatesTags: ["OpenID Provider"], + }), + updateOpenIDProvider: build.mutation< + OpenIDProvider, + Partial & Pick + >({ + query: (params) => ({ + url: `plus/openid-provider/${params.id}`, + method: "PATCH", + body: params, + }), + invalidatesTags: ["OpenID Provider"], + }), + }), +}); + +export const { + useGetAllOpenIDProvidersSimpleQuery, + useGetAllOpenIDProvidersQuery, + useCreateOpenIDProviderMutation, + useUpdateOpenIDProviderMutation, + useDeleteOpenIDProviderMutation, +} = openIDProviderApi; diff --git a/clients/admin-ui/src/features/user-management/UserForm.tsx b/clients/admin-ui/src/features/user-management/UserForm.tsx index efa3769ca0e..0fa92907c57 100644 --- a/clients/admin-ui/src/features/user-management/UserForm.tsx +++ b/clients/admin-ui/src/features/user-management/UserForm.tsx @@ -20,6 +20,7 @@ import DeleteUserModal from "user-management/DeleteUserModal"; import * as Yup from "yup"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import { useFlags } from "~/features/common/features"; import { CustomTextInput } from "~/features/common/form/inputs"; import { passwordValidation } from "~/features/common/form/validation"; import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; @@ -27,6 +28,7 @@ import { TrashCanSolidIcon } from "~/features/common/Icon/TrashCanSolidIcon"; import { USER_MANAGEMENT_ROUTE } from "~/features/common/nav/v2/routes"; import { errorToastParams, successToastParams } from "~/features/common/toast"; import { useGetEmailInviteStatusQuery } from "~/features/messaging/messaging.slice"; +import { useGetAllOpenIDProvidersQuery } from "~/features/openid-authentication/openprovider.slice"; import PasswordManagement from "./PasswordManagement"; import { User, UserCreate, UserCreateResponse } from "./types"; @@ -71,6 +73,7 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: Props) => { const activeUser = useAppSelector(selectActiveUser); const { data: emailInviteStatus } = useGetEmailInviteStatusQuery(); const inviteUsersViaEmail = emailInviteStatus?.enabled; + const { flags } = useFlags(); const isNewUser = !activeUser; const nameDisabled = isNewUser ? false : !canEditNames; @@ -94,16 +97,29 @@ const UserForm = ({ onSubmit, initialValues, canEditNames }: Props) => { }`, ), ); - if (result && result.data) { + if (result?.data) { dispatch(setActiveUserId(result.data.id)); } }; // The password field is only available when creating a new user. // Otherwise, it is within the UpdatePasswordModal - const validationSchema = showPasswordField - ? ValidationSchema - : ValidationSchema.omit(["password"]); + let validationSchema: Yup.ObjectSchema = ValidationSchema; + + const { data: openidProviders } = useGetAllOpenIDProvidersQuery(); + + const passwordFieldIsRequired = + !flags.openIDAuthentication || !openidProviders?.length; + + if (!passwordFieldIsRequired) { + validationSchema = ValidationSchema.shape({ + password: passwordValidation.optional().label("Password"), + }); + } + + validationSchema = showPasswordField + ? validationSchema + : validationSchema.omit(["password"]); return ( { placeholder="********" type="password" tooltip="Password must contain at least 8 characters, 1 number, 1 capital letter, 1 lowercase letter, and at least 1 symbol." - isRequired + isRequired={passwordFieldIsRequired} /> ) : null} diff --git a/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx b/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx index 56d1fb7124a..19f527f5a5b 100644 --- a/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx +++ b/clients/admin-ui/src/features/user-management/UserManagementLayout.tsx @@ -8,6 +8,7 @@ import { USER_MANAGEMENT_ROUTE } from "~/features/common/nav/v2/routes"; interface Props { children: React.ReactNode; } + const Profile = ({ children }: Props) => ( diff --git a/clients/admin-ui/src/flags.json b/clients/admin-ui/src/flags.json index dbba97dde72..973b5aae1ba 100644 --- a/clients/admin-ui/src/flags.json +++ b/clients/admin-ui/src/flags.json @@ -35,5 +35,11 @@ "development": true, "test": true, "production": false + }, + "openIDAuthentication": { + "description": "OpenID authentication", + "development": true, + "test": true, + "production": false } } diff --git a/clients/admin-ui/src/pages/_app.tsx b/clients/admin-ui/src/pages/_app.tsx index a0b2fa4d474..b02a0a337b2 100644 --- a/clients/admin-ui/src/pages/_app.tsx +++ b/clients/admin-ui/src/pages/_app.tsx @@ -18,6 +18,7 @@ import MainSideNav from "~/features/common/nav/v2/MainSideNav"; import store, { persistor } from "../app/store"; import theme from "../theme"; import Login from "./login"; +import LoginWithOIDC from "./login/[provider]"; if (process.env.NEXT_PUBLIC_MOCK_API) { // eslint-disable-next-line global-require @@ -36,7 +37,7 @@ const MyApp = ({ Component, pageProps }: AppProps) => ( - {Component === Login ? ( + {Component === Login || Component === LoginWithOIDC ? ( // Only the login page is accessible while logged out. If there is // a use case for more unprotected routes, Next has a guide for // per-page layouts: diff --git a/clients/admin-ui/src/pages/login.tsx b/clients/admin-ui/src/pages/login.tsx index 0058e8e87e3..d7bfa2ad750 100644 --- a/clients/admin-ui/src/pages/login.tsx +++ b/clients/admin-ui/src/pages/login.tsx @@ -31,6 +31,7 @@ import { } from "~/features/auth"; import { CustomTextInput } from "~/features/common/form/inputs"; import { passwordValidation } from "~/features/common/form/validation"; +import { useGetAllOpenIDProvidersSimpleQuery } from "~/features/openid-authentication/openprovider.slice"; const parseQueryParam = (query: ParsedUrlQuery) => { const validPathRegex = /^\/[\w/-]*$/; @@ -178,6 +179,37 @@ const useLogin = () => { }; }; +const OAuthLoginButtons = () => { + const { data: openidProviders } = useGetAllOpenIDProvidersSimpleQuery(); + + return ( +
+ + {openidProviders?.map((provider) => ( + + ))} + +
+ ); +}; + const Login: NextPage = () => { const { isFromInvite, showAnimation, inviteCode, ...formikProps } = useLogin(); @@ -292,6 +324,7 @@ const Login: NextPage = () => { {showAnimation ? : null} + diff --git a/clients/admin-ui/src/pages/login/[provider].tsx b/clients/admin-ui/src/pages/login/[provider].tsx new file mode 100644 index 00000000000..0cf632ec1cd --- /dev/null +++ b/clients/admin-ui/src/pages/login/[provider].tsx @@ -0,0 +1,46 @@ +import { Center, Spinner, useToast } from "fidesui"; +import type { NextPage } from "next"; +import { useRouter } from "next/router"; +import { useEffect } from "react"; +import { useDispatch } from "react-redux"; + +import { login, useLoginWithOIDCMutation } from "~/features/auth"; +import { LoginWithOIDCRequest } from "~/features/auth/types"; + +const LoginWithOIDC: NextPage = () => { + const router = useRouter(); + const dispatch = useDispatch(); + const [loginRequest] = useLoginWithOIDCMutation(); + const toast = useToast(); + + useEffect(() => { + if (!router.query || !router.query.provider || !router.query.code) { + return; + } + const data: LoginWithOIDCRequest = { + provider: router.query.provider as string, + code: router.query.code as string, + }; + loginRequest(data) + .unwrap() + .then((response) => { + dispatch(login(response)); + router.push("/"); + }) + .catch((error) => { + toast({ + status: "error", + description: error?.data?.detail, + }); + router.push("/login"); + }); + }, [router, toast, dispatch, router.query, loginRequest]); + + return ( +
+ +
+ ); +}; + +export default LoginWithOIDC; diff --git a/clients/admin-ui/src/pages/settings/organization.tsx b/clients/admin-ui/src/pages/settings/organization.tsx index f77c931209d..5785f715313 100644 --- a/clients/admin-ui/src/pages/settings/organization.tsx +++ b/clients/admin-ui/src/pages/settings/organization.tsx @@ -1,17 +1,25 @@ +import Restrict from "common/Restrict"; import { Box, Heading, Text } from "fidesui"; import type { NextPage } from "next"; +import { useFeatures } from "~/features/common/features"; import Layout from "~/features/common/Layout"; +import OpenIDAuthenticationSection from "~/features/openid-authentication/SSOProvidersSection"; import { DEFAULT_ORGANIZATION_FIDES_KEY, useGetOrganizationByFidesKeyQuery, } from "~/features/organization"; import { OrganizationForm } from "~/features/organization/OrganizationForm"; +import { ScopeRegistryEnum } from "~/types/api"; const OrganizationPage: NextPage = () => { const { data: organization } = useGetOrganizationByFidesKeyQuery( DEFAULT_ORGANIZATION_FIDES_KEY, ); + const { + plus: hasPlus, + flags: { openIDAuthentication }, + } = useFeatures(); return ( @@ -28,6 +36,11 @@ const OrganizationPage: NextPage = () => { + {openIDAuthentication && hasPlus && ( + + + + )} diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index ca4e469f5cd..c62ab73ef0f 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -306,6 +306,7 @@ export type { PreApprovalWebhookResponse } from "./models/PreApprovalWebhookResp export type { PreApprovalWebhookUpdate } from "./models/PreApprovalWebhookUpdate"; export type { PreferencesSaved } from "./models/PreferencesSaved"; export type { PreferencesSavedExtended } from "./models/PreferencesSavedExtended"; +export type { OpenIDProvider } from "./models/OpenIDProvider"; export type { PreferenceWithNoticeInformation } from "./models/PreferenceWithNoticeInformation"; export type { PrivacyCenterConfig } from "./models/PrivacyCenterConfig"; export type { PrivacyDeclaration } from "./models/PrivacyDeclaration"; diff --git a/clients/admin-ui/src/types/api/models/OpenIDProvider.ts b/clients/admin-ui/src/types/api/models/OpenIDProvider.ts new file mode 100644 index 00000000000..b3ecd2307d6 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/OpenIDProvider.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type OpenIDProvider = { + id: string; + identifier: string; + name: string; + provider: string; + client_id: string; + client_secret: string; +}; diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts index 365959b9cb0..9905b9aea26 100644 --- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts +++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts @@ -90,6 +90,10 @@ export enum ScopeRegistryEnum { MESSAGING_CREATE_OR_UPDATE = "messaging:create_or_update", MESSAGING_DELETE = "messaging:delete", MESSAGING_READ = "messaging:read", + OPENID_PROVIDER_CREATE = "openid_provider:create", + OPENID_PROVIDER_READ = "openid_provider:read", + OPENID_PROVIDER_UPDATE = "openid_provider:update", + OPENID_PROVIDER_DELETE = "openid_provider:delete", ORGANIZATION_CREATE = "organization:create", ORGANIZATION_DELETE = "organization:delete", ORGANIZATION_READ = "organization:read", diff --git a/noxfiles/ci_nox.py b/noxfiles/ci_nox.py index f09b6f16d9d..d18428ee1d3 100644 --- a/noxfiles/ci_nox.py +++ b/noxfiles/ci_nox.py @@ -82,8 +82,6 @@ def pylint(session: nox.Session) -> None: """Run the 'pylint' code linter.""" install_requirements(session) command = ("pylint", "src", "noxfiles", "noxfile.py", "--jobs", "0") - if session.posargs: - command = ("pylint", *session.posargs) session.run(*command) diff --git a/src/fides/api/alembic/migrations/versions/ffee79245c9a_add_openid_provider.py b/src/fides/api/alembic/migrations/versions/ffee79245c9a_add_openid_provider.py new file mode 100644 index 00000000000..34496288276 --- /dev/null +++ b/src/fides/api/alembic/migrations/versions/ffee79245c9a_add_openid_provider.py @@ -0,0 +1,77 @@ +"""add openid_provider + +Revision ID: ffee79245c9a +Revises: d69cf8f82a58 +Create Date: 2024-08-09 19:27:07.222226 + +""" + +import sqlalchemy as sa +import sqlalchemy_utils +from alembic import op + +# revision identifiers, used by Alembic. +revision = "ffee79245c9a" +down_revision = "d69cf8f82a58" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "openid_provider", + sa.Column("id", sa.String(length=255), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=True, + ), + sa.Column("identifier", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=True), + sa.Column( + "provider", + sa.Enum("google", "okta", "custom", name="providerenum"), + nullable=True, + ), + sa.Column( + "client_id", + sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(), + nullable=False, + ), + sa.Column( + "client_secret", + sqlalchemy_utils.types.encrypted.encrypted_type.StringEncryptedType(), + nullable=False, + ), + sa.Column("domain", sa.String(), nullable=True), + sa.Column("authorization_url", sa.String(), nullable=True), + sa.Column("token_url", sa.String(), nullable=True), + sa.Column("user_info_url", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_openid_provider_id"), "openid_provider", ["id"], unique=False + ) + op.create_index( + op.f("ix_openid_provider_identifier"), + "openid_provider", + ["identifier"], + unique=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_openid_provider_identifier"), table_name="openid_provider") + op.drop_index(op.f("ix_openid_provider_id"), table_name="openid_provider") + op.drop_table("openid_provider") + # ### end Alembic commands ### diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py index 8a52d33bea3..a9a38d9df8d 100644 --- a/src/fides/api/db/base.py +++ b/src/fides/api/db/base.py @@ -21,6 +21,7 @@ from fides.api.models.manual_webhook import AccessManualWebhook from fides.api.models.messaging import MessagingConfig from fides.api.models.messaging_template import MessagingTemplate +from fides.api.models.openid_provider import OpenIDProvider from fides.api.models.policy import Policy, Rule, RuleTarget from fides.api.models.privacy_center_config import PrivacyCenterConfig from fides.api.models.privacy_experience import ( diff --git a/src/fides/api/models/openid_provider.py b/src/fides/api/models/openid_provider.py new file mode 100644 index 00000000000..c0a5637cdeb --- /dev/null +++ b/src/fides/api/models/openid_provider.py @@ -0,0 +1,53 @@ +import enum + +from sqlalchemy import Column +from sqlalchemy import Enum as EnumColumn +from sqlalchemy import String +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy_utils.types.encrypted.encrypted_type import ( + AesGcmEngine, + StringEncryptedType, +) + +from fides.api.db.base_class import Base +from fides.config import CONFIG + + +class ProviderEnum(enum.Enum): + google = "google" + okta = "okta" + custom = "custom" + + +class OpenIDProvider(Base): + """The DB ORM model for OpenIDProvider.""" + + identifier = Column(String, unique=True, index=True) + name = Column(String) + provider = Column(EnumColumn(ProviderEnum)) + client_id = Column( + StringEncryptedType( + type_in=String(), + key=CONFIG.security.app_encryption_key, + engine=AesGcmEngine, + padding="pkcs5", + ), + nullable=False, + ) + client_secret = Column( + StringEncryptedType( + type_in=String(), + key=CONFIG.security.app_encryption_key, + engine=AesGcmEngine, + padding="pkcs5", + ), + nullable=False, + ) + domain = Column(String, nullable=True) # Used for Okta provider + authorization_url = Column(String, nullable=True) # Used for Custom provider + token_url = Column(String, nullable=True) # Used for Custom provider + user_info_url = Column(String, nullable=True) # Used for Custom provider + + @declared_attr + def __tablename__(self) -> str: + return "openid_provider"