Merge branch 'master' into release/6

This commit is contained in:
Wyatt Johnson
2020-03-23 12:20:18 -06:00
37 changed files with 2366 additions and 921 deletions
@@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { PropTypesOf } from "coral-framework/types";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { FormFieldDescription } from "coral-ui/components/v2";
import Header from "../../Header";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RegistrationField from "./RegistrationField";
import SSOKeyFieldContainer from "./SSOKeyFieldContainer";
import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery";
import TargetFilterField from "./TargetFilterField";
// eslint-disable-next-line no-unused-expressions
@@ -28,10 +29,9 @@ graphql`
interface Props {
disabled?: boolean;
sso: PropTypesOf<typeof SSOKeyFieldContainer>["sso"];
}
const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
const SSOConfig: FunctionComponent<Props> = ({ disabled }) => (
<ConfigBoxWithToggleField
title={
<Localized id="configure-auth-sso-loginWith">
@@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
>
{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
label={
<Localized id="configure-auth-sso-useLoginOn">
@@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent<Props> = ({
disabled,
auth,
}) => {
return <SSOConfig disabled={disabled} sso={auth.integrations.sso} />;
return <SSOConfig disabled={disabled} />;
};
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment SSOConfigContainer_auth on Auth {
integrations {
sso {
...SSOKeyFieldContainer_sso
}
}
...SSOConfig_formValues
}
`,
})(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;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,3 @@
.rotate {
margin-right: var(--v2-spacing-1)
}
@@ -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;
@@ -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;
@@ -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;
@@ -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
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<div
className="Box-root HorizontalGutter-root FormField-root SSOKeyField-root HorizontalGutter-spacing-2"
<p
className="FormFieldDescription-root"
>
<label
className="Label-root"
htmlFor="configure-auth-sso-key"
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
<a
className="ExternalLink-root"
href="https://jwt.io/introduction/"
rel="noopener noreferrer"
target="_blank"
>
Key
</label>
this introduction
</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
className="PasswordField-fullWidth PasswordField-root"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<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
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide SSO Key"
className="SSOKeyCard-keySection"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
<div
className="SSOKeyCard-label"
>
visibility
</i>
<label
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
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
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
@@ -9,6 +9,7 @@ import {
CreateTestRendererParams,
findParentWithType,
replaceHistoryLocation,
toJSON,
wait,
waitForElement,
within,
@@ -57,11 +58,11 @@ it("renders configure auth", async () => {
expect(within(configureContainer).toJSON()).toMatchSnapshot();
});
it("regenerate sso key", async () => {
it("rotate sso key", async () => {
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Mutation: {
regenerateSSOKey: () => {
rotateSSOKey: () => {
return {
settings: pureMerge<typeof settingsWithEmptyAuth>(
settingsWithEmptyAuth,
@@ -69,8 +70,22 @@ it("regenerate sso key", async () => {
auth: {
integrations: {
sso: {
key: "==GENERATED_KEY==",
keyGeneratedAt: "2018-11-12T23:26:06.239Z",
enabled: true,
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2015-01-01T00:00:00.000Z",
lastUsedAt: "2016-01-01T01:45:00.000Z",
rotatedAt: "2016-01-01T01:45:00.000Z",
inactiveAt: "2016-01-01T01:45:00.000Z",
},
{
kid: "kid-02",
secret: "new-secret",
createdAt: "2019-01-01T01:45:00.000Z",
},
],
},
},
},
@@ -90,15 +105,40 @@ it("regenerate sso key", async () => {
act(() => {
within(container)
.getByText("Regenerate", { selector: "button" })
.getByText("Rotate", { selector: "button" })
.props.onClick();
});
await wait(() =>
expect(within(container).getByLabelText("Key").props.value).toBe(
"==GENERATED_KEY=="
)
);
const rotateNow = await waitForElement(() => {
return within(container).getByText("Now", { selector: "button" });
});
act(() => {
rotateNow.props.onClick();
});
await wait(() => {
// Check that we have two SSO Keys that match
// our expected key IDs
const keyIDs = within(container).getAllByTestID("SSO-Key-ID");
const hasOldKey = keyIDs.some(k => k.props.value === "kid-01");
const hasNewKey = keyIDs.some(k => k.props.value === "kid-02");
expect(hasNewKey).toBe(true);
expect(hasOldKey).toBe(true);
const statuses = within(container).getAllByTestID("SSO-Key-Status");
expect(statuses.length).toBe(2);
const firstStatus: any = toJSON(statuses[0]);
const firstStatusIsActive = firstStatus.children.some(
(s: string) => s === "Active"
);
expect(firstStatusIsActive).toBe(true);
const secondStatus: any = toJSON(statuses[1]);
const secondStatusIsActive = secondStatus.children.some(
(s: string) => s === "Active"
);
expect(secondStatusIsActive).toBe(false);
});
});
it("prevents admin lock out", async () => {
+20
View File
@@ -114,6 +114,16 @@ export const settings = createFixture<GQLSettings>({
admin: true,
stream: true,
},
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2020-01-01T01:00:00.000Z",
lastUsedAt: undefined,
rotatedAt: undefined,
inactiveAt: undefined,
},
],
key: "",
keyGeneratedAt: null,
},
@@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
stream: true,
},
key: "",
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2020-01-01T01:00:00.000Z",
lastUsedAt: undefined,
rotatedAt: undefined,
inactiveAt: undefined,
},
],
keyGeneratedAt: null,
},
google: {
@@ -16,7 +16,7 @@ const AddExpertMutation = createMutation(
(environment: Environment, input: MutationInput<AddExpertMutation>) =>
commitMutationPromiseNormalized<AddExpertMutation>(environment, {
mutation: graphql`
mutation AddExpertMutation($input: AddExpertInput!) {
mutation AddExpertMutation($input: AddStoryExpertInput!) {
addStoryExpert(input: $input) {
story {
id
@@ -16,7 +16,7 @@ const RemoveExpertMutation = createMutation(
(environment: Environment, input: MutationInput<RemoveExpertMutation>) =>
commitMutationPromiseNormalized<RemoveExpertMutation>(environment, {
mutation: graphql`
mutation RemoveExpertMutation($input: RemoveExpertInput!) {
mutation RemoveExpertMutation($input: RemoveStoryExpertInput!) {
removeStoryExpert(input: $input) {
story {
id
@@ -1,12 +1,17 @@
import { Redis } from "ioredis";
import Joi from "joi";
import { isNil } from "lodash";
import { isNil, throttle } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { validate } from "coral-server/app/request/body";
import { IntegrationDisabled, TokenInvalidError } from "coral-server/errors";
import logger from "coral-server/logger";
import { Secret, SSOAuthIntegration } from "coral-server/models/settings";
import { Tenant } from "coral-server/models/tenant";
import {
Tenant,
updateLastUsedAtTenantSSOKey,
} from "coral-server/models/tenant";
import {
retrieveUserWithProfile,
SSOProfile,
@@ -163,6 +168,22 @@ export async function findOrCreateSSOUser(
return user;
}
const updateLastUsedAtKID = throttle(
async (redis: Redis, tenantID: string, kid: string, now: Date) => {
try {
await updateLastUsedAtTenantSSOKey(redis, tenantID, kid, now);
logger.trace({ tenantID, kid }, "updated last used tenant sso key");
} catch (err) {
logger.error(
{ err, tenantID, kid },
"could not update the last used tenant sso key"
);
}
},
// Only let this update the last used time stamp every minute.
60 * 1000
);
export interface SSOVerifierOptions {
mongo: Db;
redis: AugmentedRedis;
@@ -285,9 +306,13 @@ export class SSOVerifier implements Verifier<SSOToken> {
continue;
}
// The verification did not throw an error, which means the verification
// succeeded! Mark the key as used last now and break out. We should do
// this in the nextTick because it's not important to have it recorded at
// the same time.
updateLastUsedAtKID(this.redis, tenant.id, key.kid, now);
// TODO: [CORL-754] (wyattjoh) reintroduce when we amend the front-end to display the kid
// // The verification did not throw an error, which means the verification
// // succeeded! Break out now.
// if (!kid) {
// logger.warn(
// { tenantID: tenant.id, kid: config.kid },
+6 -1
View File
@@ -1,9 +1,11 @@
import DataLoader from "dataloader";
import GraphContext from "coral-server/graph/context";
import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types";
import { retrieveLastUsedAtTenantSSOKeys } from "coral-server/models/tenant";
import { discoverOIDCConfiguration } from "coral-server/services/tenant";
import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types";
export default (ctx: GraphContext) => ({
discoverOIDCConfiguration: new DataLoader<
string,
@@ -17,4 +19,7 @@ export default (ctx: GraphContext) => ({
cache: !ctx.disableCaching,
}
),
retrieveSSOKeyLastUsedAt: new DataLoader((kids: string[]) =>
retrieveLastUsedAtTenantSSOKeys(ctx.redis, ctx.tenant.id, kids)
),
});
@@ -3,13 +3,16 @@ import { Tenant } from "coral-server/models/tenant";
import {
createAnnouncement,
createWebhookEndpoint,
deactivateSSOKey,
deleteAnnouncement,
deleteSSOKey,
deleteWebhookEndpoint,
disableFeatureFlag,
disableWebhookEndpoint,
enableFeatureFlag,
enableWebhookEndpoint,
regenerateSSOKey,
rotateSSOKey,
rotateWebhookEndpointSecret,
update,
updateWebhookEndpoint,
@@ -18,10 +21,13 @@ import {
import {
GQLCreateAnnouncementInput,
GQLCreateWebhookEndpointInput,
GQLDeactivateSSOKeyInput,
GQLDeleteSSOKeyInput,
GQLDeleteWebhookEndpointInput,
GQLDisableWebhookEndpointInput,
GQLEnableWebhookEndpointInput,
GQLFEATURE_FLAG,
GQLRotateSSOKeyInput,
GQLRotateWebhookEndpointSecretInput,
GQLUpdateSettingsInput,
GQLUpdateWebhookEndpointInput,
@@ -43,6 +49,12 @@ export const Settings = ({
update(mongo, redis, tenantCache, config, tenant, input.settings),
regenerateSSOKey: (): Promise<Tenant | null> =>
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
rotateSSOKey: ({ inactiveIn }: GQLRotateSSOKeyInput) =>
rotateSSOKey(mongo, redis, tenantCache, tenant, inactiveIn, now),
deactivateSSOKey: ({ kid }: GQLDeactivateSSOKeyInput) =>
deactivateSSOKey(mongo, redis, tenantCache, tenant, kid, now),
deleteSSOKey: ({ kid }: GQLDeleteSSOKeyInput) =>
deleteSSOKey(mongo, redis, tenantCache, tenant, kid),
enableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
enableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
disableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
+4 -4
View File
@@ -19,12 +19,12 @@ import {
import { scrape } from "coral-server/services/stories/scraper";
import {
GQLAddExpertInput,
GQLAddStoryExpertInput,
GQLCloseStoryInput,
GQLCreateStoryInput,
GQLMergeStoriesInput,
GQLOpenStoryInput,
GQLRemoveExpertInput,
GQLRemoveStoryExpertInput,
GQLRemoveStoryInput,
GQLScrapeStoryInput,
GQLUpdateStoryInput,
@@ -78,8 +78,8 @@ export const Stories = (ctx: GraphContext) => ({
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode),
addStoryExpert: async (input: GQLAddExpertInput) =>
addStoryExpert: async (input: GQLAddStoryExpertInput) =>
addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
removeStoryExpert: async (input: GQLRemoveExpertInput) =>
removeStoryExpert: async (input: GQLRemoveStoryExpertInput) =>
removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
});
+12 -2
View File
@@ -1,7 +1,5 @@
import { GQLMutationTypeResolver } from "coral-server/graph/schema/__generated__/types";
// TODO: (wyattjoh) add rate limiting to these edges
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comments.edit(input),
@@ -81,6 +79,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
settings: await ctx.mutators.Settings.regenerateSSOKey(),
clientMutationId: input.clientMutationId,
}),
rotateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.rotateSSOKey(input),
clientMutationId: input.clientMutationId,
}),
deactivateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.deactivateSSOKey(input),
clientMutationId: input.clientMutationId,
}),
deleteSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.deleteSSOKey(input),
clientMutationId: input.clientMutationId,
}),
createStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Stories.create(input),
clientMutationId: input.clientMutationId,
@@ -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),
};
+2
View File
@@ -37,6 +37,7 @@ import { Profile } from "./Profile";
import { Query } from "./Query";
import { RecentCommentHistory } from "./RecentCommentHistory";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Secret } from "./Secret";
import { Settings } from "./Settings";
import { SlackConfiguration } from "./SlackConfiguration";
import { SSOAuthIntegration } from "./SSOAuthIntegration";
@@ -89,6 +90,7 @@ const Resolvers: GQLResolver = {
RecentCommentHistory,
RejectCommentPayload,
SSOAuthIntegration,
Secret,
Story,
StorySettings,
Subscription,
+289 -101
View File
@@ -65,6 +65,13 @@ rate enforces a rate limit on requests made by the user.
"""
directive @rate(max: Int = 1, seconds: Int!, key: String) on FIELD_DEFINITION
"""
deprecated indicates that a field should not be used in the future.
"""
directive @deprecated(
reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE
################################################################################
## Custom Scalar Types
################################################################################
@@ -483,6 +490,40 @@ type LocalAuthIntegration {
## SSOAuthIntegration
##########################
type Secret {
"""
kid is the identifier for the key used when verifying tokens issued by the
provider.
"""
kid: String!
"""
secret is the actual underlying secret used to verify the tokens with.
"""
secret: String!
"""
createdAt is the date that the key was created at.
"""
createdAt: Time!
"""
lastUsedAt is the time that the
"""
lastUsedAt: Time
"""
rotatedAt is the time that the token was rotated out.
"""
rotatedAt: Time
"""
inactiveAt is the date that the token can no longer be used to validate
tokens.
"""
inactiveAt: Time
}
"""
SSOAuthIntegration is an AuthIntegration that provides a secret to the admins
of a tenant, where they can sign a SSO payload with it to provide to the
@@ -504,15 +545,24 @@ type SSOAuthIntegration {
"""
targetFilter: AuthenticationTargetFilter!
"""
keys are the different SSOKey's used by this Tenant.
"""
keys: [Secret!]! @auth(roles: [ADMIN])
"""
key is the secret that is used to sign tokens.
"""
key: String @auth(roles: [ADMIN])
key: String
@auth(roles: [ADMIN])
@deprecated(reason: "field is deprecated in favour of `keys`")
"""
keyGeneratedAt is the Time that the key was effective from.
"""
keyGeneratedAt: Time @auth(roles: [ADMIN])
keyGeneratedAt: Time
@auth(roles: [ADMIN])
@deprecated(reason: "field is deprecated in favour of `keys`")
}
##########################
@@ -1234,21 +1284,6 @@ enum WEBHOOK_EVENT_NAME {
STORY_CREATED
}
"""
TODO: merge with SSOKey with PR #2732
"""
type Secret {
"""
secret is the actual underlying secret used to verify the tokens with.
"""
secret: String!
"""
createdAt is the date that the key was created at.
"""
createdAt: Time!
}
type WebhookEndpoint {
"""
id is the unique identifier for this specific endpoint.
@@ -1342,6 +1377,28 @@ type Announcement {
content: String!
}
type Site {
"""
id is the identifier of the Site.
"""
id: ID!
"""
name is the name of the Site.
"""
name: String!
"""
allowedOrigins are the allowed origins for embeds.
"""
allowedOrigins: [String!]!
"""
createdAt is when the site was created.
"""
createdAt: Time!
}
"""
Settings stores the global settings for a given Tenant.
"""
@@ -5855,6 +5912,92 @@ type EnableFeatureFlagPayload {
flags: [FEATURE_FLAG!]!
}
#########################
## rotateSSOKey
#########################
input RotateSSOKeyInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
inactiveIn is the number of seconds that the current active SSOKey should be
kept active (allow signed tokens signed with this secret) before rejecting
them.
"""
inactiveIn: Int!
}
type RotateSSOKeyPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
"""
settings: Settings
}
#########################
## deactivateSSOKey
#########################
input DeactivateSSOKeyInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
kid is the ID of the SSOKey being deactivated.
"""
kid: ID!
}
type DeactivateSSOKeyPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
"""
settings: Settings
}
#########################
## deleteSSOKey
#########################
input DeleteSSOKeyInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
kid is the ID of the SSOKey being deleted.
"""
kid: ID!
}
type DeleteSSOKeyPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
"""
settings: Settings
}
#########################
# disableFeatureFlag
#########################
@@ -5884,10 +6027,10 @@ type DisableFeatureFlagPayload {
}
#########################
# Add / Remove Expert
# addStoryExpert
#########################
input AddExpertInput {
input AddStoryExpertInput {
"""
clientMutationId is required for Relay support.
"""
@@ -5904,7 +6047,7 @@ input AddExpertInput {
userID: ID!
}
type AddExpertPayload {
type AddStoryExpertPayload {
"""
clientMutationId is required for Relay support.
"""
@@ -5916,7 +6059,11 @@ type AddExpertPayload {
story: Story!
}
input RemoveExpertInput {
#########################
# removeStoryExpert
#########################
input RemoveStoryExpertInput {
"""
clientMutationId is required for Relay support.
"""
@@ -5933,7 +6080,7 @@ input RemoveExpertInput {
userID: ID!
}
type RemoveExpertPayload {
type RemoveStoryExpertPayload {
"""
clientMutationId is required for Relay support.
"""
@@ -5945,6 +6092,10 @@ type RemoveExpertPayload {
story: Story!
}
#########################
## updateStoryMode
#########################
input UpdateStoryModeInput {
"""
storyID is the story id to enable Q&A on.
@@ -5963,15 +6114,110 @@ input UpdateStoryModeInput {
}
type UpdateStoryModePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
story is the resultant story that Q&A was enabled on.
"""
story: Story!
}
##################
## createSite
##################
input CreateSite {
"""
name is the name of the Site.
"""
name: String!
"""
allowedOrigins are the allowed origins for embeds.
"""
allowedOrigins: [String!]!
}
input CreateSiteInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
site is the input for the Site to create.
"""
site: CreateSite!
}
type CreateSitePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
site is the Site that was newly created.
"""
site: Site!
}
##################
## updateSite
##################
input UpdateSite {
"""
name is the name of the Site.
"""
name: String
"""
url is the Site URL, seen in email communications.
"""
url: String
"""
contactEmail is the contact email for the Site, seen in email communications.
"""
contactEmail: String
"""
allowedOrigins are the allowed origins for embeds.
"""
allowedOrigins: [String!]
}
input UpdateSiteInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the Site to update.
"""
id: ID!
"""
site is the updates for the Site.
"""
site: UpdateSite!
}
type UpdateSitePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
site is the newly updated Site.
"""
site: Site!
}
##################
@@ -6012,6 +6258,25 @@ type Mutation {
"""
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
@auth(roles: [ADMIN])
@deprecated(reason: "deprecated in favour of `rotateSSOKey`")
"""
rotateSSOKey can be used to rotate a given active SSOKey.
"""
rotateSSOKey(input: RotateSSOKeyInput!): RotateSSOKeyPayload!
@auth(roles: [ADMIN])
"""
deactivateSSOKey will deactivate a given deactivated SSOKey.
"""
deactivateSSOKey(input: DeactivateSSOKeyInput!): DeactivateSSOKeyPayload!
@auth(roles: [ADMIN])
"""
deleteSSOKey will delete a given inactive SSOKey.
"""
deleteSSOKey(input: DeleteSSOKeyInput!): DeleteSSOKeyPayload!
@auth(roles: [ADMIN])
"""
createCommentReaction will create a Reaction authored by the current logged in
@@ -6416,13 +6681,13 @@ type Mutation {
"""
addStoryExpert adds an expert to a story.
"""
addStoryExpert(input: AddExpertInput!): AddExpertPayload!
addStoryExpert(input: AddStoryExpertInput!): AddStoryExpertPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeStoryExpert removes an expert from a story.
"""
removeStoryExpert(input: RemoveExpertInput!): RemoveExpertPayload!
removeStoryExpert(input: RemoveStoryExpertInput!): RemoveStoryExpertPayload!
@auth(roles: [ADMIN, MODERATOR])
}
@@ -6608,80 +6873,3 @@ type Subscription {
commentFeatured(storyID: ID!): CommentFeaturedPayload!
@auth(roles: [MODERATOR, ADMIN])
}
type Site {
"""
id is the identifier of the Site.
"""
id: ID!
"""
name is the name of the Site.
"""
name: String!
"""
allowedOrigins are the allowed origins for embeds.
"""
allowedOrigins: [String!]!
"""
createdAt is when the site was created.
"""
createdAt: Time!
}
input CreateSite {
"""
name is the name of the Site.
"""
name: String!
"""
allowedOrigins are the allowed origins for embeds.
"""
allowedOrigins: [String!]!
}
input UpdateSite {
"""
name is the name of the Site.
"""
name: String
"""
url is the Site URL, seen in email communications.
"""
url: String
"""
contactEmail is the contact email for the Site, seen in email communications.
"""
contactEmail: String
"""
allowedOrigins are the allowed origins for embeds.
"""
allowedOrigins: [String!]
}
input CreateSiteInput {
clientMutationId: String!
site: CreateSite!
}
type CreateSitePayload {
clientMutationId: String!
site: Site!
}
input UpdateSiteInput {
clientMutationId: String!
site: UpdateSite!
id: ID!
}
type UpdateSitePayload {
clientMutationId: String!
site: Site!
}
+84 -2
View File
@@ -1,3 +1,4 @@
import { Redis } from "ioredis";
import { isEmpty } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
@@ -10,7 +11,6 @@ import { DeepPartial, Omit, Sub } from "coral-common/types";
import { isBeforeDate } from "coral-common/utils";
import { dotize } from "coral-common/utils/dotize";
import logger from "coral-server/logger";
import { Secret, Settings } from "coral-server/models/settings";
import { I18n } from "coral-server/services/i18n";
import { tenants as collection } from "coral-server/services/mongodb/collections";
@@ -22,6 +22,7 @@ import {
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import { Secret, Settings } from "../settings";
import {
generateSecret,
getDefaultReactionConfiguration,
@@ -388,7 +389,7 @@ export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
return result.value || null;
}
export async function rotateTenantSSOKey(
export async function deactivateTenantSSOKey(
mongo: Db,
id: string,
kid: string,
@@ -463,6 +464,24 @@ export async function disableTenantFeatureFlag(
return result.value || null;
}
export async function deleteTenantSSOKey(mongo: Db, id: string, kid: string) {
// Update the tenant.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"auth.integrations.sso.keys": { kid },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
return result.value || null;
}
export interface CreateAnnouncementInput {
content: string;
@@ -767,3 +786,66 @@ export async function deleteTenantWebhookEndpoint(
return result.value;
}
function lastUsedAtTenantSSOKey(id: string): string {
return `${id}:lastUsedSSOKey`;
}
/**
* updateLastUsedAtTenantSSOKey will update the time stamp that the SSO key was
* last used at.
*
* @param redis the Redis connection to use to update the timestamp on
* @param id the ID of the Tenant
* @param kid the kid of the token that was used
* @param when the date that the token was last used at
*/
export async function updateLastUsedAtTenantSSOKey(
redis: Redis,
id: string,
kid: string,
when: Date
) {
await redis.hset(lastUsedAtTenantSSOKey(id), kid, when.toISOString());
}
/**
*
* @param redis the Redis connection to use to remove the last used on.
* @param id the ID of the Tenant
* @param kid the kid of the token that is being deleted
*/
export async function deleteLastUsedAtTenantSSOKey(
redis: Redis,
id: string,
kid: string
) {
await redis.hdel(lastUsedAtTenantSSOKey(id), kid);
}
/**
* retrieveLastUsedAtTenantSSOKeys will get the dates that the requested sso
* keys were last used on.
*
* @param redis the Redis connection to use to update the timestamp on
* @param id the ID of the Tenant
* @param kids the kids of the tokens that we want to know when they were last used
*/
export async function retrieveLastUsedAtTenantSSOKeys(
redis: Redis,
id: string,
kids: string[]
) {
const results: Array<string | null> = await redis.hmget(
lastUsedAtTenantSSOKey(id),
...kids
);
return results.map(lastUsedAt => {
if (!lastUsedAt) {
return null;
}
return new Date(lastUsedAt);
});
}
+2 -548
View File
@@ -1,548 +1,2 @@
import { Redis } from "ioredis";
import { isUndefined, lowerCase, uniqBy } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { URL } from "url";
import { discover } from "coral-server/app/middleware/passport/strategies/oidc/discover";
import { Config } from "coral-server/config";
import { TenantInstalledAlreadyError } from "coral-server/errors";
import logger from "coral-server/logger";
import {
CreateAnnouncementInput,
createTenant,
createTenantAnnouncement,
CreateTenantInput,
createTenantSSOKey,
createTenantWebhookEndpoint,
CreateTenantWebhookEndpointInput,
deleteTenantAnnouncement,
deleteTenantWebhookEndpoint,
disableTenantFeatureFlag,
enableTenantFeatureFlag,
getWebhookEndpoint,
rollTenantWebhookEndpointSecret,
rotateTenantSSOKey,
Tenant,
updateTenant,
updateTenantWebhookEndpoint,
UpdateTenantWebhookEndpointInput,
} from "coral-server/models/tenant";
import { I18n } from "coral-server/services/i18n";
import {
GQLFEATURE_FLAG,
GQLSettingsInput,
GQLSettingsWordListInput,
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import TenantCache from "./cache";
export type UpdateTenant = GQLSettingsInput;
function cleanWordList(
list: GQLSettingsWordListInput
): GQLSettingsWordListInput {
if (list.banned) {
list.banned = uniqBy(list.banned.filter(Boolean), lowerCase) as string[];
}
if (list.suspect) {
list.suspect = uniqBy(list.suspect.filter(Boolean), lowerCase) as string[];
}
return list;
}
export async function update(
mongo: Db,
redis: Redis,
cache: TenantCache,
config: Config,
tenant: Tenant,
input: UpdateTenant
): Promise<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;
}
export * from "./tenant";
export * from "./sso";
+133
View File
@@ -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;
}
+500
View File
@@ -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;
}
+45
View File
@@ -427,6 +427,51 @@ configure-auth-sso-regenerateAt = KEY GENERATED AT:
configure-auth-sso-regenerateHonoredWarning =
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
configure-auth-sso-description =
To enable integration with your existing authentication system,
you will need to create a JWT Token to connect. You can learn
more about creating a JWT Token with <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-useLoginOn = Use email authentication login on