mirror of
https://github.com/wassname/talk.git
synced 2026-08-12 12:30:39 +08:00
Merge branch 'master' into release/6
This commit is contained in:
@@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat";
|
|||||||
import React, { FunctionComponent } from "react";
|
import React, { FunctionComponent } from "react";
|
||||||
import { graphql } from "react-relay";
|
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 Header from "../../Header";
|
||||||
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
|
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
|
||||||
import RegistrationField from "./RegistrationField";
|
import RegistrationField from "./RegistrationField";
|
||||||
import SSOKeyFieldContainer from "./SSOKeyFieldContainer";
|
import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery";
|
||||||
import TargetFilterField from "./TargetFilterField";
|
import TargetFilterField from "./TargetFilterField";
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
@@ -28,10 +29,9 @@ graphql`
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
sso: PropTypesOf<typeof SSOKeyFieldContainer>["sso"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
|
const SSOConfig: FunctionComponent<Props> = ({ disabled }) => (
|
||||||
<ConfigBoxWithToggleField
|
<ConfigBoxWithToggleField
|
||||||
title={
|
title={
|
||||||
<Localized id="configure-auth-sso-loginWith">
|
<Localized id="configure-auth-sso-loginWith">
|
||||||
@@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
|
|||||||
>
|
>
|
||||||
{disabledInside => (
|
{disabledInside => (
|
||||||
<>
|
<>
|
||||||
<SSOKeyFieldContainer sso={sso} disabled={disabledInside} />
|
<Localized
|
||||||
|
id="configure-auth-sso-description"
|
||||||
|
IntroLink={
|
||||||
|
<ExternalLink href="https://jwt.io/introduction/"></ExternalLink>
|
||||||
|
}
|
||||||
|
DocLink={
|
||||||
|
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/sso/"></ExternalLink>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldDescription>
|
||||||
|
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.
|
||||||
|
</FormFieldDescription>
|
||||||
|
</Localized>
|
||||||
|
<SSOKeyRotationQuery disabled={disabledInside}></SSOKeyRotationQuery>
|
||||||
<TargetFilterField
|
<TargetFilterField
|
||||||
label={
|
label={
|
||||||
<Localized id="configure-auth-sso-useLoginOn">
|
<Localized id="configure-auth-sso-useLoginOn">
|
||||||
|
|||||||
@@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent<Props> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
auth,
|
auth,
|
||||||
}) => {
|
}) => {
|
||||||
return <SSOConfig disabled={disabled} sso={auth.integrations.sso} />;
|
return <SSOConfig disabled={disabled} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enhanced = withFragmentContainer<Props>({
|
const enhanced = withFragmentContainer<Props>({
|
||||||
auth: graphql`
|
auth: graphql`
|
||||||
fragment SSOConfigContainer_auth on Auth {
|
fragment SSOConfigContainer_auth on Auth {
|
||||||
integrations {
|
...SSOConfig_formValues
|
||||||
sso {
|
|
||||||
...SSOKeyFieldContainer_sso
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})(SSOConfigContainer);
|
})(SSOConfigContainer);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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<Props> = ({
|
|
||||||
generatedKey,
|
|
||||||
keyGeneratedAt,
|
|
||||||
disabled,
|
|
||||||
onRegenerate,
|
|
||||||
}) => (
|
|
||||||
<FormField className={styles.root}>
|
|
||||||
<Localized id="configure-auth-sso-key">
|
|
||||||
<Label htmlFor="configure-auth-sso-key">Key</Label>
|
|
||||||
</Localized>
|
|
||||||
<PasswordField
|
|
||||||
id="configure-auth-sso-key"
|
|
||||||
name="key"
|
|
||||||
value={generatedKey}
|
|
||||||
readOnly
|
|
||||||
// TODO: (wyattjoh) figure out how to add translations to these props
|
|
||||||
hidePasswordTitle="Show SSO Key"
|
|
||||||
showPasswordTitle="Hide SSO Key"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
{keyGeneratedAt && (
|
|
||||||
<Localized
|
|
||||||
id="configure-auth-sso-regenerateAt"
|
|
||||||
$date={new Date(keyGeneratedAt)}
|
|
||||||
>
|
|
||||||
<HelperText className={styles.keyGenerated}>
|
|
||||||
KEY GENERATED AT: {keyGeneratedAt}
|
|
||||||
</HelperText>
|
|
||||||
</Localized>
|
|
||||||
)}
|
|
||||||
<div className={styles.warningSection}>
|
|
||||||
<Flex direction="row" itemGutter="half">
|
|
||||||
<Icon className={styles.warnIcon}>warning</Icon>
|
|
||||||
<Localized id="configure-auth-sso-regenerateHonoredWarning">
|
|
||||||
<HelperText>
|
|
||||||
When regenerating a key, tokens signed with the previous key will be
|
|
||||||
honored for 30 days.
|
|
||||||
</HelperText>
|
|
||||||
</Localized>
|
|
||||||
</Flex>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Localized id="configure-auth-sso-regenerate">
|
|
||||||
<Button
|
|
||||||
id="configure-auth-sso-regenerate"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onRegenerate}
|
|
||||||
className={styles.regenerateButton}
|
|
||||||
>
|
|
||||||
Regenerate
|
|
||||||
</Button>
|
|
||||||
</Localized>
|
|
||||||
</FormField>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default SSOKeyField;
|
|
||||||
@@ -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<typeof RegenerateSSOKeyMutation>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
awaitingResponse: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SSOKeyFieldContainer extends React.Component<Props, State> {
|
|
||||||
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 (
|
|
||||||
<SSOKeyField
|
|
||||||
disabled={disabled || this.state.awaitingResponse}
|
|
||||||
generatedKey={this.props.sso.key || undefined}
|
|
||||||
keyGeneratedAt={this.props.sso.keyGeneratedAt || undefined}
|
|
||||||
onRegenerate={this.handleRegenerate}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enhanced = withMutation(RegenerateSSOKeyMutation)(
|
|
||||||
withFragmentContainer<Props>({
|
|
||||||
sso: graphql`
|
|
||||||
fragment SSOKeyFieldContainer_sso on SSOAuthIntegration {
|
|
||||||
key
|
|
||||||
keyGeneratedAt
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
})(SSOKeyFieldContainer)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default enhanced;
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<Props> = ({ status, dates }) => {
|
||||||
|
switch (status) {
|
||||||
|
case SSOKeyStatus.ACTIVE:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<Localized id="configure-auth-sso-rotate-activeSince">
|
||||||
|
<Label>Active Since</Label>
|
||||||
|
</Localized>
|
||||||
|
</div>
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-date"
|
||||||
|
$date={new Date(dates.createdAt)}
|
||||||
|
>
|
||||||
|
<span className={styles.date}>{dates.createdAt}</span>
|
||||||
|
</Localized>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case SSOKeyStatus.EXPIRING:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<Localized id="configure-auth-sso-rotate-inactiveAt">
|
||||||
|
<Label>Inactive At</Label>
|
||||||
|
</Localized>
|
||||||
|
</div>
|
||||||
|
<Flex
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
className={styles.date}
|
||||||
|
>
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-date"
|
||||||
|
$date={
|
||||||
|
dates.inactiveAt
|
||||||
|
? new Date(dates.inactiveAt)
|
||||||
|
: new Date(dates.createdAt)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dates.inactiveAt}
|
||||||
|
</Localized>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case SSOKeyStatus.EXPIRED:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<Localized id="configure-auth-sso-rotate-inactiveSince">
|
||||||
|
<Label>Inactive Since</Label>
|
||||||
|
</Localized>
|
||||||
|
</div>
|
||||||
|
<Flex
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
className={styles.date}
|
||||||
|
>
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-date"
|
||||||
|
$date={
|
||||||
|
dates.inactiveAt
|
||||||
|
? new Date(dates.inactiveAt)
|
||||||
|
: new Date(dates.createdAt)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dates.inactiveAt}
|
||||||
|
</Localized>
|
||||||
|
</Flex>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateField;
|
||||||
+52
@@ -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<MutationTypes>) => {
|
||||||
|
return commitMutationPromiseNormalized<MutationTypes>(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;
|
||||||
+52
@@ -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<MutationTypes>) => {
|
||||||
|
return commitMutationPromiseNormalized<MutationTypes>(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;
|
||||||
+52
@@ -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<MutationTypes>) => {
|
||||||
|
return commitMutationPromiseNormalized<MutationTypes>(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;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
.rotate {
|
||||||
|
margin-right: var(--v2-spacing-1)
|
||||||
|
}
|
||||||
+72
@@ -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<Props> = ({
|
||||||
|
onRotateKey,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-dropdown-description"
|
||||||
|
attrs={{ description: true }}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
id="sso-key-rotate"
|
||||||
|
placement="bottom-start"
|
||||||
|
description="A dropdown to rotate the SSO key"
|
||||||
|
body={({ toggleVisibility }) => (
|
||||||
|
<ClickOutside onClickOutside={toggleVisibility}>
|
||||||
|
<Dropdown>
|
||||||
|
{Object.keys(RotateOptions).map((opt: string) => (
|
||||||
|
<DropdownButton
|
||||||
|
key={opt}
|
||||||
|
onClick={() => {
|
||||||
|
onRotateKey(opt);
|
||||||
|
toggleVisibility();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<RotateOption value={opt}></RotateOption>
|
||||||
|
</DropdownButton>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</ClickOutside>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ toggleVisibility, ref, visible }) => (
|
||||||
|
<Button
|
||||||
|
onClick={toggleVisibility}
|
||||||
|
ref={ref}
|
||||||
|
color="regular"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Localized id="configure-auth-sso-rotate-rotate">
|
||||||
|
<span className={styles.rotate}>Rotate</span>
|
||||||
|
</Localized>
|
||||||
|
<Icon>arrow_drop_down</Icon>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RotationDropDown;
|
||||||
+46
@@ -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<Props> = ({ value }) => {
|
||||||
|
switch (value) {
|
||||||
|
case RotateOptions.NOW: {
|
||||||
|
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
|
||||||
|
}
|
||||||
|
case RotateOptions.IN1DAY: {
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-1day">
|
||||||
|
1 day from now
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case RotateOptions.IN1WEEK: {
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-1week">
|
||||||
|
1 week from now
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case RotateOptions.IN30DAYS: {
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-30days">
|
||||||
|
30 days from now
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RotationOption;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 <RotationDropDown onRotateKey={onRotateKey} disabled={disabled} />;
|
||||||
|
case SSOKeyStatus.EXPIRING:
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-deactivateNow">
|
||||||
|
<Button color="alert" onClick={onDeactivateKey} disabled={disabled}>
|
||||||
|
Deactivate Now
|
||||||
|
</Button>
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
case SSOKeyStatus.EXPIRED:
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-delete">
|
||||||
|
<Button color="alert" onClick={onDelete} disabled={disabled}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSOKeyCard: FunctionComponent<Props> = ({
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<HorizontalGutter>
|
||||||
|
<Flex alignItems="center" justifyContent="space-between">
|
||||||
|
<div className={styles.keySection}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<Localized id="configure-auth-sso-rotate-keyID">
|
||||||
|
<Label>Key ID</Label>
|
||||||
|
</Localized>
|
||||||
|
</div>
|
||||||
|
<TextField value={id} readOnly fullWidth data-testid="SSO-Key-ID" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.secretSection}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<Localized id="configure-auth-sso-rotate-secret">
|
||||||
|
<Label>Secret</Label>
|
||||||
|
</Localized>
|
||||||
|
</div>
|
||||||
|
<Flex alignItems="center" justifyContent="flex-start">
|
||||||
|
<PasswordField
|
||||||
|
id="configure-auth-sso-rotate-secretField"
|
||||||
|
name="key"
|
||||||
|
value={secret}
|
||||||
|
readOnly
|
||||||
|
// TODO: (nick-funk) figure out how to add translations to these props
|
||||||
|
hidePasswordTitle="Show Secret"
|
||||||
|
showPasswordTitle="Hide Secret"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<CopyToClipboard text={secret}>
|
||||||
|
<Button color="mono" variant="flat">
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-copySecret"
|
||||||
|
attrs={{ "aria-label": true }}
|
||||||
|
>
|
||||||
|
<Icon size="md" aria-label="Copy Secret">
|
||||||
|
content_copy
|
||||||
|
</Icon>
|
||||||
|
</Localized>
|
||||||
|
</Button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
<Flex alignItems="flex-end" justifyContent="space-between">
|
||||||
|
<Flex alignItems="center" justifyContent="flex-start">
|
||||||
|
<div className={styles.statusSection}>
|
||||||
|
<div className={styles.label}>
|
||||||
|
<Localized id="configure-auth-sso-rotate-status">
|
||||||
|
<Label>Status</Label>
|
||||||
|
</Localized>
|
||||||
|
</div>
|
||||||
|
<StatusField status={status}></StatusField>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DateField status={status} dates={dates} />
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
{createActionButton(
|
||||||
|
status,
|
||||||
|
onRotate,
|
||||||
|
onDeactivate,
|
||||||
|
onDelete,
|
||||||
|
disabled
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</HorizontalGutter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SSOKeyCard;
|
||||||
+130
@@ -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<Props> = ({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Localized id="configure-auth-sso-rotate-keys">
|
||||||
|
<Label htmlFor="configure-auth-sso-rotate-keys">Keys</Label>
|
||||||
|
</Localized>
|
||||||
|
{sortedKeys.map(key => (
|
||||||
|
<SSOKeyCard
|
||||||
|
key={key.kid}
|
||||||
|
id={key.kid}
|
||||||
|
secret={key.secret}
|
||||||
|
status={getStatus(key)}
|
||||||
|
dates={key}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enhanced = withFragmentContainer<Props>({
|
||||||
|
settings: graphql`
|
||||||
|
fragment SSOKeyRotationContainer_settings on Settings {
|
||||||
|
auth {
|
||||||
|
integrations {
|
||||||
|
sso {
|
||||||
|
enabled
|
||||||
|
keys {
|
||||||
|
kid
|
||||||
|
secret
|
||||||
|
createdAt
|
||||||
|
lastUsedAt
|
||||||
|
rotatedAt
|
||||||
|
inactiveAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})(SSOKeyRotationContainer);
|
||||||
|
|
||||||
|
export default enhanced;
|
||||||
+54
@@ -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<Props> = ({ disabled }) => {
|
||||||
|
return (
|
||||||
|
<QueryRenderer<QueryTypes>
|
||||||
|
query={graphql`
|
||||||
|
query SSOKeyRotationQuery {
|
||||||
|
settings {
|
||||||
|
...SSOKeyRotationContainer_settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
variables={{}}
|
||||||
|
cacheConfig={{ force: true }}
|
||||||
|
render={({ error, props }: QueryRenderData<QueryTypes>) => {
|
||||||
|
if (error) {
|
||||||
|
return <CallOut>{error.message}</CallOut>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.settings) {
|
||||||
|
return <Spinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SSOKeyRotationContainer
|
||||||
|
settings={props.settings}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SSOKeyRotationQuery;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<Props> = ({ status }) => {
|
||||||
|
switch (status) {
|
||||||
|
case SSOKeyStatus.ACTIVE:
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-statusActive">
|
||||||
|
<span
|
||||||
|
className={cn(styles.status, styles.active)}
|
||||||
|
data-testid="SSO-Key-Status"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
case SSOKeyStatus.EXPIRING:
|
||||||
|
return (
|
||||||
|
<Flex alignItems="center" justifyContent="center">
|
||||||
|
<Flex
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
className={cn(styles.status, styles.expiring)}
|
||||||
|
>
|
||||||
|
<Icon className={styles.icon}>alarm</Icon>
|
||||||
|
<Localized id="configure-auth-sso-rotate-statusExpiring">
|
||||||
|
<span data-testid="SSO-Key-Status">Expiring</span>
|
||||||
|
</Localized>
|
||||||
|
</Flex>
|
||||||
|
<Tooltip
|
||||||
|
id="configure-auth-sso-rotate-expiringTooltip"
|
||||||
|
title=""
|
||||||
|
body={
|
||||||
|
<Localized id="configure-auth-sso-rotate-expiringTooltip">
|
||||||
|
<span>
|
||||||
|
An SSO key is expiring when it is scheduled for rotation.
|
||||||
|
</span>
|
||||||
|
</Localized>
|
||||||
|
}
|
||||||
|
button={({ toggleVisibility, ref, visible }) => (
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-expiringTooltip-toggleButton"
|
||||||
|
attrs={{ "aria-label": true }}
|
||||||
|
>
|
||||||
|
<TooltipButton
|
||||||
|
active
|
||||||
|
aria-label="Toggle expiring tooltip visibility"
|
||||||
|
toggleVisibility={toggleVisibility}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
</Localized>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
case SSOKeyStatus.EXPIRED:
|
||||||
|
return (
|
||||||
|
<Flex alignItems="center" justifyContent="center">
|
||||||
|
<Localized id="configure-auth-sso-rotate-statusExpired">
|
||||||
|
<span
|
||||||
|
className={cn(styles.status, styles.expired)}
|
||||||
|
data-testid="SSO-Key-Status"
|
||||||
|
>
|
||||||
|
Expired
|
||||||
|
</span>
|
||||||
|
</Localized>
|
||||||
|
<Tooltip
|
||||||
|
id="configure-auth-sso-rotate-expiredTooltip"
|
||||||
|
title=""
|
||||||
|
body={
|
||||||
|
<Localized id="configure-auth-sso-rotate-expiredTooltip">
|
||||||
|
<span>
|
||||||
|
An SSO key is expired when it has been rotated out of use.
|
||||||
|
</span>
|
||||||
|
</Localized>
|
||||||
|
}
|
||||||
|
button={({ toggleVisibility, ref, visible }) => (
|
||||||
|
<Localized
|
||||||
|
id="configure-auth-sso-rotate-expiredTooltip-toggleButton"
|
||||||
|
attrs={{ "aria-label": true }}
|
||||||
|
>
|
||||||
|
<TooltipButton
|
||||||
|
active
|
||||||
|
aria-label="Toggle expired tooltip visibility"
|
||||||
|
toggleVisibility={toggleVisibility}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
</Localized>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Localized id="configure-auth-sso-rotate-statusUnknown">
|
||||||
|
<span data-testid="SSO-Key-Status">Unknown</span>
|
||||||
|
</Localized>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusField;
|
||||||
@@ -1242,86 +1242,233 @@ integration to register for a new account.
|
|||||||
<div
|
<div
|
||||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
|
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
|
||||||
>
|
>
|
||||||
<div
|
<p
|
||||||
className="Box-root HorizontalGutter-root FormField-root SSOKeyField-root HorizontalGutter-spacing-2"
|
className="FormFieldDescription-root"
|
||||||
>
|
>
|
||||||
<label
|
To enable integration with your existing authentication system,
|
||||||
className="Label-root"
|
you will need to create a JWT Token to connect. You can learn
|
||||||
htmlFor="configure-auth-sso-key"
|
more about creating a JWT Token with
|
||||||
|
<a
|
||||||
|
className="ExternalLink-root"
|
||||||
|
href="https://jwt.io/introduction/"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
Key
|
this introduction
|
||||||
</label>
|
</a>
|
||||||
|
. See our
|
||||||
|
|
||||||
|
<a
|
||||||
|
className="ExternalLink-root"
|
||||||
|
href="https://docs.coralproject.net/coral/v5/integrating/sso/"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
documentation
|
||||||
|
</a>
|
||||||
|
for additional information on single sign on.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className="Label-root"
|
||||||
|
htmlFor="configure-auth-sso-rotate-keys"
|
||||||
|
>
|
||||||
|
Keys
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="Card-root"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="PasswordField-fullWidth PasswordField-root"
|
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="PasswordField-wrapper"
|
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignCenter"
|
||||||
>
|
>
|
||||||
<input
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
|
||||||
data-testid="password-field"
|
|
||||||
id="configure-auth-sso-key"
|
|
||||||
name="key"
|
|
||||||
placeholder=""
|
|
||||||
readOnly={true}
|
|
||||||
spellCheck={false}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className="PasswordField-icon"
|
className="SSOKeyCard-keySection"
|
||||||
onClick={[Function]}
|
|
||||||
onKeyUp={[Function]}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
title="Hide SSO Key"
|
|
||||||
>
|
>
|
||||||
<i
|
<div
|
||||||
aria-hidden="true"
|
className="SSOKeyCard-label"
|
||||||
className="Icon-root Icon-sm"
|
|
||||||
>
|
>
|
||||||
visibility
|
<label
|
||||||
</i>
|
className="Label-root"
|
||||||
|
>
|
||||||
|
Key ID
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="TextField-root TextField-fullWidth"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="TextField-input TextField-colorRegular"
|
||||||
|
data-testid="SSO-Key-ID"
|
||||||
|
placeholder=""
|
||||||
|
readOnly={true}
|
||||||
|
type="text"
|
||||||
|
value="kid-01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="SSOKeyCard-secretSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="SSOKeyCard-label"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="Label-root"
|
||||||
|
>
|
||||||
|
Secret
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="PasswordField-fullWidth PasswordField-root"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="PasswordField-wrapper"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||||
|
data-testid="password-field"
|
||||||
|
id="configure-auth-sso-rotate-secretField"
|
||||||
|
name="key"
|
||||||
|
placeholder=""
|
||||||
|
readOnly={true}
|
||||||
|
spellCheck={false}
|
||||||
|
type="password"
|
||||||
|
value="secret"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="PasswordField-icon"
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyUp={[Function]}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="Hide Secret"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
aria-hidden="true"
|
||||||
|
className="Icon-root Icon-sm"
|
||||||
|
>
|
||||||
|
visibility
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantFlat Button-uppercase"
|
||||||
|
data-color="mono"
|
||||||
|
data-variant="flat"
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onFocus={[Function]}
|
||||||
|
onMouseOut={[Function]}
|
||||||
|
onMouseOver={[Function]}
|
||||||
|
onTouchEnd={[Function]}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
aria-hidden="true"
|
||||||
|
aria-label="Copy Secret"
|
||||||
|
className="Icon-root Icon-md"
|
||||||
|
>
|
||||||
|
content_copy
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignFlexEnd"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="SSOKeyCard-statusSection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="SSOKeyCard-label"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="Label-root"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="StatusField-status StatusField-active"
|
||||||
|
data-testid="SSO-Key-Status"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="DateField-label"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="Label-root"
|
||||||
|
>
|
||||||
|
Active Since
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="DateField-date"
|
||||||
|
>
|
||||||
|
1/1/2020, 1:00 AM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="Popover-root"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled"
|
||||||
|
data-color="regular"
|
||||||
|
data-variant="regular"
|
||||||
|
disabled={true}
|
||||||
|
onBlur={[Function]}
|
||||||
|
onClick={[Function]}
|
||||||
|
onFocus={[Function]}
|
||||||
|
onMouseOut={[Function]}
|
||||||
|
onMouseOver={[Function]}
|
||||||
|
onTouchEnd={[Function]}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="RotationDropdown-rotate"
|
||||||
|
>
|
||||||
|
Rotate
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
aria-hidden="true"
|
||||||
|
className="Icon-root Icon-sm"
|
||||||
|
>
|
||||||
|
arrow_drop_down
|
||||||
|
</i>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
aria-hidden={true}
|
||||||
|
aria-labelledby="sso-key-rotate-ariainfo"
|
||||||
|
id="sso-key-rotate"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="AriaInfo-root"
|
||||||
|
id="sso-key-rotate-ariainfo"
|
||||||
|
>
|
||||||
|
A dropdown to rotate the SSO key
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="SSOKeyField-warningSection"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow gutter"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
aria-hidden="true"
|
|
||||||
className="Icon-root Icon-sm SSOKeyField-warnIcon"
|
|
||||||
>
|
|
||||||
warning
|
|
||||||
</i>
|
|
||||||
<p
|
|
||||||
className="HelperText-root"
|
|
||||||
>
|
|
||||||
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled SSOKeyField-regenerateButton"
|
|
||||||
data-color="regular"
|
|
||||||
data-variant="regular"
|
|
||||||
disabled={true}
|
|
||||||
id="configure-auth-sso-regenerate"
|
|
||||||
onBlur={[Function]}
|
|
||||||
onClick={[Function]}
|
|
||||||
onFocus={[Function]}
|
|
||||||
onMouseOut={[Function]}
|
|
||||||
onMouseOver={[Function]}
|
|
||||||
onTouchEnd={[Function]}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Regenerate
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
|
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CreateTestRendererParams,
|
CreateTestRendererParams,
|
||||||
findParentWithType,
|
findParentWithType,
|
||||||
replaceHistoryLocation,
|
replaceHistoryLocation,
|
||||||
|
toJSON,
|
||||||
wait,
|
wait,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
within,
|
within,
|
||||||
@@ -57,11 +58,11 @@ it("renders configure auth", async () => {
|
|||||||
expect(within(configureContainer).toJSON()).toMatchSnapshot();
|
expect(within(configureContainer).toJSON()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("regenerate sso key", async () => {
|
it("rotate sso key", async () => {
|
||||||
const { testRenderer } = await createTestRenderer({
|
const { testRenderer } = await createTestRenderer({
|
||||||
resolvers: createResolversStub<GQLResolver>({
|
resolvers: createResolversStub<GQLResolver>({
|
||||||
Mutation: {
|
Mutation: {
|
||||||
regenerateSSOKey: () => {
|
rotateSSOKey: () => {
|
||||||
return {
|
return {
|
||||||
settings: pureMerge<typeof settingsWithEmptyAuth>(
|
settings: pureMerge<typeof settingsWithEmptyAuth>(
|
||||||
settingsWithEmptyAuth,
|
settingsWithEmptyAuth,
|
||||||
@@ -69,8 +70,22 @@ it("regenerate sso key", async () => {
|
|||||||
auth: {
|
auth: {
|
||||||
integrations: {
|
integrations: {
|
||||||
sso: {
|
sso: {
|
||||||
key: "==GENERATED_KEY==",
|
enabled: true,
|
||||||
keyGeneratedAt: "2018-11-12T23:26:06.239Z",
|
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(() => {
|
act(() => {
|
||||||
within(container)
|
within(container)
|
||||||
.getByText("Regenerate", { selector: "button" })
|
.getByText("Rotate", { selector: "button" })
|
||||||
.props.onClick();
|
.props.onClick();
|
||||||
});
|
});
|
||||||
|
|
||||||
await wait(() =>
|
const rotateNow = await waitForElement(() => {
|
||||||
expect(within(container).getByLabelText("Key").props.value).toBe(
|
return within(container).getByText("Now", { selector: "button" });
|
||||||
"==GENERATED_KEY=="
|
});
|
||||||
)
|
|
||||||
);
|
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 () => {
|
it("prevents admin lock out", async () => {
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ export const settings = createFixture<GQLSettings>({
|
|||||||
admin: true,
|
admin: true,
|
||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
kid: "kid-01",
|
||||||
|
secret: "secret",
|
||||||
|
createdAt: "2020-01-01T01:00:00.000Z",
|
||||||
|
lastUsedAt: undefined,
|
||||||
|
rotatedAt: undefined,
|
||||||
|
inactiveAt: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
key: "",
|
key: "",
|
||||||
keyGeneratedAt: null,
|
keyGeneratedAt: null,
|
||||||
},
|
},
|
||||||
@@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
|
|||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
key: "",
|
key: "",
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
kid: "kid-01",
|
||||||
|
secret: "secret",
|
||||||
|
createdAt: "2020-01-01T01:00:00.000Z",
|
||||||
|
lastUsedAt: undefined,
|
||||||
|
rotatedAt: undefined,
|
||||||
|
inactiveAt: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
keyGeneratedAt: null,
|
keyGeneratedAt: null,
|
||||||
},
|
},
|
||||||
google: {
|
google: {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const AddExpertMutation = createMutation(
|
|||||||
(environment: Environment, input: MutationInput<AddExpertMutation>) =>
|
(environment: Environment, input: MutationInput<AddExpertMutation>) =>
|
||||||
commitMutationPromiseNormalized<AddExpertMutation>(environment, {
|
commitMutationPromiseNormalized<AddExpertMutation>(environment, {
|
||||||
mutation: graphql`
|
mutation: graphql`
|
||||||
mutation AddExpertMutation($input: AddExpertInput!) {
|
mutation AddExpertMutation($input: AddStoryExpertInput!) {
|
||||||
addStoryExpert(input: $input) {
|
addStoryExpert(input: $input) {
|
||||||
story {
|
story {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const RemoveExpertMutation = createMutation(
|
|||||||
(environment: Environment, input: MutationInput<RemoveExpertMutation>) =>
|
(environment: Environment, input: MutationInput<RemoveExpertMutation>) =>
|
||||||
commitMutationPromiseNormalized<RemoveExpertMutation>(environment, {
|
commitMutationPromiseNormalized<RemoveExpertMutation>(environment, {
|
||||||
mutation: graphql`
|
mutation: graphql`
|
||||||
mutation RemoveExpertMutation($input: RemoveExpertInput!) {
|
mutation RemoveExpertMutation($input: RemoveStoryExpertInput!) {
|
||||||
removeStoryExpert(input: $input) {
|
removeStoryExpert(input: $input) {
|
||||||
story {
|
story {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
|
import { Redis } from "ioredis";
|
||||||
import Joi from "joi";
|
import Joi from "joi";
|
||||||
import { isNil } from "lodash";
|
import { isNil, throttle } from "lodash";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { Db } from "mongodb";
|
import { Db } from "mongodb";
|
||||||
|
|
||||||
import { validate } from "coral-server/app/request/body";
|
import { validate } from "coral-server/app/request/body";
|
||||||
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
|
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
|
||||||
|
import logger from "coral-server/logger";
|
||||||
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
|
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
|
||||||
import { Tenant } from "coral-server/models/tenant";
|
import {
|
||||||
|
Tenant,
|
||||||
|
updateLastUsedAtTenantSSOKey,
|
||||||
|
} from "coral-server/models/tenant";
|
||||||
import {
|
import {
|
||||||
retrieveUserWithProfile,
|
retrieveUserWithProfile,
|
||||||
SSOProfile,
|
SSOProfile,
|
||||||
@@ -163,6 +168,22 @@ export async function findOrCreateSSOUser(
|
|||||||
return user;
|
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 {
|
export interface SSOVerifierOptions {
|
||||||
mongo: Db;
|
mongo: Db;
|
||||||
redis: AugmentedRedis;
|
redis: AugmentedRedis;
|
||||||
@@ -285,9 +306,13 @@ export class SSOVerifier implements Verifier<SSOToken> {
|
|||||||
continue;
|
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
|
// 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) {
|
// if (!kid) {
|
||||||
// logger.warn(
|
// logger.warn(
|
||||||
// { tenantID: tenant.id, kid: config.kid },
|
// { tenantID: tenant.id, kid: config.kid },
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import DataLoader from "dataloader";
|
import DataLoader from "dataloader";
|
||||||
|
|
||||||
import GraphContext from "coral-server/graph/context";
|
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 { discoverOIDCConfiguration } from "coral-server/services/tenant";
|
||||||
|
|
||||||
|
import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types";
|
||||||
|
|
||||||
export default (ctx: GraphContext) => ({
|
export default (ctx: GraphContext) => ({
|
||||||
discoverOIDCConfiguration: new DataLoader<
|
discoverOIDCConfiguration: new DataLoader<
|
||||||
string,
|
string,
|
||||||
@@ -17,4 +19,7 @@ export default (ctx: GraphContext) => ({
|
|||||||
cache: !ctx.disableCaching,
|
cache: !ctx.disableCaching,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
retrieveSSOKeyLastUsedAt: new DataLoader((kids: string[]) =>
|
||||||
|
retrieveLastUsedAtTenantSSOKeys(ctx.redis, ctx.tenant.id, kids)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { Tenant } from "coral-server/models/tenant";
|
|||||||
import {
|
import {
|
||||||
createAnnouncement,
|
createAnnouncement,
|
||||||
createWebhookEndpoint,
|
createWebhookEndpoint,
|
||||||
|
deactivateSSOKey,
|
||||||
deleteAnnouncement,
|
deleteAnnouncement,
|
||||||
|
deleteSSOKey,
|
||||||
deleteWebhookEndpoint,
|
deleteWebhookEndpoint,
|
||||||
disableFeatureFlag,
|
disableFeatureFlag,
|
||||||
disableWebhookEndpoint,
|
disableWebhookEndpoint,
|
||||||
enableFeatureFlag,
|
enableFeatureFlag,
|
||||||
enableWebhookEndpoint,
|
enableWebhookEndpoint,
|
||||||
regenerateSSOKey,
|
regenerateSSOKey,
|
||||||
|
rotateSSOKey,
|
||||||
rotateWebhookEndpointSecret,
|
rotateWebhookEndpointSecret,
|
||||||
update,
|
update,
|
||||||
updateWebhookEndpoint,
|
updateWebhookEndpoint,
|
||||||
@@ -18,10 +21,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
GQLCreateAnnouncementInput,
|
GQLCreateAnnouncementInput,
|
||||||
GQLCreateWebhookEndpointInput,
|
GQLCreateWebhookEndpointInput,
|
||||||
|
GQLDeactivateSSOKeyInput,
|
||||||
|
GQLDeleteSSOKeyInput,
|
||||||
GQLDeleteWebhookEndpointInput,
|
GQLDeleteWebhookEndpointInput,
|
||||||
GQLDisableWebhookEndpointInput,
|
GQLDisableWebhookEndpointInput,
|
||||||
GQLEnableWebhookEndpointInput,
|
GQLEnableWebhookEndpointInput,
|
||||||
GQLFEATURE_FLAG,
|
GQLFEATURE_FLAG,
|
||||||
|
GQLRotateSSOKeyInput,
|
||||||
GQLRotateWebhookEndpointSecretInput,
|
GQLRotateWebhookEndpointSecretInput,
|
||||||
GQLUpdateSettingsInput,
|
GQLUpdateSettingsInput,
|
||||||
GQLUpdateWebhookEndpointInput,
|
GQLUpdateWebhookEndpointInput,
|
||||||
@@ -43,6 +49,12 @@ export const Settings = ({
|
|||||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||||
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
|
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: (flag: GQLFEATURE_FLAG) =>
|
||||||
enableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
|
enableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
|
||||||
disableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
|
disableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import {
|
|||||||
import { scrape } from "coral-server/services/stories/scraper";
|
import { scrape } from "coral-server/services/stories/scraper";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GQLAddExpertInput,
|
GQLAddStoryExpertInput,
|
||||||
GQLCloseStoryInput,
|
GQLCloseStoryInput,
|
||||||
GQLCreateStoryInput,
|
GQLCreateStoryInput,
|
||||||
GQLMergeStoriesInput,
|
GQLMergeStoriesInput,
|
||||||
GQLOpenStoryInput,
|
GQLOpenStoryInput,
|
||||||
GQLRemoveExpertInput,
|
GQLRemoveStoryExpertInput,
|
||||||
GQLRemoveStoryInput,
|
GQLRemoveStoryInput,
|
||||||
GQLScrapeStoryInput,
|
GQLScrapeStoryInput,
|
||||||
GQLUpdateStoryInput,
|
GQLUpdateStoryInput,
|
||||||
@@ -78,8 +78,8 @@ export const Stories = (ctx: GraphContext) => ({
|
|||||||
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
|
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
|
||||||
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
|
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
|
||||||
updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode),
|
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),
|
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),
|
removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { GQLMutationTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
import { GQLMutationTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||||
|
|
||||||
// TODO: (wyattjoh) add rate limiting to these edges
|
|
||||||
|
|
||||||
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||||
editComment: async (source, { input }, ctx) => ({
|
editComment: async (source, { input }, ctx) => ({
|
||||||
comment: await ctx.mutators.Comments.edit(input),
|
comment: await ctx.mutators.Comments.edit(input),
|
||||||
@@ -81,6 +79,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
|||||||
settings: await ctx.mutators.Settings.regenerateSSOKey(),
|
settings: await ctx.mutators.Settings.regenerateSSOKey(),
|
||||||
clientMutationId: input.clientMutationId,
|
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) => ({
|
createStory: async (source, { input }, ctx) => ({
|
||||||
story: await ctx.mutators.Stories.create(input),
|
story: await ctx.mutators.Stories.create(input),
|
||||||
clientMutationId: input.clientMutationId,
|
clientMutationId: input.clientMutationId,
|
||||||
|
|||||||
@@ -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<settings.Secret> = {
|
||||||
|
lastUsedAt: async ({ kid }, args, ctx) =>
|
||||||
|
ctx.loaders.Auth.retrieveSSOKeyLastUsedAt.load(kid),
|
||||||
|
};
|
||||||
@@ -37,6 +37,7 @@ import { Profile } from "./Profile";
|
|||||||
import { Query } from "./Query";
|
import { Query } from "./Query";
|
||||||
import { RecentCommentHistory } from "./RecentCommentHistory";
|
import { RecentCommentHistory } from "./RecentCommentHistory";
|
||||||
import { RejectCommentPayload } from "./RejectCommentPayload";
|
import { RejectCommentPayload } from "./RejectCommentPayload";
|
||||||
|
import { Secret } from "./Secret";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { SlackConfiguration } from "./SlackConfiguration";
|
import { SlackConfiguration } from "./SlackConfiguration";
|
||||||
import { SSOAuthIntegration } from "./SSOAuthIntegration";
|
import { SSOAuthIntegration } from "./SSOAuthIntegration";
|
||||||
@@ -89,6 +90,7 @@ const Resolvers: GQLResolver = {
|
|||||||
RecentCommentHistory,
|
RecentCommentHistory,
|
||||||
RejectCommentPayload,
|
RejectCommentPayload,
|
||||||
SSOAuthIntegration,
|
SSOAuthIntegration,
|
||||||
|
Secret,
|
||||||
Story,
|
Story,
|
||||||
StorySettings,
|
StorySettings,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
|||||||
@@ -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
|
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
|
## Custom Scalar Types
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -483,6 +490,40 @@ type LocalAuthIntegration {
|
|||||||
## SSOAuthIntegration
|
## 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
|
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
|
of a tenant, where they can sign a SSO payload with it to provide to the
|
||||||
@@ -504,15 +545,24 @@ type SSOAuthIntegration {
|
|||||||
"""
|
"""
|
||||||
targetFilter: AuthenticationTargetFilter!
|
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 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 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
|
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 {
|
type WebhookEndpoint {
|
||||||
"""
|
"""
|
||||||
id is the unique identifier for this specific endpoint.
|
id is the unique identifier for this specific endpoint.
|
||||||
@@ -1342,6 +1377,28 @@ type Announcement {
|
|||||||
content: String!
|
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.
|
Settings stores the global settings for a given Tenant.
|
||||||
"""
|
"""
|
||||||
@@ -5855,6 +5912,92 @@ type EnableFeatureFlagPayload {
|
|||||||
flags: [FEATURE_FLAG!]!
|
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
|
# disableFeatureFlag
|
||||||
#########################
|
#########################
|
||||||
@@ -5884,10 +6027,10 @@ type DisableFeatureFlagPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#########################
|
#########################
|
||||||
# Add / Remove Expert
|
# addStoryExpert
|
||||||
#########################
|
#########################
|
||||||
|
|
||||||
input AddExpertInput {
|
input AddStoryExpertInput {
|
||||||
"""
|
"""
|
||||||
clientMutationId is required for Relay support.
|
clientMutationId is required for Relay support.
|
||||||
"""
|
"""
|
||||||
@@ -5904,7 +6047,7 @@ input AddExpertInput {
|
|||||||
userID: ID!
|
userID: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
type AddExpertPayload {
|
type AddStoryExpertPayload {
|
||||||
"""
|
"""
|
||||||
clientMutationId is required for Relay support.
|
clientMutationId is required for Relay support.
|
||||||
"""
|
"""
|
||||||
@@ -5916,7 +6059,11 @@ type AddExpertPayload {
|
|||||||
story: Story!
|
story: Story!
|
||||||
}
|
}
|
||||||
|
|
||||||
input RemoveExpertInput {
|
#########################
|
||||||
|
# removeStoryExpert
|
||||||
|
#########################
|
||||||
|
|
||||||
|
input RemoveStoryExpertInput {
|
||||||
"""
|
"""
|
||||||
clientMutationId is required for Relay support.
|
clientMutationId is required for Relay support.
|
||||||
"""
|
"""
|
||||||
@@ -5933,7 +6080,7 @@ input RemoveExpertInput {
|
|||||||
userID: ID!
|
userID: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoveExpertPayload {
|
type RemoveStoryExpertPayload {
|
||||||
"""
|
"""
|
||||||
clientMutationId is required for Relay support.
|
clientMutationId is required for Relay support.
|
||||||
"""
|
"""
|
||||||
@@ -5945,6 +6092,10 @@ type RemoveExpertPayload {
|
|||||||
story: Story!
|
story: Story!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#########################
|
||||||
|
## updateStoryMode
|
||||||
|
#########################
|
||||||
|
|
||||||
input UpdateStoryModeInput {
|
input UpdateStoryModeInput {
|
||||||
"""
|
"""
|
||||||
storyID is the story id to enable Q&A on.
|
storyID is the story id to enable Q&A on.
|
||||||
@@ -5963,15 +6114,110 @@ input UpdateStoryModeInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateStoryModePayload {
|
type UpdateStoryModePayload {
|
||||||
|
"""
|
||||||
|
clientMutationId is required for Relay support.
|
||||||
|
"""
|
||||||
|
clientMutationId: String!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
story is the resultant story that Q&A was enabled on.
|
story is the resultant story that Q&A was enabled on.
|
||||||
"""
|
"""
|
||||||
story: Story!
|
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 is required for Relay support.
|
||||||
"""
|
"""
|
||||||
clientMutationId: String!
|
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!
|
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
|
||||||
@auth(roles: [ADMIN])
|
@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
|
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 adds an expert to a story.
|
||||||
"""
|
"""
|
||||||
addStoryExpert(input: AddExpertInput!): AddExpertPayload!
|
addStoryExpert(input: AddStoryExpertInput!): AddStoryExpertPayload!
|
||||||
@auth(roles: [ADMIN, MODERATOR])
|
@auth(roles: [ADMIN, MODERATOR])
|
||||||
|
|
||||||
"""
|
"""
|
||||||
removeStoryExpert removes an expert from a story.
|
removeStoryExpert removes an expert from a story.
|
||||||
"""
|
"""
|
||||||
removeStoryExpert(input: RemoveExpertInput!): RemoveExpertPayload!
|
removeStoryExpert(input: RemoveStoryExpertInput!): RemoveStoryExpertPayload!
|
||||||
@auth(roles: [ADMIN, MODERATOR])
|
@auth(roles: [ADMIN, MODERATOR])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6608,80 +6873,3 @@ type Subscription {
|
|||||||
commentFeatured(storyID: ID!): CommentFeaturedPayload!
|
commentFeatured(storyID: ID!): CommentFeaturedPayload!
|
||||||
@auth(roles: [MODERATOR, ADMIN])
|
@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!
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Redis } from "ioredis";
|
||||||
import { isEmpty } from "lodash";
|
import { isEmpty } from "lodash";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { Db } from "mongodb";
|
import { Db } from "mongodb";
|
||||||
@@ -10,7 +11,6 @@ import { DeepPartial, Omit, Sub } from "coral-common/types";
|
|||||||
import { isBeforeDate } from "coral-common/utils";
|
import { isBeforeDate } from "coral-common/utils";
|
||||||
import { dotize } from "coral-common/utils/dotize";
|
import { dotize } from "coral-common/utils/dotize";
|
||||||
import logger from "coral-server/logger";
|
import logger from "coral-server/logger";
|
||||||
import { Secret, Settings } from "coral-server/models/settings";
|
|
||||||
import { I18n } from "coral-server/services/i18n";
|
import { I18n } from "coral-server/services/i18n";
|
||||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
GQLWEBHOOK_EVENT_NAME,
|
GQLWEBHOOK_EVENT_NAME,
|
||||||
} from "coral-server/graph/schema/__generated__/types";
|
} from "coral-server/graph/schema/__generated__/types";
|
||||||
|
|
||||||
|
import { Secret, Settings } from "../settings";
|
||||||
import {
|
import {
|
||||||
generateSecret,
|
generateSecret,
|
||||||
getDefaultReactionConfiguration,
|
getDefaultReactionConfiguration,
|
||||||
@@ -388,7 +389,7 @@ export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
|
|||||||
return result.value || null;
|
return result.value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rotateTenantSSOKey(
|
export async function deactivateTenantSSOKey(
|
||||||
mongo: Db,
|
mongo: Db,
|
||||||
id: string,
|
id: string,
|
||||||
kid: string,
|
kid: string,
|
||||||
@@ -463,6 +464,24 @@ export async function disableTenantFeatureFlag(
|
|||||||
|
|
||||||
return result.value || null;
|
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 {
|
export interface CreateAnnouncementInput {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -767,3 +786,66 @@ export async function deleteTenantWebhookEndpoint(
|
|||||||
|
|
||||||
return result.value;
|
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<string | null> = await redis.hmget(
|
||||||
|
lastUsedAtTenantSSOKey(id),
|
||||||
|
...kids
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.map(lastUsedAt => {
|
||||||
|
if (!lastUsedAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(lastUsedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,548 +1,2 @@
|
|||||||
import { Redis } from "ioredis";
|
export * from "./tenant";
|
||||||
import { isUndefined, lowerCase, uniqBy } from "lodash";
|
export * from "./sso";
|
||||||
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<Tenant | null> {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Tenant | null> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -427,6 +427,51 @@ configure-auth-sso-regenerateAt = KEY GENERATED AT:
|
|||||||
configure-auth-sso-regenerateHonoredWarning =
|
configure-auth-sso-regenerateHonoredWarning =
|
||||||
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
|
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 <IntroLink>this introduction</IntroLink>. See our
|
||||||
|
<DocLink>documentation</DocLink> 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-loginWith = Login with email authentication
|
||||||
configure-auth-local-useLoginOn = Use email authentication login on
|
configure-auth-local-useLoginOn = Use email authentication login on
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user