diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx index 5bb9e7b3d..5802278e1 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfig.tsx @@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; -import { PropTypesOf } from "coral-framework/types"; +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { FormFieldDescription } from "coral-ui/components/v2"; import Header from "../../Header"; import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField"; import RegistrationField from "./RegistrationField"; -import SSOKeyFieldContainer from "./SSOKeyFieldContainer"; +import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery"; import TargetFilterField from "./TargetFilterField"; // eslint-disable-next-line no-unused-expressions @@ -28,10 +29,9 @@ graphql` interface Props { disabled?: boolean; - sso: PropTypesOf["sso"]; } -const SSOConfig: FunctionComponent = ({ disabled, sso }) => ( +const SSOConfig: FunctionComponent = ({ disabled }) => ( @@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent = ({ disabled, sso }) => ( > {disabledInside => ( <> - + + } + DocLink={ + + } + > + + To enable integration with your existing authentication system, you + will need to create a JWT Token to connect. You can learn more about + creating a JWT Token with this introduction. See our documentation + for additional information on single sign on. + + + diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx index 13e9fee12..775eb0441 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOConfigContainer.tsx @@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent = ({ disabled, auth, }) => { - return ; + return ; }; const enhanced = withFragmentContainer({ auth: graphql` fragment SSOConfigContainer_auth on Auth { - integrations { - sso { - ...SSOKeyFieldContainer_sso - } - } + ...SSOConfig_formValues } `, })(SSOConfigContainer); diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.css deleted file mode 100644 index 1b2f8e3c5..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.css +++ /dev/null @@ -1,29 +0,0 @@ -.root { - padding-bottom: var(--spacing-4); -} - -.keyGenerated { - composes: button from "coral-ui/shared/typography.css"; - color: var(--palette-text-secondary); - flex-shrink: 0; -} - -.warnIcon { - color: var(--palette-text-secondary); - flex-shrink: 0; - padding-top: 3px; - padding-right: var(--spacing-1); -} - -.warn { - color: var(--palette-text-secondary); -} - -.warningSection { - padding-top: var(--spacing-1); - padding-bottom: var(--spacing-1); -} - -.regenerateButton { - float: right; -} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.tsx deleted file mode 100644 index 83d47b43c..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyField.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Localized } from "@fluent/react/compat"; -import React, { FunctionComponent } from "react"; - -import { - Button, - Flex, - FormField, - Icon, - Label, - PasswordField, -} from "coral-ui/components/v2"; - -import HelperText from "../../HelperText"; - -import styles from "./SSOKeyField.css"; - -interface Props { - disabled?: boolean; - generatedKey?: string; - keyGeneratedAt?: any; - onRegenerate?: () => void; -} - -const SSOKeyField: FunctionComponent = ({ - generatedKey, - keyGeneratedAt, - disabled, - onRegenerate, -}) => ( - - - - - - {keyGeneratedAt && ( - - - KEY GENERATED AT: {keyGeneratedAt} - - - )} -
- - warning - - - When regenerating a key, tokens signed with the previous key will be - honored for 30 days. - - - -
- - - - -
-); - -export default SSOKeyField; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyFieldContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyFieldContainer.tsx deleted file mode 100644 index 4c1fa0eff..000000000 --- a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyFieldContainer.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { graphql } from "react-relay"; - -import { - MutationProp, - withFragmentContainer, - withMutation, -} from "coral-framework/lib/relay"; - -import { SSOKeyFieldContainer_sso as SSOData } from "coral-admin/__generated__/SSOKeyFieldContainer_sso.graphql"; - -import RegenerateSSOKeyMutation from "./RegenerateSSOKeyMutation"; -import SSOKeyField from "./SSOKeyField"; - -interface Props { - sso: SSOData; - disabled?: boolean; - regenerateSSOKey: MutationProp; -} - -interface State { - awaitingResponse: boolean; -} - -class SSOKeyFieldContainer extends React.Component { - public state = { - awaitingResponse: false, - }; - - private handleRegenerate = async () => { - this.setState({ awaitingResponse: true }); - await this.props.regenerateSSOKey(); - this.setState({ awaitingResponse: false }); - }; - - public render() { - const { disabled } = this.props; - return ( - - ); - } -} - -const enhanced = withMutation(RegenerateSSOKeyMutation)( - withFragmentContainer({ - sso: graphql` - fragment SSOKeyFieldContainer_sso on SSOAuthIntegration { - key - keyGeneratedAt - } - `, - })(SSOKeyFieldContainer) -); - -export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css new file mode 100644 index 000000000..dee544d71 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.css @@ -0,0 +1,10 @@ +.label { + padding-bottom: var(--v2-spacing-2); +} + +.date { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-regular); + font-size: var(--v2-font-size-2); + line-height: var(--v2-line-height-reset); +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx new file mode 100644 index 000000000..528875980 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DateField.tsx @@ -0,0 +1,97 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { Flex, Label } from "coral-ui/components/v2"; + +import { SSOKeyStatus } from "./StatusField"; + +import styles from "./DateField.css"; + +export interface SSOKeyDates { + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly rotatedAt: string | null; + readonly inactiveAt: string | null; +} + +interface Props { + status: SSOKeyStatus; + dates: SSOKeyDates; +} + +const DateField: FunctionComponent = ({ status, dates }) => { + switch (status) { + case SSOKeyStatus.ACTIVE: + return ( + <> +
+ + + +
+ + {dates.createdAt} + + + ); + case SSOKeyStatus.EXPIRING: + return ( + <> +
+ + + +
+ + + {dates.inactiveAt} + + + + ); + case SSOKeyStatus.EXPIRED: + return ( + <> +
+ + + +
+ + + {dates.inactiveAt} + + + + ); + default: + return null; + } +}; + +export default DateField; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts new file mode 100644 index 000000000..16bab846d --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeactivateSSOKeyMutation.ts @@ -0,0 +1,52 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql"; + +const clientMutationId = 0; + +const DeactivateSSOKeyMutation = createMutation( + "deactivateSSOKey", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) { + deactivateSSOKey(input: $input) { + settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + }); + } +); + +export default DeactivateSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts new file mode 100644 index 000000000..b459854c1 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/DeleteSSOKeyMutation.ts @@ -0,0 +1,52 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql"; + +const clientMutationId = 0; + +const DeleteSSOKeyMutation = createMutation( + "deleteSSOKey", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) { + deleteSSOKey(input: $input) { + settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + }); + } +); + +export default DeleteSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts new file mode 100644 index 000000000..8010a0aa8 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotateSSOKeyMutation.ts @@ -0,0 +1,52 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql"; + +const clientMutationId = 0; + +const RotateSSOKeyMutation = createMutation( + "rotateSSOKey", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) { + rotateSSOKey(input: $input) { + settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + }); + } +); + +export default RotateSSOKeyMutation; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css new file mode 100644 index 000000000..5f3824668 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.css @@ -0,0 +1,3 @@ +.rotate { + margin-right: var(--v2-spacing-1) +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx new file mode 100644 index 000000000..f3eea1458 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationDropdown.tsx @@ -0,0 +1,72 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { + Button, + ClickOutside, + Dropdown, + DropdownButton, + Icon, + Popover, +} from "coral-ui/components/v2"; + +import RotateOption, { RotateOptions } from "./RotationOption"; + +import styles from "./RotationDropdown.css"; + +interface Props { + onRotateKey: (rotation: string) => void; + disabled?: boolean; +} + +const RotationDropDown: FunctionComponent = ({ + onRotateKey, + disabled, +}) => { + return ( + + ( + + + {Object.keys(RotateOptions).map((opt: string) => ( + { + onRotateKey(opt); + toggleVisibility(); + }} + disabled={disabled} + > + + + ))} + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + )} + + + ); +}; + +export default RotationDropDown; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx new file mode 100644 index 000000000..e0a02cbb5 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/RotationOption.tsx @@ -0,0 +1,46 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +export enum RotateOptions { + NOW = "NOW", + IN1DAY = "IN1DAY", + IN1WEEK = "IN1WEEK", + IN30DAYS = "IN30DAYS", +} + +interface Props { + value: string; +} + +const RotationOption: FunctionComponent = ({ value }) => { + switch (value) { + case RotateOptions.NOW: { + return Now; + } + case RotateOptions.IN1DAY: { + return ( + + 1 day from now + + ); + } + case RotateOptions.IN1WEEK: { + return ( + + 1 week from now + + ); + } + case RotateOptions.IN30DAYS: { + return ( + + 30 days from now + + ); + } + default: + return Now; + } +}; + +export default RotationOption; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css new file mode 100644 index 000000000..2b4e295fe --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.css @@ -0,0 +1,23 @@ +.label { + padding-bottom: var(--v2-spacing-2); +} + +.keySection { + flex-grow: 1; + min-width: 50px; + + padding-right: var(--v2-spacing-3); +} + +.statusSection { + margin-right: var(--v2-spacing-3); +} + +.secretSection { + flex-grow: 1; + min-width: 50px; +} + +.action { + margin-right: var(--v2-spacing-1) +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx new file mode 100644 index 000000000..39df18340 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyCard.tsx @@ -0,0 +1,187 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import CopyToClipboard from "react-copy-to-clipboard"; + +import { useMutation } from "coral-framework/lib/relay"; +import { + Button, + Card, + Flex, + HorizontalGutter, + Icon, + Label, + PasswordField, + TextField, +} from "coral-ui/components/v2"; + +import DateField from "./DateField"; +import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation"; +import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation"; +import RotateSSOKeyMutation from "./RotateSSOKeyMutation"; +import RotationDropDown from "./RotationDropdown"; +import { RotateOptions } from "./RotationOption"; +import StatusField, { SSOKeyStatus } from "./StatusField"; + +import styles from "./SSOKeyCard.css"; + +export interface SSOKeyDates { + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly rotatedAt: string | null; + readonly inactiveAt: string | null; +} + +interface Props { + id: string; + secret: string; + status: SSOKeyStatus; + dates: SSOKeyDates; + disabled?: boolean; +} + +function createActionButton( + status: SSOKeyStatus, + onRotateKey: (rotation: string) => void, + onDeactivateKey: () => void, + onDelete: () => void, + disabled?: boolean +) { + switch (status) { + case SSOKeyStatus.ACTIVE: + return ; + case SSOKeyStatus.EXPIRING: + return ( + + + + ); + case SSOKeyStatus.EXPIRED: + return ( + + + + ); + default: + return null; + } +} + +const SSOKeyCard: FunctionComponent = ({ + id, + secret, + status, + dates, + disabled, +}) => { + const rotateSSOKey = useMutation(RotateSSOKeyMutation); + const deactivateSSOKey = useMutation(DeactivateSSOKeyMutation); + const deleteSSOKey = useMutation(DeleteSSOKeyMutation); + + const onRotate = useCallback( + (rotation: string) => { + switch (rotation) { + case RotateOptions.NOW: + rotateSSOKey({ inactiveIn: 0 }); + break; + case RotateOptions.IN1DAY: + rotateSSOKey({ inactiveIn: 24 * 60 * 60 }); + break; + case RotateOptions.IN1WEEK: + rotateSSOKey({ inactiveIn: 7 * 24 * 60 * 60 }); + break; + case RotateOptions.IN30DAYS: + rotateSSOKey({ inactiveIn: 30 * 24 * 60 * 60 }); + break; + default: + rotateSSOKey({ inactiveIn: 0 }); + } + }, + [rotateSSOKey] + ); + const onDeactivate = useCallback(() => { + deactivateSSOKey({ + kid: id, + }); + }, [deactivateSSOKey, id]); + const onDelete = useCallback(() => { + deleteSSOKey({ + kid: id, + }); + }, [deleteSSOKey, id]); + + return ( + + + +
+
+ + + +
+ +
+
+
+ + + +
+ + + + + + +
+
+ + +
+
+ + + +
+ +
+
+ +
+
+ {createActionButton( + status, + onRotate, + onDeactivate, + onDelete, + disabled + )} +
+
+
+ ); +}; + +export default SSOKeyCard; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationContainer.tsx new file mode 100644 index 000000000..34f828982 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationContainer.tsx @@ -0,0 +1,130 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useMemo } from "react"; + +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { Label } from "coral-ui/components/v2"; + +import { SSOKeyRotationContainer_settings } from "coral-admin/__generated__/SSOKeyRotationContainer_settings.graphql"; + +import SSOKeyCard, { SSOKeyDates } from "./SSOKeyCard"; +import { SSOKeyStatus } from "./StatusField"; + +interface Props { + settings: SSOKeyRotationContainer_settings; + disabled?: boolean; +} + +interface Key { + readonly kid: string; + readonly secret: string; + readonly createdAt: string; + readonly lastUsedAt: string | null; + readonly rotatedAt: string | null; + readonly inactiveAt: string | null; +} + +function getStatus(dates: SSOKeyDates) { + if ( + dates.inactiveAt && + dates.rotatedAt && + new Date(dates.inactiveAt) > new Date() + ) { + return SSOKeyStatus.EXPIRING; + } + + if (dates.inactiveAt && new Date(dates.inactiveAt) <= new Date()) { + return SSOKeyStatus.EXPIRED; + } + + return SSOKeyStatus.ACTIVE; +} + +const SSOKeyRotationContainer: FunctionComponent = ({ + disabled, + settings, +}) => { + const { + auth: { + integrations: { + sso: { keys }, + }, + }, + } = settings; + + const sortedKeys = useMemo( + () => + keys + // Copy this map because we don't want to modify the underlying copy. + .map(key => key) + .sort((a: Key, b: Key) => { + // Both active, sort on createdAt date. + if (!a.inactiveAt && !b.inactiveAt) { + return ( + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + } + // A is active, B is not, A comes before B. + if (!a.inactiveAt && b.inactiveAt) { + return -1; + } + // B is active, A is not, B comes before A. + if (a.inactiveAt && !b.inactiveAt) { + return 1; + } + + // Sort primarily on inactiveAt, fall back to createdAt if + // for some reason it's not available. + const aDate = a.inactiveAt + ? new Date(a.inactiveAt) + : new Date(a.createdAt); + const bDate = b.inactiveAt + ? new Date(b.inactiveAt) + : new Date(b.createdAt); + + return bDate.getTime() - aDate.getTime(); + }), + [keys] + ); + + return ( + <> + + + + {sortedKeys.map(key => ( + + ))} + + ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment SSOKeyRotationContainer_settings on Settings { + auth { + integrations { + sso { + enabled + keys { + kid + secret + createdAt + lastUsedAt + rotatedAt + inactiveAt + } + } + } + } + } + `, +})(SSOKeyRotationContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationQuery.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationQuery.tsx new file mode 100644 index 000000000..dcf7c62b2 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/SSOKeyRotationQuery.tsx @@ -0,0 +1,54 @@ +import React, { FunctionComponent } from "react"; + +import { + graphql, + QueryRenderData, + QueryRenderer, +} from "coral-framework/lib/relay"; +import { CallOut, Spinner } from "coral-ui/components/v2"; + +import { SSOKeyRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOKeyRotationQuery.graphql"; + +import SSOKeyRotationContainer from "./SSOKeyRotationContainer"; + +interface Props { + disabled?: boolean; +} + +const SSOKeyRotationQuery: FunctionComponent = ({ disabled }) => { + return ( + + query={graphql` + query SSOKeyRotationQuery { + settings { + ...SSOKeyRotationContainer_settings + } + } + `} + variables={{}} + cacheConfig={{ force: true }} + render={({ error, props }: QueryRenderData) => { + if (error) { + return {error.message}; + } + + if (!props) { + return ; + } + + if (!props.settings) { + return ; + } + + return ( + + ); + }} + /> + ); +}; + +export default SSOKeyRotationQuery; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.css b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.css new file mode 100644 index 000000000..9c8630cd4 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.css @@ -0,0 +1,35 @@ +.status { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-regular); + font-size: var(--v2-font-size-2); + line-height: var(--v2-line-height-reset); + + border-radius: 2px; + padding-left: var(--v2-spacing-1); + padding-right: var(--v2-spacing-1); +} + +.active { + background-color: var(--v2-colors-green-500); + color: var(--v2-colors-pure-white); +} + +.expiring { + background-color: var(--v2-colors-yellow-500); + color: var(--v2-colors-mono-500); + + padding-top: var(--v2-spacing-1); + padding-bottom: var(--v2-spacing-1); +} + +.expired { + background-color: var(--v2-colors-red-500); + color: var(--v2-colors-pure-white); + + padding-top: var(--v2-spacing-1); + padding-bottom: var(--v2-spacing-1); +} + +.icon { + padding-right: var(--v2-spacing-1); +} diff --git a/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.tsx b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.tsx new file mode 100644 index 000000000..c6f835554 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Auth/SSOKeyRotation/StatusField.tsx @@ -0,0 +1,117 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { Flex, Icon, Tooltip, TooltipButton } from "coral-ui/components/v2"; + +import styles from "./StatusField.css"; + +export enum SSOKeyStatus { + EXPIRED, + EXPIRING, + ACTIVE, +} + +interface Props { + status: SSOKeyStatus; +} + +const StatusField: FunctionComponent = ({ status }) => { + switch (status) { + case SSOKeyStatus.ACTIVE: + return ( + + + Active + + + ); + case SSOKeyStatus.EXPIRING: + return ( + + + alarm + + Expiring + + + + + An SSO key is expiring when it is scheduled for rotation. + + + } + button={({ toggleVisibility, ref, visible }) => ( + + + + )} + /> + + ); + case SSOKeyStatus.EXPIRED: + return ( + + + + Expired + + + + + An SSO key is expired when it has been rotated out of use. + + + } + button={({ toggleVisibility, ref, visible }) => ( + + + + )} + /> + + ); + default: + return ( + + Unknown + + ); + } +}; + +export default StatusField; diff --git a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap index 40e92c098..c261b3c9a 100644 --- a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap @@ -1242,86 +1242,233 @@ integration to register for a new account.
-
- + this introduction + + . See our + + + documentation + + for additional information on single sign on. +

+ +
-
- + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+ + Active + +
+
+
+ +
+ + 1/1/2020, 1:00 AM + +
+
+
+ +
-
-
- -

- When regenerating a key, tokens signed with the previous key will be honored for 30 days. -

-
-
-
{ expect(within(configureContainer).toJSON()).toMatchSnapshot(); }); -it("regenerate sso key", async () => { +it("rotate sso key", async () => { const { testRenderer } = await createTestRenderer({ resolvers: createResolversStub({ Mutation: { - regenerateSSOKey: () => { + rotateSSOKey: () => { return { settings: pureMerge( settingsWithEmptyAuth, @@ -69,8 +70,22 @@ it("regenerate sso key", async () => { auth: { integrations: { sso: { - key: "==GENERATED_KEY==", - keyGeneratedAt: "2018-11-12T23:26:06.239Z", + enabled: true, + keys: [ + { + kid: "kid-01", + secret: "secret", + createdAt: "2015-01-01T00:00:00.000Z", + lastUsedAt: "2016-01-01T01:45:00.000Z", + rotatedAt: "2016-01-01T01:45:00.000Z", + inactiveAt: "2016-01-01T01:45:00.000Z", + }, + { + kid: "kid-02", + secret: "new-secret", + createdAt: "2019-01-01T01:45:00.000Z", + }, + ], }, }, }, @@ -90,15 +105,40 @@ it("regenerate sso key", async () => { act(() => { within(container) - .getByText("Regenerate", { selector: "button" }) + .getByText("Rotate", { selector: "button" }) .props.onClick(); }); - await wait(() => - expect(within(container).getByLabelText("Key").props.value).toBe( - "==GENERATED_KEY==" - ) - ); + const rotateNow = await waitForElement(() => { + return within(container).getByText("Now", { selector: "button" }); + }); + + act(() => { + rotateNow.props.onClick(); + }); + + await wait(() => { + // Check that we have two SSO Keys that match + // our expected key IDs + const keyIDs = within(container).getAllByTestID("SSO-Key-ID"); + const hasOldKey = keyIDs.some(k => k.props.value === "kid-01"); + const hasNewKey = keyIDs.some(k => k.props.value === "kid-02"); + expect(hasNewKey).toBe(true); + expect(hasOldKey).toBe(true); + + const statuses = within(container).getAllByTestID("SSO-Key-Status"); + expect(statuses.length).toBe(2); + const firstStatus: any = toJSON(statuses[0]); + const firstStatusIsActive = firstStatus.children.some( + (s: string) => s === "Active" + ); + expect(firstStatusIsActive).toBe(true); + const secondStatus: any = toJSON(statuses[1]); + const secondStatusIsActive = secondStatus.children.some( + (s: string) => s === "Active" + ); + expect(secondStatusIsActive).toBe(false); + }); }); it("prevents admin lock out", async () => { diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index a2ca94e2c..ae14ee502 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -114,6 +114,16 @@ export const settings = createFixture({ admin: true, stream: true, }, + keys: [ + { + kid: "kid-01", + secret: "secret", + createdAt: "2020-01-01T01:00:00.000Z", + lastUsedAt: undefined, + rotatedAt: undefined, + inactiveAt: undefined, + }, + ], key: "", keyGeneratedAt: null, }, @@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture( stream: true, }, key: "", + keys: [ + { + kid: "kid-01", + secret: "secret", + createdAt: "2020-01-01T01:00:00.000Z", + lastUsedAt: undefined, + rotatedAt: undefined, + inactiveAt: undefined, + }, + ], keyGeneratedAt: null, }, google: { diff --git a/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts b/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts index bf71f37b3..939858743 100644 --- a/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts +++ b/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts @@ -16,7 +16,7 @@ const AddExpertMutation = createMutation( (environment: Environment, input: MutationInput) => commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation AddExpertMutation($input: AddExpertInput!) { + mutation AddExpertMutation($input: AddStoryExpertInput!) { addStoryExpert(input: $input) { story { id diff --git a/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts b/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts index aecfe7a56..9c559e8dd 100644 --- a/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts +++ b/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts @@ -16,7 +16,7 @@ const RemoveExpertMutation = createMutation( (environment: Environment, input: MutationInput) => commitMutationPromiseNormalized(environment, { mutation: graphql` - mutation RemoveExpertMutation($input: RemoveExpertInput!) { + mutation RemoveExpertMutation($input: RemoveStoryExpertInput!) { removeStoryExpert(input: $input) { story { id diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index ad4bc1454..111f23d39 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -1,12 +1,17 @@ +import { Redis } from "ioredis"; import Joi from "joi"; -import { isNil } from "lodash"; +import { isNil, throttle } from "lodash"; import { DateTime } from "luxon"; import { Db } from "mongodb"; import { validate } from "coral-server/app/request/body"; import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors"; +import logger from "coral-server/logger"; import { Secret, SSOAuthIntegration } from "coral-server/models/settings"; -import { Tenant } from "coral-server/models/tenant"; +import { + Tenant, + updateLastUsedAtTenantSSOKey, +} from "coral-server/models/tenant"; import { retrieveUserWithProfile, SSOProfile, @@ -163,6 +168,22 @@ export async function findOrCreateSSOUser( return user; } +const updateLastUsedAtKID = throttle( + async (redis: Redis, tenantID: string, kid: string, now: Date) => { + try { + await updateLastUsedAtTenantSSOKey(redis, tenantID, kid, now); + logger.trace({ tenantID, kid }, "updated last used tenant sso key"); + } catch (err) { + logger.error( + { err, tenantID, kid }, + "could not update the last used tenant sso key" + ); + } + }, + // Only let this update the last used time stamp every minute. + 60 * 1000 +); + export interface SSOVerifierOptions { mongo: Db; redis: AugmentedRedis; @@ -285,9 +306,13 @@ export class SSOVerifier implements Verifier { continue; } + // The verification did not throw an error, which means the verification + // succeeded! Mark the key as used last now and break out. We should do + // this in the nextTick because it's not important to have it recorded at + // the same time. + updateLastUsedAtKID(this.redis, tenant.id, key.kid, now); + // TODO: [CORL-754] (wyattjoh) reintroduce when we amend the front-end to display the kid - // // The verification did not throw an error, which means the verification - // // succeeded! Break out now. // if (!kid) { // logger.warn( // { tenantID: tenant.id, kid: config.kid }, diff --git a/src/core/server/graph/loaders/Auth.ts b/src/core/server/graph/loaders/Auth.ts index a294fe1d2..076830306 100644 --- a/src/core/server/graph/loaders/Auth.ts +++ b/src/core/server/graph/loaders/Auth.ts @@ -1,9 +1,11 @@ import DataLoader from "dataloader"; import GraphContext from "coral-server/graph/context"; -import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types"; +import { retrieveLastUsedAtTenantSSOKeys } from "coral-server/models/tenant"; import { discoverOIDCConfiguration } from "coral-server/services/tenant"; +import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types"; + export default (ctx: GraphContext) => ({ discoverOIDCConfiguration: new DataLoader< string, @@ -17,4 +19,7 @@ export default (ctx: GraphContext) => ({ cache: !ctx.disableCaching, } ), + retrieveSSOKeyLastUsedAt: new DataLoader((kids: string[]) => + retrieveLastUsedAtTenantSSOKeys(ctx.redis, ctx.tenant.id, kids) + ), }); diff --git a/src/core/server/graph/mutators/Settings.ts b/src/core/server/graph/mutators/Settings.ts index 2b2908a01..cda89b2b1 100644 --- a/src/core/server/graph/mutators/Settings.ts +++ b/src/core/server/graph/mutators/Settings.ts @@ -3,13 +3,16 @@ import { Tenant } from "coral-server/models/tenant"; import { createAnnouncement, createWebhookEndpoint, + deactivateSSOKey, deleteAnnouncement, + deleteSSOKey, deleteWebhookEndpoint, disableFeatureFlag, disableWebhookEndpoint, enableFeatureFlag, enableWebhookEndpoint, regenerateSSOKey, + rotateSSOKey, rotateWebhookEndpointSecret, update, updateWebhookEndpoint, @@ -18,10 +21,13 @@ import { import { GQLCreateAnnouncementInput, GQLCreateWebhookEndpointInput, + GQLDeactivateSSOKeyInput, + GQLDeleteSSOKeyInput, GQLDeleteWebhookEndpointInput, GQLDisableWebhookEndpointInput, GQLEnableWebhookEndpointInput, GQLFEATURE_FLAG, + GQLRotateSSOKeyInput, GQLRotateWebhookEndpointSecretInput, GQLUpdateSettingsInput, GQLUpdateWebhookEndpointInput, @@ -43,6 +49,12 @@ export const Settings = ({ update(mongo, redis, tenantCache, config, tenant, input.settings), regenerateSSOKey: (): Promise => regenerateSSOKey(mongo, redis, tenantCache, tenant, now), + rotateSSOKey: ({ inactiveIn }: GQLRotateSSOKeyInput) => + rotateSSOKey(mongo, redis, tenantCache, tenant, inactiveIn, now), + deactivateSSOKey: ({ kid }: GQLDeactivateSSOKeyInput) => + deactivateSSOKey(mongo, redis, tenantCache, tenant, kid, now), + deleteSSOKey: ({ kid }: GQLDeleteSSOKeyInput) => + deleteSSOKey(mongo, redis, tenantCache, tenant, kid), enableFeatureFlag: (flag: GQLFEATURE_FLAG) => enableFeatureFlag(mongo, redis, tenantCache, tenant, flag), disableFeatureFlag: (flag: GQLFEATURE_FLAG) => diff --git a/src/core/server/graph/mutators/Stories.ts b/src/core/server/graph/mutators/Stories.ts index e60239a27..2a2e67c7c 100644 --- a/src/core/server/graph/mutators/Stories.ts +++ b/src/core/server/graph/mutators/Stories.ts @@ -19,12 +19,12 @@ import { import { scrape } from "coral-server/services/stories/scraper"; import { - GQLAddExpertInput, + GQLAddStoryExpertInput, GQLCloseStoryInput, GQLCreateStoryInput, GQLMergeStoriesInput, GQLOpenStoryInput, - GQLRemoveExpertInput, + GQLRemoveStoryExpertInput, GQLRemoveStoryInput, GQLScrapeStoryInput, GQLUpdateStoryInput, @@ -78,8 +78,8 @@ export const Stories = (ctx: GraphContext) => ({ scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id), updateStoryMode: async (input: GQLUpdateStoryModeInput) => updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode), - addStoryExpert: async (input: GQLAddExpertInput) => + addStoryExpert: async (input: GQLAddStoryExpertInput) => addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID), - removeStoryExpert: async (input: GQLRemoveExpertInput) => + removeStoryExpert: async (input: GQLRemoveStoryExpertInput) => removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID), }); diff --git a/src/core/server/graph/resolvers/Mutation.ts b/src/core/server/graph/resolvers/Mutation.ts index 5db79823e..141a515d4 100644 --- a/src/core/server/graph/resolvers/Mutation.ts +++ b/src/core/server/graph/resolvers/Mutation.ts @@ -1,7 +1,5 @@ import { GQLMutationTypeResolver } from "coral-server/graph/schema/__generated__/types"; -// TODO: (wyattjoh) add rate limiting to these edges - export const Mutation: Required> = { editComment: async (source, { input }, ctx) => ({ comment: await ctx.mutators.Comments.edit(input), @@ -81,6 +79,18 @@ export const Mutation: Required> = { settings: await ctx.mutators.Settings.regenerateSSOKey(), clientMutationId: input.clientMutationId, }), + rotateSSOKey: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.rotateSSOKey(input), + clientMutationId: input.clientMutationId, + }), + deactivateSSOKey: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.deactivateSSOKey(input), + clientMutationId: input.clientMutationId, + }), + deleteSSOKey: async (source, { input }, ctx) => ({ + settings: await ctx.mutators.Settings.deleteSSOKey(input), + clientMutationId: input.clientMutationId, + }), createStory: async (source, { input }, ctx) => ({ story: await ctx.mutators.Stories.create(input), clientMutationId: input.clientMutationId, diff --git a/src/core/server/graph/resolvers/Secret.ts b/src/core/server/graph/resolvers/Secret.ts new file mode 100644 index 000000000..dccbb155a --- /dev/null +++ b/src/core/server/graph/resolvers/Secret.ts @@ -0,0 +1,8 @@ +import * as settings from "coral-server/models/settings"; + +import { GQLSecretTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const Secret: GQLSecretTypeResolver = { + lastUsedAt: async ({ kid }, args, ctx) => + ctx.loaders.Auth.retrieveSSOKeyLastUsedAt.load(kid), +}; diff --git a/src/core/server/graph/resolvers/index.ts b/src/core/server/graph/resolvers/index.ts index 0ba8f620d..7b78a0a38 100644 --- a/src/core/server/graph/resolvers/index.ts +++ b/src/core/server/graph/resolvers/index.ts @@ -37,6 +37,7 @@ import { Profile } from "./Profile"; import { Query } from "./Query"; import { RecentCommentHistory } from "./RecentCommentHistory"; import { RejectCommentPayload } from "./RejectCommentPayload"; +import { Secret } from "./Secret"; import { Settings } from "./Settings"; import { SlackConfiguration } from "./SlackConfiguration"; import { SSOAuthIntegration } from "./SSOAuthIntegration"; @@ -89,6 +90,7 @@ const Resolvers: GQLResolver = { RecentCommentHistory, RejectCommentPayload, SSOAuthIntegration, + Secret, Story, StorySettings, Subscription, diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index 4cf3ca649..2b63a8675 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -65,6 +65,13 @@ rate enforces a rate limit on requests made by the user. """ directive @rate(max: Int = 1, seconds: Int!, key: String) on FIELD_DEFINITION +""" +deprecated indicates that a field should not be used in the future. +""" +directive @deprecated( + reason: String = "No longer supported" +) on FIELD_DEFINITION | ENUM_VALUE + ################################################################################ ## Custom Scalar Types ################################################################################ @@ -483,6 +490,40 @@ type LocalAuthIntegration { ## SSOAuthIntegration ########################## +type Secret { + """ + kid is the identifier for the key used when verifying tokens issued by the + provider. + """ + kid: String! + + """ + secret is the actual underlying secret used to verify the tokens with. + """ + secret: String! + + """ + createdAt is the date that the key was created at. + """ + createdAt: Time! + + """ + lastUsedAt is the time that the + """ + lastUsedAt: Time + + """ + rotatedAt is the time that the token was rotated out. + """ + rotatedAt: Time + + """ + inactiveAt is the date that the token can no longer be used to validate + tokens. + """ + inactiveAt: Time +} + """ SSOAuthIntegration is an AuthIntegration that provides a secret to the admins of a tenant, where they can sign a SSO payload with it to provide to the @@ -504,15 +545,24 @@ type SSOAuthIntegration { """ targetFilter: AuthenticationTargetFilter! + """ + keys are the different SSOKey's used by this Tenant. + """ + keys: [Secret!]! @auth(roles: [ADMIN]) + """ key is the secret that is used to sign tokens. """ - key: String @auth(roles: [ADMIN]) + key: String + @auth(roles: [ADMIN]) + @deprecated(reason: "field is deprecated in favour of `keys`") """ keyGeneratedAt is the Time that the key was effective from. """ - keyGeneratedAt: Time @auth(roles: [ADMIN]) + keyGeneratedAt: Time + @auth(roles: [ADMIN]) + @deprecated(reason: "field is deprecated in favour of `keys`") } ########################## @@ -1234,21 +1284,6 @@ enum WEBHOOK_EVENT_NAME { STORY_CREATED } -""" -TODO: merge with SSOKey with PR #2732 -""" -type Secret { - """ - secret is the actual underlying secret used to verify the tokens with. - """ - secret: String! - - """ - createdAt is the date that the key was created at. - """ - createdAt: Time! -} - type WebhookEndpoint { """ id is the unique identifier for this specific endpoint. @@ -1342,6 +1377,28 @@ type Announcement { content: String! } +type Site { + """ + id is the identifier of the Site. + """ + id: ID! + + """ + name is the name of the Site. + """ + name: String! + + """ + allowedOrigins are the allowed origins for embeds. + """ + allowedOrigins: [String!]! + + """ + createdAt is when the site was created. + """ + createdAt: Time! +} + """ Settings stores the global settings for a given Tenant. """ @@ -5855,6 +5912,92 @@ type EnableFeatureFlagPayload { flags: [FEATURE_FLAG!]! } +######################### +## rotateSSOKey +######################### + +input RotateSSOKeyInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + inactiveIn is the number of seconds that the current active SSOKey should be + kept active (allow signed tokens signed with this secret) before rejecting + them. + """ + inactiveIn: Int! +} + +type RotateSSOKeyPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + settings is the Settings that the SSO key was regenerated on. + """ + settings: Settings +} + +######################### +## deactivateSSOKey +######################### + +input DeactivateSSOKeyInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + kid is the ID of the SSOKey being deactivated. + """ + kid: ID! +} + +type DeactivateSSOKeyPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + settings is the Settings that the SSO key was regenerated on. + """ + settings: Settings +} + +######################### +## deleteSSOKey +######################### + +input DeleteSSOKeyInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + kid is the ID of the SSOKey being deleted. + """ + kid: ID! +} + +type DeleteSSOKeyPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + settings is the Settings that the SSO key was regenerated on. + """ + settings: Settings +} + ######################### # disableFeatureFlag ######################### @@ -5884,10 +6027,10 @@ type DisableFeatureFlagPayload { } ######################### -# Add / Remove Expert +# addStoryExpert ######################### -input AddExpertInput { +input AddStoryExpertInput { """ clientMutationId is required for Relay support. """ @@ -5904,7 +6047,7 @@ input AddExpertInput { userID: ID! } -type AddExpertPayload { +type AddStoryExpertPayload { """ clientMutationId is required for Relay support. """ @@ -5916,7 +6059,11 @@ type AddExpertPayload { story: Story! } -input RemoveExpertInput { +######################### +# removeStoryExpert +######################### + +input RemoveStoryExpertInput { """ clientMutationId is required for Relay support. """ @@ -5933,7 +6080,7 @@ input RemoveExpertInput { userID: ID! } -type RemoveExpertPayload { +type RemoveStoryExpertPayload { """ clientMutationId is required for Relay support. """ @@ -5945,6 +6092,10 @@ type RemoveExpertPayload { story: Story! } +######################### +## updateStoryMode +######################### + input UpdateStoryModeInput { """ storyID is the story id to enable Q&A on. @@ -5963,15 +6114,110 @@ input UpdateStoryModeInput { } type UpdateStoryModePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + """ story is the resultant story that Q&A was enabled on. """ story: Story! +} +################## +## createSite +################## + +input CreateSite { + """ + name is the name of the Site. + """ + name: String! + + """ + allowedOrigins are the allowed origins for embeds. + """ + allowedOrigins: [String!]! +} + +input CreateSiteInput { """ clientMutationId is required for Relay support. """ clientMutationId: String! + + """ + site is the input for the Site to create. + """ + site: CreateSite! +} + +type CreateSitePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + site is the Site that was newly created. + """ + site: Site! +} + +################## +## updateSite +################## + +input UpdateSite { + """ + name is the name of the Site. + """ + name: String + + """ + url is the Site URL, seen in email communications. + """ + url: String + + """ + contactEmail is the contact email for the Site, seen in email communications. + """ + contactEmail: String + + """ + allowedOrigins are the allowed origins for embeds. + """ + allowedOrigins: [String!] +} + +input UpdateSiteInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + id is the ID of the Site to update. + """ + id: ID! + + """ + site is the updates for the Site. + """ + site: UpdateSite! +} + +type UpdateSitePayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + site is the newly updated Site. + """ + site: Site! } ################## @@ -6012,6 +6258,25 @@ type Mutation { """ regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload! @auth(roles: [ADMIN]) + @deprecated(reason: "deprecated in favour of `rotateSSOKey`") + + """ + rotateSSOKey can be used to rotate a given active SSOKey. + """ + rotateSSOKey(input: RotateSSOKeyInput!): RotateSSOKeyPayload! + @auth(roles: [ADMIN]) + + """ + deactivateSSOKey will deactivate a given deactivated SSOKey. + """ + deactivateSSOKey(input: DeactivateSSOKeyInput!): DeactivateSSOKeyPayload! + @auth(roles: [ADMIN]) + + """ + deleteSSOKey will delete a given inactive SSOKey. + """ + deleteSSOKey(input: DeleteSSOKeyInput!): DeleteSSOKeyPayload! + @auth(roles: [ADMIN]) """ createCommentReaction will create a Reaction authored by the current logged in @@ -6416,13 +6681,13 @@ type Mutation { """ addStoryExpert adds an expert to a story. """ - addStoryExpert(input: AddExpertInput!): AddExpertPayload! + addStoryExpert(input: AddStoryExpertInput!): AddStoryExpertPayload! @auth(roles: [ADMIN, MODERATOR]) """ removeStoryExpert removes an expert from a story. """ - removeStoryExpert(input: RemoveExpertInput!): RemoveExpertPayload! + removeStoryExpert(input: RemoveStoryExpertInput!): RemoveStoryExpertPayload! @auth(roles: [ADMIN, MODERATOR]) } @@ -6608,80 +6873,3 @@ type Subscription { commentFeatured(storyID: ID!): CommentFeaturedPayload! @auth(roles: [MODERATOR, ADMIN]) } - -type Site { - """ - id is the identifier of the Site. - """ - id: ID! - - """ - name is the name of the Site. - """ - name: String! - - """ - allowedOrigins are the allowed origins for embeds. - """ - allowedOrigins: [String!]! - - """ - createdAt is when the site was created. - """ - createdAt: Time! -} - -input CreateSite { - """ - name is the name of the Site. - """ - name: String! - - """ - allowedOrigins are the allowed origins for embeds. - """ - allowedOrigins: [String!]! -} - -input UpdateSite { - """ - name is the name of the Site. - """ - name: String - - """ - url is the Site URL, seen in email communications. - """ - url: String - - """ - contactEmail is the contact email for the Site, seen in email communications. - """ - contactEmail: String - - """ - allowedOrigins are the allowed origins for embeds. - """ - allowedOrigins: [String!] -} - -input CreateSiteInput { - clientMutationId: String! - site: CreateSite! -} - -type CreateSitePayload { - clientMutationId: String! - site: Site! -} - -input UpdateSiteInput { - clientMutationId: String! - site: UpdateSite! - id: ID! -} - -type UpdateSitePayload { - clientMutationId: String! - site: Site! -} diff --git a/src/core/server/models/tenant/tenant.ts b/src/core/server/models/tenant/tenant.ts index 8d40e9877..7fbaa8256 100644 --- a/src/core/server/models/tenant/tenant.ts +++ b/src/core/server/models/tenant/tenant.ts @@ -1,3 +1,4 @@ +import { Redis } from "ioredis"; import { isEmpty } from "lodash"; import { DateTime } from "luxon"; import { Db } from "mongodb"; @@ -10,7 +11,6 @@ import { DeepPartial, Omit, Sub } from "coral-common/types"; import { isBeforeDate } from "coral-common/utils"; import { dotize } from "coral-common/utils/dotize"; import logger from "coral-server/logger"; -import { Secret, Settings } from "coral-server/models/settings"; import { I18n } from "coral-server/services/i18n"; import { tenants as collection } from "coral-server/services/mongodb/collections"; @@ -22,6 +22,7 @@ import { GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; +import { Secret, Settings } from "../settings"; import { generateSecret, getDefaultReactionConfiguration, @@ -388,7 +389,7 @@ export async function createTenantSSOKey(mongo: Db, id: string, now: Date) { return result.value || null; } -export async function rotateTenantSSOKey( +export async function deactivateTenantSSOKey( mongo: Db, id: string, kid: string, @@ -463,6 +464,24 @@ export async function disableTenantFeatureFlag( return result.value || null; } +export async function deleteTenantSSOKey(mongo: Db, id: string, kid: string) { + // Update the tenant. + const result = await collection(mongo).findOneAndUpdate( + { id }, + { + $pull: { + "auth.integrations.sso.keys": { kid }, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + + return result.value || null; +} export interface CreateAnnouncementInput { content: string; @@ -767,3 +786,66 @@ export async function deleteTenantWebhookEndpoint( return result.value; } + +function lastUsedAtTenantSSOKey(id: string): string { + return `${id}:lastUsedSSOKey`; +} + +/** + * updateLastUsedAtTenantSSOKey will update the time stamp that the SSO key was + * last used at. + * + * @param redis the Redis connection to use to update the timestamp on + * @param id the ID of the Tenant + * @param kid the kid of the token that was used + * @param when the date that the token was last used at + */ +export async function updateLastUsedAtTenantSSOKey( + redis: Redis, + id: string, + kid: string, + when: Date +) { + await redis.hset(lastUsedAtTenantSSOKey(id), kid, when.toISOString()); +} + +/** + * + * @param redis the Redis connection to use to remove the last used on. + * @param id the ID of the Tenant + * @param kid the kid of the token that is being deleted + */ +export async function deleteLastUsedAtTenantSSOKey( + redis: Redis, + id: string, + kid: string +) { + await redis.hdel(lastUsedAtTenantSSOKey(id), kid); +} + +/** + * retrieveLastUsedAtTenantSSOKeys will get the dates that the requested sso + * keys were last used on. + * + * @param redis the Redis connection to use to update the timestamp on + * @param id the ID of the Tenant + * @param kids the kids of the tokens that we want to know when they were last used + */ +export async function retrieveLastUsedAtTenantSSOKeys( + redis: Redis, + id: string, + kids: string[] +) { + const results: Array = await redis.hmget( + lastUsedAtTenantSSOKey(id), + ...kids + ); + + return results.map(lastUsedAt => { + if (!lastUsedAt) { + return null; + } + + return new Date(lastUsedAt); + }); +} diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 47494ea78..36a1b843c 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -1,548 +1,2 @@ -import { Redis } from "ioredis"; -import { isUndefined, lowerCase, uniqBy } from "lodash"; -import { DateTime } from "luxon"; -import { Db } from "mongodb"; -import { URL } from "url"; - -import { discover } from "coral-server/app/middleware/passport/strategies/oidc/discover"; -import { Config } from "coral-server/config"; -import { TenantInstalledAlreadyError } from "coral-server/errors"; -import logger from "coral-server/logger"; -import { - CreateAnnouncementInput, - createTenant, - createTenantAnnouncement, - CreateTenantInput, - createTenantSSOKey, - createTenantWebhookEndpoint, - CreateTenantWebhookEndpointInput, - deleteTenantAnnouncement, - deleteTenantWebhookEndpoint, - disableTenantFeatureFlag, - enableTenantFeatureFlag, - getWebhookEndpoint, - rollTenantWebhookEndpointSecret, - rotateTenantSSOKey, - Tenant, - updateTenant, - updateTenantWebhookEndpoint, - UpdateTenantWebhookEndpointInput, -} from "coral-server/models/tenant"; -import { I18n } from "coral-server/services/i18n"; - -import { - GQLFEATURE_FLAG, - GQLSettingsInput, - GQLSettingsWordListInput, - GQLWEBHOOK_EVENT_NAME, -} from "coral-server/graph/schema/__generated__/types"; - -import TenantCache from "./cache"; - -export type UpdateTenant = GQLSettingsInput; - -function cleanWordList( - list: GQLSettingsWordListInput -): GQLSettingsWordListInput { - if (list.banned) { - list.banned = uniqBy(list.banned.filter(Boolean), lowerCase) as string[]; - } - - if (list.suspect) { - list.suspect = uniqBy(list.suspect.filter(Boolean), lowerCase) as string[]; - } - - return list; -} - -export async function update( - mongo: Db, - redis: Redis, - cache: TenantCache, - config: Config, - tenant: Tenant, - input: UpdateTenant -): Promise { - // If the environment variable for disabling live updates is provided, then - // ensure we don't permit changes to the database model. - if ( - config.get("disable_live_updates") && - input.live && - !isUndefined(input.live.enabled) - ) { - delete input.live.enabled; - } - - // If the word list was specified, we should validate it to ensure there isn't - // any empty spaces. - if (input.wordList) { - input.wordList = cleanWordList(input.wordList); - } - - const updatedTenant = await updateTenant(mongo, tenant.id, input); - if (!updatedTenant) { - return null; - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - return updatedTenant; -} - -/** - * isInstalled will return a promise that if true, indicates that a Tenant has - * been installed. - */ -export async function isInstalled(cache: TenantCache, domain?: string) { - const count = await cache.count(); - if (count === 0) { - return false; - } - - if (domain) { - const tenant = await cache.retrieveByDomain(domain); - if (tenant) { - return true; - } - - return false; - } - - return true; -} - -export type InstallTenant = CreateTenantInput; - -export async function install( - mongo: Db, - redis: Redis, - cache: TenantCache, - i18n: I18n, - input: InstallTenant, - now = new Date() -) { - // Ensure that this Tenant isn't being installed onto a domain that already - // exists. - if (await isInstalled(cache, input.domain)) { - throw new TenantInstalledAlreadyError(); - } - - logger.info("installing tenant"); - - // Create the Tenant. - const tenant = await createTenant(mongo, i18n, input, now); - - // Update the tenant cache. - await cache.update(redis, tenant); - - logger.info({ tenantID: tenant.id }, "a tenant has been installed"); - - return tenant; -} - -/** - * canInstall will return a promise that determines if a given install can - * proceed. - */ -export async function canInstall(cache: TenantCache) { - return (await cache.count()) === 0; -} - -/** - * regenerateSSOKey will regenerate the Single Sign-On key for the specified - * Tenant and notify all other Tenant's connected that the Tenant was updated. - */ -export async function regenerateSSOKey( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - now: Date -) { - // Deprecate the old Tenant SSO key if it exists. - if (tenant.auth.integrations.sso.keys.length > 0) { - // Get the old keys that are not deprecated. - const keysToDeprecate = tenant.auth.integrations.sso.keys.filter(key => { - return !key.rotatedAt; - }); - - // Check to see if there are keys to deprecate. - if (keysToDeprecate.length > 0) { - // All the keys will be deprecated a month from now. - // TODO: [CORL-754] (wyattjoh) take input for the deprecation duration later. - const deprecateAt = DateTime.fromJSDate(now) - .plus({ month: 1 }) - .toJSDate(); - - // Deprecate all the keys that are associated on the tenant that haven't - // been done. - for (const key of keysToDeprecate) { - await rotateTenantSSOKey(mongo, tenant.id, key.kid, deprecateAt, now); - } - } - } - - // Create the new Tenant. - const updatedTenant = await createTenantSSOKey(mongo, tenant.id, now); - if (!updatedTenant) { - return null; - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - return updatedTenant; -} - -/** - * discoverOIDCConfiguration will discover the OpenID Connect configuration as - * is required by any OpenID Connect compatible service: - * - * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig - * - * @param issuerString the issuer that should be used as the discovery root. - */ -export async function discoverOIDCConfiguration(issuerString: string) { - // Parse the issuer. - const issuer = new URL(issuerString); - - // Discover the configuration. - return discover(issuer); -} - -interface WebhookEndpointInput { - url: string; - all: boolean; - events: GQLWEBHOOK_EVENT_NAME[]; -} - -export function validateWebhookEndpointInput( - config: Config, - input: WebhookEndpointInput -) { - // Check to see that this URL is valid and has a https:// scheme if in - // production mode. - const url = new URL(input.url); - if (config.get("env") === "production" && url.protocol !== "https:") { - throw new Error(`invalid scheme provided in production: ${url.protocol}`); - } - - // Ensure that either the "all" or "events" is provided but not both. - if (input.all && input.events.length > 0) { - throw new Error("both all events and specific events were requested"); - } -} - -export async function createWebhookEndpoint( - mongo: Db, - redis: Redis, - config: Config, - cache: TenantCache, - tenant: Tenant, - input: CreateTenantWebhookEndpointInput, - now: Date -) { - // Validate the input. - validateWebhookEndpointInput(config, input); - - // Looks good in create this, send it off to be created. - const result = await createTenantWebhookEndpoint( - mongo, - tenant.id, - input, - now - ); - if (!result.tenant) { - throw new Error("could not create the tenant endpoint, tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, result.tenant); - - return { - endpoint: result.endpoint, - settings: result.tenant, - }; -} - -export async function updateWebhookEndpoint( - mongo: Db, - redis: Redis, - config: Config, - cache: TenantCache, - tenant: Tenant, - endpointID: string, - input: UpdateTenantWebhookEndpointInput -) { - // Find the endpoint. - let endpoint = getWebhookEndpoint(tenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - // Extract the input. - const { - url = endpoint.url, - all = endpoint.all, - events = endpoint.events, - } = input; - - // Validate the input. - validateWebhookEndpointInput(config, { - url, - all, - events, - }); - - const updatedTenant = await updateTenantWebhookEndpoint( - mongo, - tenant.id, - endpointID, - input - ); - if (!updatedTenant) { - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - // Find the updated endpoint. - endpoint = getWebhookEndpoint(updatedTenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - return endpoint; -} - -export async function enableWebhookEndpoint( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - endpointID: string -) { - // Find the endpoint. - let endpoint = getWebhookEndpoint(tenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - // Endpoint is already enabled. - if (endpoint.enabled === true) { - return endpoint; - } - - const updatedTenant = await updateTenantWebhookEndpoint( - mongo, - tenant.id, - endpointID, - { enabled: true } - ); - if (!updatedTenant) { - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - // Find the updated endpoint. - endpoint = getWebhookEndpoint(updatedTenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - return endpoint; -} - -export async function disableWebhookEndpoint( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - endpointID: string -) { - // Find the endpoint. - let endpoint = getWebhookEndpoint(tenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - // Endpoint is already disabled. - if (endpoint.enabled === false) { - return endpoint; - } - - const updatedTenant = await updateTenantWebhookEndpoint( - mongo, - tenant.id, - endpointID, - { enabled: false } - ); - if (!updatedTenant) { - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - // Find the updated endpoint. - endpoint = getWebhookEndpoint(updatedTenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - return endpoint; -} - -export async function deleteWebhookEndpoint( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - endpointID: string -) { - // Find the endpoint. - const endpoint = getWebhookEndpoint(tenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - const updatedTenant = await deleteTenantWebhookEndpoint( - mongo, - tenant.id, - endpointID - ); - if (!updatedTenant) { - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - return endpoint; -} - -export async function rotateWebhookEndpointSecret( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - endpointID: string, - inactiveIn: number, - now: Date -) { - // Compute the inactiveAt dates for the current active secrets. - const inactiveAt = DateTime.fromJSDate(now) - .plus({ seconds: inactiveIn }) - .toJSDate(); - - // Rotate the secrets. - const updatedTenant = await rollTenantWebhookEndpointSecret( - mongo, - tenant.id, - endpointID, - inactiveAt, - now - ); - if (!updatedTenant) { - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updatedTenant); - - // Find the updated endpoint. - const endpoint = getWebhookEndpoint(updatedTenant, endpointID); - if (!endpoint) { - throw new Error("referenced endpoint was not found on tenant"); - } - - return endpoint; -} - -export async function enableFeatureFlag( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - flag: GQLFEATURE_FLAG -) { - // If the Tenant already has this flag, don't bother adding it again. - if (tenant.featureFlags && tenant.featureFlags.includes(flag)) { - return tenant.featureFlags; - } - - // Enable the feature flag. - const updated = await enableTenantFeatureFlag(mongo, tenant.id, flag); - if (!updated || !updated.featureFlags) { - // As we just added the feature flag, we would expect that the Tenant would - // always have the feature flags set to some array. - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updated); - - // Return the updated feature flags. - return updated.featureFlags; -} - -export async function disableFeatureFlag( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - flag: GQLFEATURE_FLAG -) { - // If the feature flag doesn't exist on the Tenant (or the Tenant has no - // feature flags), don't bother trying to remove it again. - if (!tenant.featureFlags || !tenant.featureFlags.includes(flag)) { - return tenant.featureFlags || []; - } - - // Remove the feature flag. - const updated = await disableTenantFeatureFlag(mongo, tenant.id, flag); - if (!updated) { - throw new Error("tenant not found"); - } - - // Update the tenant cache. - await cache.update(redis, updated); - - // Return the updated feature flags (or [] if there was no feature flags to - // begin with). - return updated.featureFlags || []; -} - -export async function createAnnouncement( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - input: CreateAnnouncementInput, - now = new Date() -) { - const updated = await createTenantAnnouncement(mongo, tenant.id, input); - if (!updated) { - throw new Error("tenant not found"); - } - await cache.update(redis, updated); - return updated; -} - -export async function deleteAnnouncement( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant -) { - const updated = await deleteTenantAnnouncement(mongo, tenant.id); - if (!updated) { - throw new Error("tenant not found"); - } - await cache.update(redis, updated); - return updated; -} +export * from "./tenant"; +export * from "./sso"; diff --git a/src/core/server/services/tenant/sso.ts b/src/core/server/services/tenant/sso.ts new file mode 100644 index 000000000..9bc9a25a8 --- /dev/null +++ b/src/core/server/services/tenant/sso.ts @@ -0,0 +1,133 @@ +import { Redis } from "ioredis"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { + createTenantSSOKey, + deactivateTenantSSOKey, + deleteLastUsedAtTenantSSOKey, + deleteTenantSSOKey, + Tenant, +} from "coral-server/models/tenant"; + +import TenantCache from "./cache"; + +/** + * regenerateSSOKey will regenerate the Single Sign-On key for the specified + * Tenant and notify all other Tenant's connected that the Tenant was updated. + */ +export async function regenerateSSOKey( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + now: Date +) { + // Regeneration is the same as rotating but with a specific 30 day window. + return rotateSSOKey(mongo, redis, cache, tenant, 30 * 24 * 60 * 60, now); +} + +export async function rotateSSOKey( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + inactiveIn: number, + now: Date +) { + // Deprecate the old Tenant SSO key if it exists. + if (tenant.auth.integrations.sso.keys.length > 0) { + // Get the old keys that are not deprecated. + const keysToDeprecate = tenant.auth.integrations.sso.keys.filter(key => { + return !key.rotatedAt; + }); + + // Check to see if there are keys to deprecate. + if (keysToDeprecate.length > 0) { + const deprecateAt = DateTime.fromJSDate(now) + .plus({ seconds: inactiveIn }) + .toJSDate(); + + // Deprecate all the keys that are associated on the tenant that haven't + // been done. + for (const key of keysToDeprecate) { + await deactivateTenantSSOKey( + mongo, + tenant.id, + key.kid, + deprecateAt, + now + ); + } + } + } + + // Create the new SSOKey. + const updatedTenant = await createTenantSSOKey(mongo, tenant.id, now); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} + +export async function deactivateSSOKey( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + kid: string, + now: Date +) { + const key = tenant.auth.integrations.sso.keys.find(k => k.kid === kid); + if (!key) { + throw new Error("specified kid not found on tenant"); + } + + // Deactivate the sso key now. + const updatedTenant = await deactivateTenantSSOKey( + mongo, + tenant.id, + kid, + now, + now + ); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} + +export async function deleteSSOKey( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + kid: string +) { + const key = tenant.auth.integrations.sso.keys.find(k => k.kid === kid); + if (!key) { + throw new Error("specified kid not found on tenant"); + } + + // Deactivate the sso key now. + const updatedTenant = await deleteTenantSSOKey(mongo, tenant.id, kid); + if (!updatedTenant) { + return null; + } + + // Remove the last used date entry from the Redis hash. + await deleteLastUsedAtTenantSSOKey(redis, tenant.id, kid); + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} diff --git a/src/core/server/services/tenant/tenant.ts b/src/core/server/services/tenant/tenant.ts new file mode 100644 index 000000000..1d806540a --- /dev/null +++ b/src/core/server/services/tenant/tenant.ts @@ -0,0 +1,500 @@ +import { Redis } from "ioredis"; +import { isUndefined, lowerCase, uniqBy } from "lodash"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; +import { URL } from "url"; + +import { discover } from "coral-server/app/middleware/passport/strategies/oidc/discover"; +import { Config } from "coral-server/config"; +import { TenantInstalledAlreadyError } from "coral-server/errors"; +import logger from "coral-server/logger"; +import { + CreateAnnouncementInput, + createTenant, + createTenantAnnouncement, + CreateTenantInput, + createTenantWebhookEndpoint, + CreateTenantWebhookEndpointInput, + deleteTenantAnnouncement, + deleteTenantWebhookEndpoint, + disableTenantFeatureFlag, + enableTenantFeatureFlag, + getWebhookEndpoint, + rollTenantWebhookEndpointSecret, + Tenant, + updateTenant, + updateTenantWebhookEndpoint, + UpdateTenantWebhookEndpointInput, +} from "coral-server/models/tenant"; +import { I18n } from "coral-server/services/i18n"; + +import { + GQLFEATURE_FLAG, + GQLSettingsInput, + GQLSettingsWordListInput, + GQLWEBHOOK_EVENT_NAME, +} from "coral-server/graph/schema/__generated__/types"; + +import TenantCache from "./cache"; + +export type UpdateTenant = GQLSettingsInput; + +function cleanWordList( + list: GQLSettingsWordListInput +): GQLSettingsWordListInput { + if (list.banned) { + list.banned = uniqBy(list.banned.filter(Boolean), lowerCase) as string[]; + } + + if (list.suspect) { + list.suspect = uniqBy(list.suspect.filter(Boolean), lowerCase) as string[]; + } + + return list; +} + +export async function update( + mongo: Db, + redis: Redis, + cache: TenantCache, + config: Config, + tenant: Tenant, + input: UpdateTenant +): Promise { + // If the environment variable for disabling live updates is provided, then + // ensure we don't permit changes to the database model. + if ( + config.get("disable_live_updates") && + input.live && + !isUndefined(input.live.enabled) + ) { + delete input.live.enabled; + } + + // If the word list was specified, we should validate it to ensure there isn't + // any empty spaces. + if (input.wordList) { + input.wordList = cleanWordList(input.wordList); + } + + const updatedTenant = await updateTenant(mongo, tenant.id, input); + if (!updatedTenant) { + return null; + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return updatedTenant; +} + +/** + * isInstalled will return a promise that if true, indicates that a Tenant has + * been installed. + */ +export async function isInstalled(cache: TenantCache, domain?: string) { + const count = await cache.count(); + if (count === 0) { + return false; + } + + if (domain) { + const tenant = await cache.retrieveByDomain(domain); + if (tenant) { + return true; + } + + return false; + } + + return true; +} + +export type InstallTenant = CreateTenantInput; + +export async function install( + mongo: Db, + redis: Redis, + cache: TenantCache, + i18n: I18n, + input: InstallTenant, + now = new Date() +) { + // Ensure that this Tenant isn't being installed onto a domain that already + // exists. + if (await isInstalled(cache, input.domain)) { + throw new TenantInstalledAlreadyError(); + } + + logger.info("installing tenant"); + + // Create the Tenant. + const tenant = await createTenant(mongo, i18n, input, now); + + // Update the tenant cache. + await cache.update(redis, tenant); + + logger.info({ tenantID: tenant.id }, "a tenant has been installed"); + + return tenant; +} + +/** + * canInstall will return a promise that determines if a given install can + * proceed. + */ +export async function canInstall(cache: TenantCache) { + return (await cache.count()) === 0; +} + +/** + * discoverOIDCConfiguration will discover the OpenID Connect configuration as + * is required by any OpenID Connect compatible service: + * + * https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + * + * @param issuerString the issuer that should be used as the discovery root. + */ +export async function discoverOIDCConfiguration(issuerString: string) { + // Parse the issuer. + const issuer = new URL(issuerString); + + // Discover the configuration. + return discover(issuer); +} + +interface WebhookEndpointInput { + url: string; + all: boolean; + events: GQLWEBHOOK_EVENT_NAME[]; +} + +export function validateWebhookEndpointInput( + config: Config, + input: WebhookEndpointInput +) { + // Check to see that this URL is valid and has a https:// scheme if in + // production mode. + const url = new URL(input.url); + if (config.get("env") === "production" && url.protocol !== "https:") { + throw new Error(`invalid scheme provided in production: ${url.protocol}`); + } + + // Ensure that either the "all" or "events" is provided but not both. + if (input.all && input.events.length > 0) { + throw new Error("both all events and specific events were requested"); + } +} + +export async function createWebhookEndpoint( + mongo: Db, + redis: Redis, + config: Config, + cache: TenantCache, + tenant: Tenant, + input: CreateTenantWebhookEndpointInput, + now: Date +) { + // Validate the input. + validateWebhookEndpointInput(config, input); + + // Looks good in create this, send it off to be created. + const result = await createTenantWebhookEndpoint( + mongo, + tenant.id, + input, + now + ); + if (!result.tenant) { + throw new Error("could not create the tenant endpoint, tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, result.tenant); + + return { + endpoint: result.endpoint, + settings: result.tenant, + }; +} + +export async function updateWebhookEndpoint( + mongo: Db, + redis: Redis, + config: Config, + cache: TenantCache, + tenant: Tenant, + endpointID: string, + input: UpdateTenantWebhookEndpointInput +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + // Extract the input. + const { + url = endpoint.url, + all = endpoint.all, + events = endpoint.events, + } = input; + + // Validate the input. + validateWebhookEndpointInput(config, { + url, + all, + events, + }); + + const updatedTenant = await updateTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID, + input + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function enableWebhookEndpoint( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + // Endpoint is already enabled. + if (endpoint.enabled === true) { + return endpoint; + } + + const updatedTenant = await updateTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID, + { enabled: true } + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function disableWebhookEndpoint( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string +) { + // Find the endpoint. + let endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + // Endpoint is already disabled. + if (endpoint.enabled === false) { + return endpoint; + } + + const updatedTenant = await updateTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID, + { enabled: false } + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function deleteWebhookEndpoint( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string +) { + // Find the endpoint. + const endpoint = getWebhookEndpoint(tenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + const updatedTenant = await deleteTenantWebhookEndpoint( + mongo, + tenant.id, + endpointID + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + return endpoint; +} + +export async function rotateWebhookEndpointSecret( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + endpointID: string, + inactiveIn: number, + now: Date +) { + // Compute the inactiveAt dates for the current active secrets. + const inactiveAt = DateTime.fromJSDate(now) + .plus({ seconds: inactiveIn }) + .toJSDate(); + + // Rotate the secrets. + const updatedTenant = await rollTenantWebhookEndpointSecret( + mongo, + tenant.id, + endpointID, + inactiveAt, + now + ); + if (!updatedTenant) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updatedTenant); + + // Find the updated endpoint. + const endpoint = getWebhookEndpoint(updatedTenant, endpointID); + if (!endpoint) { + throw new Error("referenced endpoint was not found on tenant"); + } + + return endpoint; +} + +export async function enableFeatureFlag( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + flag: GQLFEATURE_FLAG +) { + // If the Tenant already has this flag, don't bother adding it again. + if (tenant.featureFlags && tenant.featureFlags.includes(flag)) { + return tenant.featureFlags; + } + + // Enable the feature flag. + const updated = await enableTenantFeatureFlag(mongo, tenant.id, flag); + if (!updated || !updated.featureFlags) { + // As we just added the feature flag, we would expect that the Tenant would + // always have the feature flags set to some array. + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updated); + + // Return the updated feature flags. + return updated.featureFlags; +} + +export async function disableFeatureFlag( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + flag: GQLFEATURE_FLAG +) { + // If the feature flag doesn't exist on the Tenant (or the Tenant has no + // feature flags), don't bother trying to remove it again. + if (!tenant.featureFlags || !tenant.featureFlags.includes(flag)) { + return tenant.featureFlags || []; + } + + // Remove the feature flag. + const updated = await disableTenantFeatureFlag(mongo, tenant.id, flag); + if (!updated) { + throw new Error("tenant not found"); + } + + // Update the tenant cache. + await cache.update(redis, updated); + + // Return the updated feature flags (or [] if there was no feature flags to + // begin with). + return updated.featureFlags || []; +} + +export async function createAnnouncement( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant, + input: CreateAnnouncementInput, + now = new Date() +) { + const updated = await createTenantAnnouncement(mongo, tenant.id, input, now); + if (!updated) { + throw new Error("tenant not found"); + } + await cache.update(redis, updated); + return updated; +} + +export async function deleteAnnouncement( + mongo: Db, + redis: Redis, + cache: TenantCache, + tenant: Tenant +) { + const updated = await deleteTenantAnnouncement(mongo, tenant.id); + if (!updated) { + throw new Error("tenant not found"); + } + await cache.update(redis, updated); + return updated; +} diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index dd4c99679..36a53526d 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -427,6 +427,51 @@ configure-auth-sso-regenerateAt = KEY GENERATED AT: configure-auth-sso-regenerateHonoredWarning = When regenerating a key, tokens signed with the previous key will be honored for 30 days. +configure-auth-sso-description = + To enable integration with your existing authentication system, + you will need to create a JWT Token to connect. You can learn + more about creating a JWT Token with this introduction. See our + documentation for additional information on single sign on. + +configure-auth-sso-rotate-keys = Keys +configure-auth-sso-rotate-keyID = Key ID +configure-auth-sso-rotate-secret = Secret +configure-auth-sso-rotate-copySecret = + .aria-label = Copy Secret + +configure-auth-sso-rotate-date = + { DATETIME($date, year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric") } +configure-auth-sso-rotate-activeSince = Active Since +configure-auth-sso-rotate-inactiveAt = Inactive At +configure-auth-sso-rotate-inactiveSince = Inactive Since + +configure-auth-sso-rotate-status = Status +configure-auth-sso-rotate-statusActive = Active +configure-auth-sso-rotate-statusExpiring = Expiring +configure-auth-sso-rotate-statusExpired = Expired +configure-auth-sso-rotate-statusUnknown = Unknown + +configure-auth-sso-rotate-expiringTooltip = + An SSO key is expiring when it is scheduled for rotation. +configure-auth-sso-rotate-expiringTooltip-toggleButton = + .aria-label = Toggle expiring tooltip visibility +configure-auth-sso-rotate-expiredTooltip = + An SSO key is expired when it has been rotated out of use. +configure-auth-sso-rotate-expiredTooltip-toggleButton = + Toggle expired tooltip visibility + +configure-auth-sso-rotate-rotate = Rotate +configure-auth-sso-rotate-deactivateNow = Deactivate Now +configure-auth-sso-rotate-delete = Delete + +configure-auth-sso-rotate-now = Now +configure-auth-sso-rotate-10seconds = 10 seconds from now +configure-auth-sso-rotate-1day = 1 day from now +configure-auth-sso-rotate-1week = 1 week from now +configure-auth-sso-rotate-30days = 30 days from now +configure-auth-sso-rotate-dropdown-description = + .description = A dropdown to rotate the SSO key + configure-auth-local-loginWith = Login with email authentication configure-auth-local-useLoginOn = Use email authentication login on