mirror of
https://github.com/wassname/talk.git
synced 2026-07-10 03:47:28 +08:00
[CORL-754] SSO Key Management (#2732)
* feat: initial impl * feat: clean up redis when keys are deleted * fix: remove test timeout value
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,64 @@ 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
|
||||
}
|
||||
|
||||
#########################
|
||||
# disableFeatureFlag
|
||||
#########################
|
||||
@@ -5884,10 +5999,10 @@ type DisableFeatureFlagPayload {
|
||||
}
|
||||
|
||||
#########################
|
||||
# Add / Remove Expert
|
||||
# addStoryExpert
|
||||
#########################
|
||||
|
||||
input AddExpertInput {
|
||||
input AddStoryExpertInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
@@ -5904,7 +6019,7 @@ input AddExpertInput {
|
||||
userID: ID!
|
||||
}
|
||||
|
||||
type AddExpertPayload {
|
||||
type AddStoryExpertPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
@@ -5916,7 +6031,11 @@ type AddExpertPayload {
|
||||
story: Story!
|
||||
}
|
||||
|
||||
input RemoveExpertInput {
|
||||
#########################
|
||||
# removeStoryExpert
|
||||
#########################
|
||||
|
||||
input RemoveStoryExpertInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
@@ -5933,7 +6052,7 @@ input RemoveExpertInput {
|
||||
userID: ID!
|
||||
}
|
||||
|
||||
type RemoveExpertPayload {
|
||||
type RemoveStoryExpertPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
@@ -5945,6 +6064,38 @@ type RemoveExpertPayload {
|
||||
story: Story!
|
||||
}
|
||||
|
||||
#########################
|
||||
## 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
|
||||
}
|
||||
|
||||
#########################
|
||||
## 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!
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user