[CORL-810] Custom Moderation Phases (#2901)

* feat: initial implementation

* feat: renamed fields from mutations

* fix: more renaming to streamline {Key,Secret}->SigningSecret

* feat: introduced WrappedInternalError

* feat: enhanced extern payload, more fetch options

- Added tenant.{id,domain} to extern payload
- Added site.id to the extern payload
- Added response size limit to fetch
- Added new SCRAPE_MAX_RESPONSE_SIZE env var for managing the size of
  responses for scraping

* fix: fixed bug with scrape invocation

* feat: added more queries + mutations

- Added Query.externalModerationPhase
- Added Mutation.createExternalModerationPhase
- Added Mutation.updateExternalModerationPhase
- Added Mutation.enableExternalModerationPhase
- Added Mutation.disableExternalModerationPhase
- Added Mutation.deleteExternalModerationPhase
- Added Mutation.rotateExternalModerationPhaseSigningSecret

* feat: added secret management

* fix: linting

* fix: merge conflict fix

* feat: added UI

* fix: linting

* fix: linting

* fix: updated snapshots

* fix: improved docs

* fix: improved docs

* fix: added locales

* review: improve naming

* review: some review changes

- Switched /moderation/phase to /moderation/phases
- Fixed scrolling
- Fixed redirection

* fix: added scroll timeout for webhooks
This commit is contained in:
Wyatt Johnson
2020-05-14 19:20:35 +00:00
committed by GitHub
parent ceb96dba75
commit ed92f4916d
154 changed files with 5445 additions and 1694 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ import { Metrics } from "coral-server/services/metrics";
import { MigrationManager } from "coral-server/services/migrate";
import { PersistedQueryCache } from "coral-server/services/queries";
import { AugmentedRedis } from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { healthHandler, versionHandler } from "./handlers";
import { compileTrust } from "./helpers";
+2 -2
View File
@@ -1,6 +1,6 @@
import { FluentBundle } from "@fluent/bundle/compat";
import { CoralError, InternalError } from "coral-server/errors";
import { CoralError, WrappedInternalError } from "coral-server/errors";
import { I18n } from "coral-server/services/i18n";
import { ErrorRequestHandler, Request } from "coral-server/types/express";
@@ -12,7 +12,7 @@ import { ErrorRequestHandler, Request } from "coral-server/types/express";
const wrapError = (err: Error) =>
err instanceof CoralError
? err
: new InternalError(err, "wrapped internal error");
: new WrappedInternalError(err, "wrapped internal error");
/**
* serializeError will return a serialized error that can be returned via the
@@ -7,8 +7,10 @@ import { IntegrationDisabled } from "coral-server/errors";
import { AuthIntegrations } from "coral-server/models/settings";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import {
TenantCache,
TenantCacheAdapter,
} from "coral-server/services/tenant/cache";
import { Request } from "coral-server/types/express";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
@@ -23,8 +23,10 @@ import {
User,
} from "coral-server/models/user";
import { AsymmetricSigningAlgorithm } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import {
TenantCache,
TenantCacheAdapter,
} from "coral-server/services/tenant/cache";
import { findOrCreate } from "coral-server/services/users";
import { validateUsername } from "coral-server/services/users/helpers";
import { Request } from "coral-server/types/express";
@@ -2,10 +2,10 @@ import jwks, { JwksClient } from "jwks-rsa";
import { Db } from "mongodb";
import { AppOptions } from "coral-server/app";
import { Tenant } from "coral-server/models/tenant";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache";
import { Verifier } from "../jwt";
import {
findOrCreateOIDCUserWithToken,
@@ -7,10 +7,14 @@ 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 {
filterActiveSigningSecrets,
SigningSecret,
SSOAuthIntegration,
} from "coral-server/models/settings";
import {
Tenant,
updateLastUsedAtTenantSSOKey,
updateLastUsedAtTenantSSOSigningSecret,
} from "coral-server/models/tenant";
import {
retrieveUserWithProfile,
@@ -167,7 +171,7 @@ export async function findOrCreateSSOUser(
const updateLastUsedAtKID = throttle(
async (redis: Redis, tenantID: string, kid: string, now: Date) => {
try {
await updateLastUsedAtTenantSSOKey(redis, tenantID, kid, now);
await updateLastUsedAtTenantSSOSigningSecret(redis, tenantID, kid, now);
logger.trace({ tenantID, kid }, "updated last used tenant sso key");
} catch (err) {
logger.error(
@@ -185,23 +189,18 @@ export interface SSOVerifierOptions {
redis: AugmentedRedis;
}
export function getRelevantSSOKeys(
export function getRelevantSSOSigningSecrets(
integration: SSOAuthIntegration,
tokenString: string,
now: Date,
kid?: string
): Secret[] {
): SigningSecret[] {
// Collect all the current valid keys.
const keys = integration.keys.filter((k) => {
if (k.inactiveAt && now >= k.inactiveAt) {
return false;
}
return k;
});
// If there is only one key, that's all we can use!
const keys = integration.signingSecrets.filter(
filterActiveSigningSecrets(now)
);
if (keys.length === 1) {
// There is only one key, that's all we can use!
return keys;
}
@@ -259,14 +258,13 @@ export class SSOVerifier implements Verifier<SSOToken> {
throw new IntegrationDisabled("sso");
}
// check to see if there is at least one key associated with this
// integration.
if (integration.keys.length === 0) {
throw new Error("integration key does not exist");
}
// Get the valid configurations for the given token and integration pair.
const keys = getRelevantSSOKeys(integration, tokenString, now, kid);
const keys = getRelevantSSOSigningSecrets(
integration,
tokenString,
now,
kid
);
if (keys.length === 0) {
throw new TokenInvalidError(
tokenString,
+1 -1
View File
@@ -2,7 +2,7 @@ import { v1 as uuid } from "uuid";
import { TenantNotFoundError } from "coral-server/errors";
import logger from "coral-server/logger";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { RequestHandler } from "coral-server/types/express";
export interface MiddlewareOptions {
+1 -1
View File
@@ -9,7 +9,7 @@ import { cspSiteMiddleware } from "coral-server/app/middleware/csp/tenant";
import { installedMiddleware } from "coral-server/app/middleware/installed";
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
import logger from "coral-server/logger";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { RequestHandler } from "coral-server/types/express";
import Entrypoints, { Entrypoint } from "../helpers/entrypoints";
+9 -2
View File
@@ -7,7 +7,7 @@ import os from "os";
import { LOCALES } from "coral-common/helpers/i18n/locales";
import { ensureEndSlash } from "coral-common/utils";
import { InternalError } from "./errors";
import { WrappedInternalError } from "./errors";
// Add custom format for the mongo uri scheme.
convict.addFormat({
@@ -15,7 +15,7 @@ convict.addFormat({
validate: (url: string) => {
parseConnectionString(url, (err) => {
if (err) {
throw new InternalError(err, "invalid mongo-uri");
throw new WrappedInternalError(err, "invalid mongo-uri");
}
});
},
@@ -237,6 +237,13 @@ const config = convict({
default: false,
env: "DISABLE_RATE_LIMITERS",
},
scrape_max_response_size: {
doc: "The maximum size (in bytes) to allow for scraping responses.",
format: Number,
default: 10e6,
env: "SCRAPE_MAX_RESPONSE_SIZE",
arg: "scrapeMaxResponseSize",
},
scrape_timeout: {
doc: "The request timeout (in ms) for scraping operations.",
format: "ms",
+1 -1
View File
@@ -2,7 +2,7 @@ import { Db } from "mongodb";
import { retrieveUserScheduledForDeletion } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { deleteUser } from "coral-server/services/users/delete";
import {
+1 -1
View File
@@ -3,7 +3,7 @@ import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { JWTSigningConfig } from "coral-server/services/jwt";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { registerAccountDeletion } from "./accountDeletion";
import { registerNotificationDigesting } from "./notificationDigesting";
@@ -7,7 +7,7 @@ import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { JWTSigningConfig } from "coral-server/services/jwt";
import NotificationContext from "coral-server/services/notifications/context";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import {
ScheduledJob,
+3 -3
View File
@@ -1,6 +1,6 @@
import { VError } from "verror";
import { CoralError, DuplicateUserError, InternalError } from ".";
import { CoralError, DuplicateUserError, WrappedInternalError } from ".";
it("has the right inheritance chain", () => {
const err = new DuplicateUserError();
@@ -14,12 +14,12 @@ it("has the right inheritance chain", () => {
});
it("provides an accurate stack", () => {
const err = new InternalError(
const err = new WrappedInternalError(
new Error("this is a test"),
"this is the reason"
);
expect(err).toBeInstanceOf(InternalError);
expect(err).toBeInstanceOf(WrappedInternalError);
expect(err).toBeInstanceOf(CoralError);
expect(err).toBeInstanceOf(VError);
expect(err).toBeInstanceOf(Error);
+11 -1
View File
@@ -465,7 +465,7 @@ export class IntegrationDisabled extends CoralError {
}
}
export class InternalError extends CoralError {
export class WrappedInternalError extends CoralError {
constructor(cause: Error, reason: string) {
super({
code: ERROR_CODES.INTERNAL_ERROR,
@@ -476,6 +476,16 @@ export class InternalError extends CoralError {
}
}
export class InternalError extends CoralError {
constructor(reason: string, context?: Record<string, any>) {
super({
code: ERROR_CODES.INTERNAL_ERROR,
context: { pvt: { reason, ...context } },
status: 500,
});
}
}
export class InternalDevelopmentError extends CoralError {
constructor(cause: Error, reason: string) {
super({
+1 -1
View File
@@ -19,7 +19,7 @@ import { WebhookQueue } from "coral-server/queue/tasks/webhook";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { AugmentedRedis } from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { Request } from "coral-server/types/express";
import loaders from "./loaders";
@@ -6,7 +6,7 @@ import { merge } from "lodash";
import {
CoralError,
InternalDevelopmentError,
InternalError,
WrappedInternalError,
} from "coral-server/errors";
import GraphContext from "coral-server/graph/context";
import { getOriginalError } from "./helpers";
@@ -71,7 +71,7 @@ function getWrappedOriginalError(
);
}
return new InternalError(originalError, "wrapped internal error");
return new WrappedInternalError(originalError, "wrapped internal error");
}
/**
+3 -3
View File
@@ -1,7 +1,7 @@
import DataLoader from "dataloader";
import GraphContext from "coral-server/graph/context";
import { retrieveLastUsedAtTenantSSOKeys } from "coral-server/models/tenant";
import { retrieveLastUsedAtTenantSSOSigningSecrets } from "coral-server/models/tenant";
import { discoverOIDCConfiguration } from "coral-server/services/tenant";
import { GQLDiscoveredOIDCConfiguration } from "coral-server/graph/schema/__generated__/types";
@@ -19,7 +19,7 @@ export default (ctx: GraphContext) => ({
cache: !ctx.disableCaching,
}
),
retrieveSSOKeyLastUsedAt: new DataLoader((kids: string[]) =>
retrieveLastUsedAtTenantSSOKeys(ctx.redis, ctx.tenant.id, kids)
retrieveSSOSigningSecretLastUsedAt: new DataLoader((kids: string[]) =>
retrieveLastUsedAtTenantSSOSigningSecrets(ctx.redis, ctx.tenant.id, kids)
),
});
+6 -5
View File
@@ -211,12 +211,13 @@ export default (ctx: GraphContext) => ({
// This typecast is needed because the custom `ms` format does not return
// the desired `number` type even though that's the only type it can
// output.
scraper.scrape(
scraper.scrape({
url,
(ctx.config.get("scrape_timeout") as unknown) as number,
ctx.tenant.stories.scraping.customUserAgent,
ctx.tenant.stories.scraping.proxyURL
)
timeout: (ctx.config.get("scrape_timeout") as unknown) as number,
size: ctx.config.get("scrape_response_max_size"),
customUserAgent: ctx.tenant.stories.scraping.customUserAgent,
proxyURL: ctx.tenant.stories.scraping.proxyURL,
})
),
{
// Disable caching for the DataLoader if the Context is designed to be
+80 -18
View File
@@ -2,34 +2,46 @@ import GraphContext from "coral-server/graph/context";
import { Tenant } from "coral-server/models/tenant";
import {
createAnnouncement,
createExternalModerationPhase,
createWebhookEndpoint,
deactivateSSOKey,
deactivateSSOSigningSecret,
deleteAnnouncement,
deleteSSOKey,
deleteExternalModerationPhase,
deleteSSOSigningSecret,
deleteWebhookEndpoint,
disableExternalModerationPhase,
disableFeatureFlag,
disableWebhookEndpoint,
enableExternalModerationPhase,
enableFeatureFlag,
enableWebhookEndpoint,
regenerateSSOKey,
rotateSSOKey,
rotateWebhookEndpointSecret,
rotateExternalModerationPhaseSigningSecret,
rotateSSOSigningSecret,
rotateWebhookEndpointSigningSecret,
sendSMTPTest,
update,
updateExternalModerationPhase,
updateWebhookEndpoint,
} from "coral-server/services/tenant";
import {
GQLCreateAnnouncementInput,
GQLCreateExternalModerationPhaseInput,
GQLCreateWebhookEndpointInput,
GQLDeactivateSSOKeyInput,
GQLDeleteSSOKeyInput,
GQLDeactivateSSOSigningSecretInput,
GQLDeleteExternalModerationPhaseInput,
GQLDeleteSSOSigningSecretInput,
GQLDeleteWebhookEndpointInput,
GQLDisableExternalModerationPhaseInput,
GQLDisableWebhookEndpointInput,
GQLEnableExternalModerationPhaseInput,
GQLEnableWebhookEndpointInput,
GQLFEATURE_FLAG,
GQLRotateSSOKeyInput,
GQLRotateWebhookEndpointSecretInput,
GQLRotateExternalModerationPhaseSigningSecretInput,
GQLRotateSSOSigningSecretInput,
GQLRotateWebhookEndpointSigningSecretInput,
GQLUpdateExternalModerationPhaseInput,
GQLUpdateSettingsInput,
GQLUpdateWebhookEndpointInput,
} from "coral-server/graph/schema/__generated__/types";
@@ -50,20 +62,21 @@ export const Settings = ({
input: WithoutMutationID<GQLUpdateSettingsInput>
): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, config, tenant, input.settings),
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
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),
rotateSSOSigningSecret: ({ inactiveIn }: GQLRotateSSOSigningSecretInput) =>
rotateSSOSigningSecret(mongo, redis, tenantCache, tenant, inactiveIn, now),
deleteSSOSigningSecret: ({ kid }: GQLDeleteSSOSigningSecretInput) =>
deleteSSOSigningSecret(mongo, redis, tenantCache, tenant, kid),
deactivateSSOSigningSecret: ({ kid }: GQLDeactivateSSOSigningSecretInput) =>
deactivateSSOSigningSecret(mongo, redis, tenantCache, tenant, kid, now),
enableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
enableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
disableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
disableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
createAnnouncement: (input: GQLCreateAnnouncementInput) =>
createAnnouncement(mongo, redis, tenantCache, tenant, input, now),
createAnnouncement(mongo, redis, tenantCache, tenant, input),
deleteAnnouncement: () =>
deleteAnnouncement(mongo, redis, tenantCache, tenant),
createWebhookEndpoint: (
@@ -92,10 +105,59 @@ export const Settings = ({
deleteWebhookEndpoint: (
input: WithoutMutationID<GQLDeleteWebhookEndpointInput>
) => deleteWebhookEndpoint(mongo, redis, tenantCache, tenant, input.id),
rotateWebhookEndpointSecret: (
input: WithoutMutationID<GQLRotateWebhookEndpointSecretInput>
rotateWebhookEndpointSigningSecret: (
input: WithoutMutationID<GQLRotateWebhookEndpointSigningSecretInput>
) =>
rotateWebhookEndpointSecret(
rotateWebhookEndpointSigningSecret(
mongo,
redis,
tenantCache,
tenant,
input.id,
input.inactiveIn,
now
),
createExternalModerationPhase: (
input: WithoutMutationID<GQLCreateExternalModerationPhaseInput>
) =>
createExternalModerationPhase(
mongo,
redis,
config,
tenantCache,
tenant,
input,
now
),
updateExternalModerationPhase: ({
id,
...input
}: WithoutMutationID<GQLUpdateExternalModerationPhaseInput>) =>
updateExternalModerationPhase(
mongo,
redis,
config,
tenantCache,
tenant,
id,
input
),
enableExternalModerationPhase: (
input: WithoutMutationID<GQLEnableExternalModerationPhaseInput>
) =>
enableExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id),
disableExternalModerationPhase: (
input: WithoutMutationID<GQLDisableExternalModerationPhaseInput>
) =>
disableExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id),
deleteExternalModerationPhase: (
input: WithoutMutationID<GQLDeleteExternalModerationPhaseInput>
) =>
deleteExternalModerationPhase(mongo, redis, tenantCache, tenant, input.id),
rotateExternalModerationPhaseSigningSecret: (
input: WithoutMutationID<GQLRotateExternalModerationPhaseSigningSecretInput>
) =>
rotateExternalModerationPhaseSigningSecret(
mongo,
redis,
tenantCache,
+4 -3
View File
@@ -9,6 +9,7 @@ import {
} from "coral-server/models/action/comment";
import * as comment from "coral-server/models/comment";
import {
getDepth,
getLatestRevision,
hasAncestors,
hasPublishedStatus,
@@ -52,9 +53,9 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
c.revisions.length > 0
? { revision: getLatestRevision(c), comment: c }
: null,
deleted: ({ deletedAt }) => !!deletedAt,
revisionHistory: (c) =>
c.revisions.map((revision) => ({ revision, comment: c })),
deleted: ({ deletedAt }) => !!deletedAt,
editing: ({ revisions, createdAt }, input, ctx) => ({
// When there is more than one body history, then the comment has been
// edited.
@@ -98,8 +99,8 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
}),
viewerActionPresence: (c, input, ctx) =>
ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null,
parentCount: (c) => (hasAncestors(c) ? c.ancestorIDs.length : 0),
depth: (c) => (hasAncestors(c) ? c.ancestorIDs.length : 0),
parentCount: (c) => getDepth(c),
depth: (c) => getDepth(c),
rootParent: (c, input, ctx, info) =>
hasAncestors(c)
? maybeLoadOnlyID(ctx, info, c.ancestorIDs[c.ancestorIDs.length - 1])
@@ -0,0 +1,8 @@
import * as settings from "coral-server/models/settings";
import { GQLExternalModerationPhaseTypeResolver } from "coral-server/graph/schema/__generated__/types";
export const ExternalModerationPhase: GQLExternalModerationPhaseTypeResolver<settings.ExternalModerationPhase> = {
signingSecret: ({ signingSecrets }) =>
signingSecrets[signingSecrets.length - 1],
};
+61 -8
View File
@@ -75,20 +75,21 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
comment: await ctx.mutators.Comments.unfeature(input),
clientMutationId,
}),
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
regenerateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.regenerateSSOKey(),
clientMutationId: input.clientMutationId,
}),
rotateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.rotateSSOKey(input),
rotateSSOSigningSecret: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.rotateSSOSigningSecret(input),
clientMutationId: input.clientMutationId,
}),
deactivateSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.deactivateSSOKey(input),
deactivateSSOSigningSecret: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.deactivateSSOSigningSecret(input),
clientMutationId: input.clientMutationId,
}),
deleteSSOKey: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.deleteSSOKey(input),
deleteSSOSigningSecret: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.deleteSSOSigningSecret(input),
clientMutationId: input.clientMutationId,
}),
createStory: async (source, { input }, ctx) => ({
@@ -319,12 +320,64 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
endpoint: await ctx.mutators.Settings.deleteWebhookEndpoint(input),
clientMutationId,
}),
rotateWebhookEndpointSecret: async (
rotateWebhookEndpointSigningSecret: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSecret(input),
endpoint: await ctx.mutators.Settings.rotateWebhookEndpointSigningSecret(
input
),
clientMutationId,
}),
createExternalModerationPhase: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
...(await ctx.mutators.Settings.createExternalModerationPhase(input)),
clientMutationId,
}),
updateExternalModerationPhase: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
phase: await ctx.mutators.Settings.updateExternalModerationPhase(input),
clientMutationId,
}),
disableExternalModerationPhase: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
phase: await ctx.mutators.Settings.disableExternalModerationPhase(input),
clientMutationId,
}),
enableExternalModerationPhase: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
phase: await ctx.mutators.Settings.enableExternalModerationPhase(input),
clientMutationId,
}),
deleteExternalModerationPhase: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
phase: await ctx.mutators.Settings.deleteExternalModerationPhase(input),
clientMutationId,
}),
rotateExternalModerationPhaseSigningSecret: async (
source,
{ input: { clientMutationId, ...input } },
ctx
) => ({
phase: await ctx.mutators.Settings.rotateExternalModerationPhaseSigningSecret(
input
),
clientMutationId,
}),
testSMTP: async (source, { input: { clientMutationId } }, ctx) => {
+5
View File
@@ -1,3 +1,4 @@
import { getExternalModerationPhase } from "coral-server/models/settings";
import { getWebhookEndpoint } from "coral-server/models/tenant";
import { GQLQueryTypeResolver } from "coral-server/graph/schema/__generated__/types";
@@ -29,4 +30,8 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
site: (source, { id }, ctx) => (id ? ctx.loaders.Sites.site.load(id) : null),
webhookEndpoint: (source, { id }, ctx) => getWebhookEndpoint(ctx.tenant, id),
queues: () => ({}),
externalModerationPhase: (source, { id }, ctx) =>
ctx.tenant.integrations.external
? getExternalModerationPhase(ctx.tenant.integrations.external, id)
: null,
};
@@ -1,25 +1,25 @@
import * as settings from "coral-server/models/settings";
import { GQLSSOAuthIntegrationTypeResolver } from "coral-server/graph/schema/__generated__/types";
import { filterFreshSigningSecrets } from "coral-server/models/settings";
function getActiveSSOKey(keys: settings.Secret[]) {
// Any key that has been rotated cannot be the active key.
return keys.find((key) => !key.rotatedAt);
function getActiveSSOSigningSecret(keys: settings.SigningSecret[]) {
return keys.find(filterFreshSigningSecrets());
}
export const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<settings.SSOAuthIntegration> = {
key: ({ keys }) => {
const key = getActiveSSOKey(keys);
if (key) {
return key.secret;
key: ({ signingSecrets }) => {
const signingSecret = getActiveSSOSigningSecret(signingSecrets);
if (signingSecret) {
return signingSecret.secret;
}
return null;
},
keyGeneratedAt: ({ keys }) => {
const key = getActiveSSOKey(keys);
if (key) {
return key.createdAt;
keyGeneratedAt: ({ signingSecrets }) => {
const signingSecret = getActiveSSOSigningSecret(signingSecrets);
if (signingSecret) {
return signingSecret.createdAt;
}
return null;
@@ -1,8 +0,0 @@
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),
};
@@ -0,0 +1,8 @@
import * as settings from "coral-server/models/settings";
import { GQLSigningSecretTypeResolver } from "coral-server/graph/schema/__generated__/types";
export const SigningSecret: GQLSigningSecretTypeResolver<settings.SigningSecret> = {
lastUsedAt: async ({ kid }, args, ctx) =>
ctx.loaders.Auth.retrieveSSOSigningSecretLastUsedAt.load(kid),
};
+4 -2
View File
@@ -20,6 +20,7 @@ import { CommentReplyCreatedPayload } from "./CommentReplyCreatedPayload";
import { CommentRevision } from "./CommentRevision";
import { CommentStatusUpdatedPayload } from "./CommentStatusUpdatedPayload";
import { DisableCommenting } from "./DisableCommenting";
import { ExternalModerationPhase } from "./ExternalModerationPhase";
import { FacebookAuthIntegration } from "./FacebookAuthIntegration";
import { FeatureCommentPayload } from "./FeatureCommentPayload";
import { Flag } from "./Flag";
@@ -39,8 +40,8 @@ import { Queue } from "./Queue";
import { Queues } from "./Queues";
import { RecentCommentHistory } from "./RecentCommentHistory";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Secret } from "./Secret";
import { Settings } from "./Settings";
import { SigningSecret } from "./SigningSecret";
import { SlackConfiguration } from "./SlackConfiguration";
import { SSOAuthIntegration } from "./SSOAuthIntegration";
import { Story } from "./Story";
@@ -73,6 +74,7 @@ const Resolvers: GQLResolver = {
CommentStatusUpdatedPayload,
Cursor,
DisableCommenting,
ExternalModerationPhase,
FacebookAuthIntegration,
FeatureCommentPayload,
Flag,
@@ -92,7 +94,7 @@ const Resolvers: GQLResolver = {
RecentCommentHistory,
RejectCommentPayload,
SSOAuthIntegration,
Secret,
SigningSecret,
Story,
StorySettings,
Subscription,
+394 -46
View File
@@ -495,7 +495,7 @@ type LocalAuthIntegration {
## SSOAuthIntegration
##########################
type Secret {
type SigningSecret {
"""
kid is the identifier for the key used when verifying tokens issued by the
provider.
@@ -551,23 +551,23 @@ type SSOAuthIntegration {
targetFilter: AuthenticationTargetFilter!
"""
keys are the different SSOKey's used by this Tenant.
signingSecrets are the different SigningSecret's used by this Tenant.
"""
keys: [Secret!]! @auth(roles: [ADMIN])
signingSecrets: [SigningSecret!]! @auth(roles: [ADMIN])
"""
key is the secret that is used to sign tokens.
"""
key: String
@auth(roles: [ADMIN])
@deprecated(reason: "field is deprecated in favour of `keys`")
@deprecated(reason: "field is deprecated in favour of `signingSecrets`")
"""
keyGeneratedAt is the Time that the key was effective from.
"""
keyGeneratedAt: Time
@auth(roles: [ADMIN])
@deprecated(reason: "field is deprecated in favour of `keys`")
@deprecated(reason: "field is deprecated in favour of `signingSecrets`")
}
##########################
@@ -908,6 +908,73 @@ type PerspectiveExternalIntegration {
sendFeedback: Boolean @auth(roles: [ADMIN])
}
"""
COMMENT_BODY_FORMAT describes the various formats that a comment body can be
provided in.
"""
enum COMMENT_BODY_FORMAT {
"""
HTML describes the format of the comment body using HTML.
"""
HTML
"""
PLAIN_TEXT describes the format of the comment body with the HTML stripped.
"""
PLAIN_TEXT
}
"""
ExternalModerationPhase describes a phase use in the moderation pipeline that
calls out to an external resource as defined by the provided URL.
"""
type ExternalModerationPhase {
"""
id identifies this particular External Moderation Phase.
"""
id: ID!
"""
name is the name assigned to this ExternalModerationPhase for identification
purposes.
"""
name: String!
"""
enabled when true, will use this phase in the moderation pipeline.
"""
enabled: Boolean!
"""
url is the actual URL that should be called.
"""
url: String!
"""
format is the format of the comment body sent.
"""
format: COMMENT_BODY_FORMAT!
"""
timeout is the number of milliseconds that this moderation is maximum expected
to take before it is skipped.
"""
timeout: Int!
"""
signingSecret is the secret used to sign outgoing requests to the url during
the moderation pipeline.
"""
signingSecret: SigningSecret!
}
type CustomExternalIntegration {
"""
phases is all the external moderation phases for this Tenant.
"""
phases: [ExternalModerationPhase!]!
}
type ExternalIntegrations {
"""
akismet provides integration with the Akismet Spam detection service.
@@ -919,6 +986,12 @@ type ExternalIntegrations {
platform.
"""
perspective: PerspectiveExternalIntegration!
"""
external provides integration details for external moderation phases that can be
used in the moderation pipeline.
"""
external: CustomExternalIntegration
}
################################################################################
@@ -1318,7 +1391,7 @@ type WebhookEndpoint {
"""
signingSecret is the current secret used to sign the events sent out.
"""
signingSecret: Secret!
signingSecret: SigningSecret!
"""
deliveries store the deliveries for each event sent for the last 50 events.
@@ -3183,6 +3256,13 @@ type Query {
queues returns information on queues used in Coral to manage
"""
queues: Queues! @auth(roles: [ADMIN])
"""
externalModerationPhase will return a specific ExternalModerationPhase if it
exists.
"""
externalModerationPhase(id: ID!): ExternalModerationPhase
@auth(roles: [ADMIN])
}
################################################################################
@@ -4369,7 +4449,7 @@ input RegenerateSSOKeyInput {
type RegenerateSSOKeyPayload {
"""
settings is the Settings that the SSO key was regenerated on.
settings is the Settings that the SSO secret was regenerated on.
"""
settings: Settings
@@ -5158,10 +5238,10 @@ type UpdateWebhookEndpointPayload {
}
##################
# rotateWebhookEndpointSecret
# rotateWebhookEndpointSigningSecret
##################
input RotateWebhookEndpointSecretInput {
input RotateWebhookEndpointSigningSecretInput {
"""
clientMutationId is required for Relay support.
"""
@@ -5179,7 +5259,7 @@ input RotateWebhookEndpointSecretInput {
inactiveIn: Int!
}
type RotateWebhookEndpointSecretPayload {
type RotateWebhookEndpointSigningSecretPayload {
"""
clientMutationId is required for Relay support.
"""
@@ -5188,7 +5268,7 @@ type RotateWebhookEndpointSecretPayload {
"""
endpoint is the endpoint that we just updated.
"""
endpoint: WebhookEndpoint
endpoint: WebhookEndpoint!
}
##################
@@ -5216,7 +5296,7 @@ type DisableWebhookEndpointPayload {
"""
endpoint is the endpoint that we just disabled.
"""
endpoint: WebhookEndpoint
endpoint: WebhookEndpoint!
}
##################
@@ -5244,7 +5324,7 @@ type EnableWebhookEndpointPayload {
"""
endpoint is the endpoint that we just enabled.
"""
endpoint: WebhookEndpoint
endpoint: WebhookEndpoint!
}
##################
@@ -5272,7 +5352,225 @@ type DeleteWebhookEndpointPayload {
"""
endpoint is the endpoint that we just deleted.
"""
endpoint: WebhookEndpoint
endpoint: WebhookEndpoint!
}
#################
# createExternalModerationPhase
##################
input CreateExternalModerationPhaseInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
name is the name assigned to this ExternalModerationPhase for identification
purposes.
"""
name: String!
"""
url is the URL that Coral will POST moderation queries to.
"""
url: String!
"""
format is the format of the comment body sent.
"""
format: COMMENT_BODY_FORMAT!
"""
timeout is the number of milliseconds that this moderation is maximum expected
to take before it is skipped.
"""
timeout: Int!
}
type CreateExternalModerationPhasePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
phase is the ExternalModerationPhase that we just created.
"""
phase: ExternalModerationPhase!
"""
settings is the updated settings also containing the new phase.
"""
settings: Settings!
}
##################
# updateExternalModerationPhase
##################
input UpdateExternalModerationPhaseInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the ExternalModerationPhase being updated.
"""
id: ID!
"""
name is the name assigned to this ExternalModerationPhase for identification
purposes.
"""
name: String
"""
url is the URL that Coral will POST moderation queries to.
"""
url: String
"""
format is the format of the comment body sent.
"""
format: COMMENT_BODY_FORMAT
"""
timeout is the number of milliseconds that this moderation is maximum expected
to take before it is skipped.
"""
timeout: Int
}
type UpdateExternalModerationPhasePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
phase is the ExternalModerationPhase that we just updated.
"""
phase: ExternalModerationPhase!
}
##################
# deleteExternalModerationPhase
##################
input DeleteExternalModerationPhaseInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the ExternalModerationPhase being deleted.
"""
id: ID!
}
type DeleteExternalModerationPhasePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
phase is the ExternalModerationPhase that we just deleted.
"""
phase: ExternalModerationPhase!
}
##################
# disableExternalModerationPhase
##################
input DisableExternalModerationPhaseInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the ExternalModerationPhase being disabled.
"""
id: ID!
}
type DisableExternalModerationPhasePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
phase is the ExternalModerationPhase that we just disabled.
"""
phase: ExternalModerationPhase!
}
##################
# enableExternalModerationPhase
##################
input EnableExternalModerationPhaseInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the ExternalModerationPhase being enabled.
"""
id: ID!
}
type EnableExternalModerationPhasePayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
phase is the ExternalModerationPhase that we just enabled.
"""
phase: ExternalModerationPhase!
}
##################
# rotateExternalModerationPhaseSigningSecret
##################
input RotateExternalModerationPhaseSigningSecretInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
id is the ID of the ExternalModerationPhase being updated.
"""
id: ID!
"""
inactiveIn is the number of seconds that the current active Secret should be
kept active.
"""
inactiveIn: Int!
}
type RotateExternalModerationPhaseSigningSecretPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
phase is the ExternalModerationPhase that we just updated.
"""
phase: ExternalModerationPhase!
}
##################
@@ -6001,87 +6299,87 @@ type EnableFeatureFlagPayload {
}
#########################
## rotateSSOKey
## rotateSSOSigningSecret
#########################
input RotateSSOKeyInput {
input RotateSSOSigningSecretInput {
"""
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 is the number of seconds that the current active SigningSecret
should be kept active (allow signed tokens signed with this secret) before
rejecting them.
"""
inactiveIn: Int!
}
type RotateSSOKeyPayload {
type RotateSSOSigningSecretPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
settings is the Settings that the SSO secret was regenerated on.
"""
settings: Settings
}
#########################
## deactivateSSOKey
## deactivateSSOSigningSecret
#########################
input DeactivateSSOKeyInput {
input DeactivateSSOSigningSecretInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
kid is the ID of the SSOKey being deactivated.
kid is the ID of the SigningSecret being deactivated.
"""
kid: ID!
}
type DeactivateSSOKeyPayload {
type DeactivateSSOSigningSecretPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
settings is the Settings that the SSO secret was regenerated on.
"""
settings: Settings
}
#########################
## deleteSSOKey
## deleteSSOSigningSecret
#########################
input DeleteSSOKeyInput {
input DeleteSSOSigningSecretInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
kid is the ID of the SSOKey being deleted.
kid is the ID of the SigningSecret being deleted.
"""
kid: ID!
}
type DeleteSSOKeyPayload {
type DeleteSSOSigningSecretPayload {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
"""
settings is the Settings that the SSO key was regenerated on.
settings is the Settings that the SSO secret was regenerated on.
"""
settings: Settings
}
@@ -6349,30 +6647,35 @@ type Mutation {
@auth(roles: [ADMIN])
"""
regenerateSSOKey will regenerate the SSO key used to sign secrets. This will
regenerateSSOKey will regenerate the SSO secret used to sign secrets. This will
invalidate any existing user sessions.
DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
"""
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
@auth(roles: [ADMIN])
@deprecated(reason: "deprecated in favour of `rotateSSOKey`")
@deprecated(reason: "deprecated in favour of `rotateSSOSigningSecret`")
"""
rotateSSOKey can be used to rotate a given active SSOKey.
rotateSSOSigningSecret can be used to rotate a given active SigningSecret.
"""
rotateSSOKey(input: RotateSSOKeyInput!): RotateSSOKeyPayload!
@auth(roles: [ADMIN])
rotateSSOSigningSecret(
input: RotateSSOSigningSecretInput!
): RotateSSOSigningSecretPayload! @auth(roles: [ADMIN])
"""
deactivateSSOKey will deactivate a given deactivated SSOKey.
deactivateSSOSigningSecret will deactivate a given deactivated SigningSecret.
"""
deactivateSSOKey(input: DeactivateSSOKeyInput!): DeactivateSSOKeyPayload!
@auth(roles: [ADMIN])
deactivateSSOSigningSecret(
input: DeactivateSSOSigningSecretInput!
): DeactivateSSOSigningSecretPayload! @auth(roles: [ADMIN])
"""
deleteSSOKey will delete a given inactive SSOKey.
deleteSSOSigningSecret will delete a given inactive SigningSecret.
"""
deleteSSOKey(input: DeleteSSOKeyInput!): DeleteSSOKeyPayload!
@auth(roles: [ADMIN])
deleteSSOSigningSecret(
input: DeleteSSOSigningSecretInput!
): DeleteSSOSigningSecretPayload! @auth(roles: [ADMIN])
"""
createCommentReaction will create a Reaction authored by the current logged in
@@ -6762,11 +7065,56 @@ type Mutation {
): DeleteWebhookEndpointPayload! @auth(roles: [ADMIN])
"""
rotateWebhookEndpointSecret will roll the current active secret to a new key.
rotateWebhookEndpointSigningSecret will roll the current active secret to a new key.
"""
rotateWebhookEndpointSecret(
input: RotateWebhookEndpointSecretInput!
): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN])
rotateWebhookEndpointSigningSecret(
input: RotateWebhookEndpointSigningSecretInput!
): RotateWebhookEndpointSigningSecretPayload! @auth(roles: [ADMIN])
"""
createExternalModerationPhase will create a new ExternalModerationPhase.
"""
createExternalModerationPhase(
input: CreateExternalModerationPhaseInput!
): CreateExternalModerationPhasePayload! @auth(roles: [ADMIN])
"""
updateExternalModerationPhase will update a ExternalModerationPhase.
"""
updateExternalModerationPhase(
input: UpdateExternalModerationPhaseInput!
): UpdateExternalModerationPhasePayload! @auth(roles: [ADMIN])
"""
enableExternalModerationPhase will enable a ExternalModerationPhase to recieve
new comments.
"""
enableExternalModerationPhase(
input: EnableExternalModerationPhaseInput!
): EnableExternalModerationPhasePayload! @auth(roles: [ADMIN])
"""
disableExternalModerationPhase will disable a ExternalModerationPhase from
recieving new comments.
"""
disableExternalModerationPhase(
input: DisableExternalModerationPhaseInput!
): DisableExternalModerationPhasePayload! @auth(roles: [ADMIN])
"""
deleteExternalModerationPhase will delete a ExternalModerationPhase.
"""
deleteExternalModerationPhase(
input: DeleteExternalModerationPhaseInput!
): DeleteExternalModerationPhasePayload! @auth(roles: [ADMIN])
"""
rotateExternalModerationPhaseSigningSecret will roll the current active secret
to a new key.
"""
rotateExternalModerationPhaseSigningSecret(
input: RotateExternalModerationPhaseSigningSecretInput!
): RotateExternalModerationPhaseSigningSecretPayload! @auth(roles: [ADMIN])
"""
updateStoryMode will set the story mode.
@@ -24,10 +24,10 @@ import {
} from "coral-server/app/middleware/passport/strategies/jwt";
import {
CoralError,
InternalError,
LiveUpdatesDisabled,
RawQueryNotAuthorized,
TenantNotFoundError,
WrappedInternalError,
} from "coral-server/errors";
import { enrichError, logError, logQuery } from "coral-server/graph/extensions";
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
@@ -151,7 +151,10 @@ export function onConnect(options: OnConnectOptions): OnConnectFn {
if (!(err instanceof CoralError)) {
// eslint-disable-next-line no-ex-assign
err = new InternalError(err, "could not setup websocket connection");
err = new WrappedInternalError(
err,
"could not setup websocket connection"
);
}
const { message } = err.serializeExtensions(
options.i18n.getDefaultBundle()
+1 -1
View File
@@ -34,7 +34,7 @@ import {
createAugmentedRedisClient,
createRedisClient,
} from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import {
NotifierCoralEventListener,
+11
View File
@@ -57,3 +57,14 @@ export function calculateRejectionRate(counts: CommentStatusCounts): number {
export function hasTag(comment: Pick<Comment, "tags">, tag: GQLTAG) {
return comment.tags.some((v) => v.type === tag);
}
/**
* getDepth will return the depth of the comment.
*
* @param comment the comment to check for depth
*/
export function getDepth(
comment: Pick<Comment, "ancestorIDs" | "parentID">
): number {
return hasAncestors(comment) ? comment.ancestorIDs.length : 0;
}
@@ -0,0 +1,15 @@
import {
ExternalModerationExternalIntegration,
ExternalModerationPhase,
} from "./settings";
export function filterActivePhase() {
return (phase: Pick<ExternalModerationPhase, "enabled">) => phase.enabled;
}
export function getExternalModerationPhase(
integration: ExternalModerationExternalIntegration,
phaseID: string
) {
return integration.phases.find((p) => p.id === phaseID) || null;
}
+2 -1
View File
@@ -1,2 +1,3 @@
export * from "./settings";
export * from "./secret";
export * from "./helpers";
export * from "./signingSecret";
-44
View File
@@ -1,44 +0,0 @@
export interface 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: Date;
/**
* rotatedAt is the time that the token was rotated out.
*/
rotatedAt?: Date;
/**
* inactiveAt is the date that the token can no longer be used to validate
* tokens.
*/
inactiveAt?: Date;
}
export function isSecretExpired({ inactiveAt }: Secret, now = new Date()) {
if (inactiveAt && inactiveAt <= now) {
return true;
}
return false;
}
export function filterExpiredSecrets(now = new Date()) {
return (secret: Secret) => isSecretExpired(secret, now);
}
export function filterActiveSecrets(now = new Date()) {
return (secret: Secret) => !isSecretExpired(secret, now);
}
+75 -4
View File
@@ -1,6 +1,8 @@
import {
GQLAkismetExternalIntegration,
GQLAuth,
GQLAuthenticationTargetFilter,
GQLCOMMENT_BODY_FORMAT,
GQLEmailConfiguration,
GQLFacebookAuthIntegration,
GQLGoogleAuthIntegration,
@@ -8,10 +10,11 @@ import {
GQLLocalAuthIntegration,
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
GQLPerspectiveExternalIntegration,
GQLSettings,
} from "coral-server/graph/schema/__generated__/types";
import { Secret } from "./secret";
import { SigningSecretResource } from "./signingSecret";
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
@@ -38,11 +41,10 @@ export type FacebookAuthIntegration = Omit<
"callbackURL" | "redirectURL"
>;
export interface SSOAuthIntegration {
export interface SSOAuthIntegration extends SigningSecretResource {
enabled: boolean;
allowRegistration: boolean;
targetFilter: GQLAuthenticationTargetFilter;
keys: Secret[];
}
/**
@@ -85,6 +87,71 @@ export type Auth = Omit<GQLAuth, "integrations"> & {
integrations: AuthIntegrations;
};
export interface ExternalModerationPhase extends SigningSecretResource {
/**
* id identifies this particular External Moderation Phase.
*/
id: string;
/**
* name is the name assigned to this ExternalModerationPhase for
* identification purposes.
*/
name: string;
/**
* enabled when true, will use this phase in the moderation pipeline.
*/
enabled: boolean;
/**
* url is the actual URL that should be called.
*/
url: string;
/**
* format is the format of the comment body sent.
*/
format: GQLCOMMENT_BODY_FORMAT;
/**
* timeout is the number of milliseconds that this moderation is maximum
* expected to take before it is skipped.
*/
timeout: number;
/**
* createdAt is the date that this External Moderation Phase was created at.
*/
createdAt: Date;
}
export interface ExternalModerationExternalIntegration {
/**
* phases is all the external moderation phases for this Tenant.
*/
phases: ExternalModerationPhase[];
}
export interface ExternalIntegrations {
/**
* akismet provides integration with the Akismet Spam detection service.
*/
akismet: GQLAkismetExternalIntegration;
/**
* perspective provides integration with the Perspective API comment analysis
* platform.
*/
perspective: GQLPerspectiveExternalIntegration;
/**
* external provides integration details for external moderation phases that can be
* used in the moderation pipeline.
*/
external?: ExternalModerationExternalIntegration;
}
/**
* CloseCommenting contains settings related to the automatic closing of commenting on
* Stories.
@@ -108,7 +175,6 @@ export type Settings = GlobalModerationSettings &
| "email"
| "recentCommentHistory"
| "wordList"
| "integrations"
| "reaction"
| "staff"
| "editCommentWindowLength"
@@ -146,6 +212,11 @@ export type Settings = GlobalModerationSettings &
*/
accountFeatures: AccountFeatures;
/**
* integrations contains all the external integrations that can be enabled.
*/
integrations: ExternalIntegrations;
/**
* newCommenters is the configuration for how new commenters comments are treated.
*/
@@ -0,0 +1,99 @@
import crypto from "crypto";
import { SigningSecret } from "./signingSecret";
function generateSecureRandomString(size: number, drift = 5) {
return crypto
.randomBytes(size + Math.floor(Math.random() * drift))
.toString("hex");
}
export function generateSigningSecret(
prefix: string,
createdAt: Date
): SigningSecret {
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
// as 16 was the minimum length recommended.
//
// Reference: https://security.stackexchange.com/a/96176
const secret = prefix + "_" + generateSecureRandomString(32, 5);
const kid = generateSecureRandomString(8, 3);
return { kid, secret, createdAt };
}
/**
* isSecretExpired is a function that given a secret and the current date will
* return whether the secret has expired.
*
* @param secret the secret to test
* @param now the current date
*/
function isSigningSecretExpired({ inactiveAt }: SigningSecret, now: Date) {
if (inactiveAt && inactiveAt <= now) {
return true;
}
return false;
}
/**
* filterExpiredSecrets is a filter function that can be used to filter only
* secrets that are inactive or expired.
*
* @param now the current date
*/
export function filterExpiredSigningSecrets(now: Date) {
return (secret: SigningSecret) => isSigningSecretExpired(secret, now);
}
/**
* filterFreshSecrets is a filtering function that can be used to filter for any
* secret that has not been rotated.
*/
export function filterFreshSigningSecrets() {
return (secret: SigningSecret) => !secret.rotatedAt;
}
/**
* filterActiveSecrets is a filter function that can be used to filter only
* secrets that are active.
*
* @param now the current date
*/
export function filterActiveSigningSecrets(now: Date) {
return (secret: SigningSecret) => !isSigningSecretExpired(secret, now);
}
/**
* generateSignature will generate a signature used to assist clients to
* validate that the request came from Coral.
*
* @param key the secret used to sign the body with
* @param data the data to sign
*/
function generateSignature(key: string, data: string) {
return crypto.createHmac("sha256", key).update(data).digest().toString("hex");
}
/**
* generateSignatures will return a header value that can be used to verify the
* integrity and authenticity of a payload sent from Coral.
*
* @param signingSecrets the secrets that should be used to sign the data with
* @param data the data to sign
* @param now the current date
*/
export function generateSignatures(
signingSecrets: SigningSecret[],
data: string,
now: Date
) {
// For each of the signatures, we only want to sign the body with secrets that
// are still active.
return signingSecrets
.filter(filterActiveSigningSecrets(now))
.map(({ secret }) => generateSignature(secret, data))
.map((signature) => `sha256=${signature}`)
.join(",");
}
@@ -0,0 +1,2 @@
export * from "./helpers";
export * from "./signingSecret";
@@ -0,0 +1,287 @@
import { get } from "lodash";
import { Collection, FindOneAndUpdateOption, UpdateQuery } from "mongodb";
import logger from "coral-server/logger";
import { FilterQuery } from "coral-server/models/helpers";
import { filterFreshSigningSecrets, generateSigningSecret } from "./helpers";
export interface SigningSecret {
/**
* 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: Date;
/**
* rotatedAt is the time that the token was rotated out.
*/
rotatedAt?: Date;
/**
* inactiveAt is the date that the token can no longer be used to validate
* tokens.
*/
inactiveAt?: Date;
}
/**
* SigningSecretResource is a resource that contains signing credentials.
*/
export interface SigningSecretResource {
/**
* signingSecrets are secrets used for signing and verification.
*/
signingSecrets: SigningSecret[];
}
interface SigningSecretResourceNode extends SigningSecretResource {
id: string;
}
interface RotateSigningSecretOptions<T extends {}> {
/**
* collection is the database collection that the document exists in.
*/
collection: Collection<Readonly<T>>;
/**
* filter is the database query used to filter out the specific document that
* contains a SigningResource.
*/
filter: FilterQuery<T>;
/**
* path is the dot notation path of the resource that contains the signing
* secrets.
*/
path: string;
/**
* prefix is the secret prefix that will be added to the new secret that is
* generated.
*/
prefix: string;
/**
* id is the identifier used when querying for the resource identified by the
* `path` parameter above.
*/
id?: string;
/**
* inactiveAt is the date that the previous keys should be active for.
*/
inactiveAt: Date;
/**
* now is the current date.
*/
now: Date;
}
/**
* getSecretKIDsToDeprecate will return all the keys that should be deprecated
* from the first phase of the rolling process.
*
* @param signingSecrets the keys returned by the query operation
*/
const getSecretKIDsToDeprecate = (signingSecrets: SigningSecret[]) =>
signingSecrets
// By excluding the last one (the one we just pushed)...
.splice(0, signingSecrets.length - 1)
// And only finding keys that have not been rotated yet.
.filter(filterFreshSigningSecrets())
// And get their kid's.
.map((s) => s.kid);
async function pushNewSigningSecret<T extends {}>({
collection,
filter,
path,
prefix,
id,
now,
}: RotateSigningSecretOptions<T>) {
// Create the new secret.
const secret = generateSigningSecret(prefix, now);
// Generate the update for the operation.
let update: UpdateQuery<T>;
if (id) {
update = {
$push: {
[`${path}.$[resource].signingSecrets`]: secret,
},
};
} else {
update = {
$push: {
[`${path}.signingSecrets`]: secret,
},
};
}
// Generate the options for the operation.
const options: FindOneAndUpdateOption = {
// False to return the updated document instead of the original
// document.
returnOriginal: false,
};
if (id) {
options.arrayFilters = [
// Select the secret resource under the object.
{ "resource.id": id },
];
}
// Update the resource with this new secret.
const result = await collection.findOneAndUpdate(filter, update, options);
if (!result.value) {
return null;
}
return result.value;
}
function getResourceFromDoc<T extends {}>(
{ id, path }: Pick<RotateSigningSecretOptions<T>, "id" | "path">,
doc: T
) {
if (id) {
// The ID was provided, so we need to get the referenced resource by it's
// ID. This also means that the resource indicated exists in an Array, so
// treat it as such.
// Get the resource referenced by the path (should be a path to an array).
const resources: SigningSecretResourceNode[] | undefined = get(doc, path);
if (!resources || !Array.isArray(resources)) {
return null;
}
// Get the specific resource from the array.
const resource = resources.find((r) => r.id === id);
if (!resource) {
return null;
}
return resource;
}
// The ID was not provided, which means that the resource is not in an array.
const resource: SigningSecretResource | undefined = get(doc, path);
if (!resource) {
if (Array.isArray(resource)) {
throw new Error("we were not passed an ID but got an array anyways");
}
return null;
}
return resource;
}
async function deprecateOldSigningSecrets<T extends {}>(
{
collection,
path,
inactiveAt,
filter,
id,
now,
}: Pick<
RotateSigningSecretOptions<T>,
"collection" | "path" | "inactiveAt" | "filter" | "id" | "now"
>,
doc: Readonly<T>
) {
// Get the resource from the value.
const resource = getResourceFromDoc({ id, path }, doc);
if (!resource) {
return null;
}
// Get the secrets we need to deactivate...
const secretKIDsToDeprecate = getSecretKIDsToDeprecate(
resource.signingSecrets
);
if (secretKIDsToDeprecate.length === 0) {
return doc;
}
logger.trace(
{ kids: secretKIDsToDeprecate, filter, arrayFilter: { id: id || null } },
"deprecating old signingSecrets"
);
// Construct the update operation for rotating the secret.
let update: UpdateQuery<T>;
if (id) {
update = {
$set: {
[`${path}.$[resource].signingSecrets.$[signingSecret].inactiveAt`]: inactiveAt,
[`${path}.$[resource].signingSecrets.$[signingSecret].rotatedAt`]: now,
},
};
} else {
update = {
$set: {
[`${path}.signingSecrets.$[signingSecret].inactiveAt`]: inactiveAt,
[`${path}.signingSecrets.$[signingSecret].rotatedAt`]: now,
},
};
}
// Construct the options for the operation.
const options: FindOneAndUpdateOption = {
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [
// Select any signing secrets with the given ids.
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
],
};
if (id) {
options.arrayFilters!.push(
// Select the secret resource under the object.
{ "resource.id": id }
);
}
// Deactivate the old keys.
const result = await collection.findOneAndUpdate(filter, update, options);
if (!result.value) {
return null;
}
return result.value;
}
export async function rotateSigningSecret<T extends {}>(
options: RotateSigningSecretOptions<T>
) {
// Push the new secret into the resource and return it.
let doc = await pushNewSigningSecret(options);
if (!doc) {
return null;
}
// Deprecate any old secrets on the document.
doc = await deprecateOldSigningSecrets(options, doc);
if (!doc) {
return null;
}
return doc;
}
@@ -0,0 +1,215 @@
import { isEmpty } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid/v4";
import { dotize } from "coral-common/utils/dotize";
import { tenants as collection } from "coral-server/services/mongodb/collections";
import { GQLCOMMENT_BODY_FORMAT } from "coral-server/graph/schema/__generated__/types";
import {
ExternalModerationPhase,
generateSigningSecret,
getExternalModerationPhase,
rotateSigningSecret,
} from "../settings";
import { retrieveTenant } from "./tenant";
export async function rotateTenantExternalModerationPhaseSigningSecret(
mongo: Db,
id: string,
phaseID: string,
inactiveAt: Date,
now: Date
) {
return rotateSigningSecret({
collection: collection(mongo),
filter: { id },
path: "integrations.external.phases",
id: phaseID,
prefix: "empsec",
inactiveAt,
now,
});
}
export interface CreateTenantExternalModerationPhaseInput {
name: string;
url: string;
format: GQLCOMMENT_BODY_FORMAT;
timeout: number;
}
export async function createTenantExternalModerationPhase(
mongo: Db,
id: string,
input: CreateTenantExternalModerationPhaseInput,
now: Date
) {
// Create the new phase.
const phase: ExternalModerationPhase = {
...input,
id: uuid(),
enabled: true,
signingSecrets: [generateSigningSecret("empsec", now)],
createdAt: now,
};
// Update the Tenant with this new phase.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $push: { "integrations.external.phases": phase } },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return {
phase: null,
tenant: null,
};
}
throw new Error("update failed for an unexpected reason");
}
return {
phase,
tenant: result.value,
};
}
export interface UpdateTenantExternalModerationPhaseInput {
name?: string;
enabled?: boolean;
url?: string;
format?: GQLCOMMENT_BODY_FORMAT;
timeout?: number;
}
export async function updateTenantExternalModerationPhase(
mongo: Db,
id: string,
phaseID: string,
update: UpdateTenantExternalModerationPhaseInput
) {
const $set = dotize(
{ "integrations.external.phases.$[phase]": update },
{ embedArrays: true }
);
// Check to see if there is any updates that will be made.
if (isEmpty($set)) {
// No updates need to be made, abort here and just return the tenant.
return retrieveTenant(mongo, id);
}
// Perform the actual update operation.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $set },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [{ "phase.id": phaseID }],
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
if (!tenant.integrations.external) {
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
}
const endpoint = getExternalModerationPhase(
tenant.integrations.external,
phaseID
);
if (!endpoint) {
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteTenantExternalModerationPhase(
mongo: Db,
id: string,
phaseID: string
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"integrations.external.phases": { id: phaseID },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteTenantExternalModerationPhaseSigningSecrets(
mongo: Db,
id: string,
phaseID: string,
kids: string[]
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"integrations.external.phases.$[phase].signingSecrets": {
kid: { $in: kids },
},
},
},
{ returnOriginal: false, arrayFilters: [{ "phase.id": phaseID }] }
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
if (!tenant.integrations.external) {
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
}
const endpoint = getExternalModerationPhase(
tenant.integrations.external,
phaseID
);
if (!endpoint) {
throw new Error(`phase not found with id: ${phaseID} on tenant: ${id}`);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
-19
View File
@@ -1,5 +1,4 @@
import { FluentBundle } from "@fluent/bundle/compat";
import crypto from "crypto";
import { translate } from "coral-server/services/i18n";
@@ -10,7 +9,6 @@ import {
GQLStaffConfiguration,
} from "coral-server/graph/schema/__generated__/types";
import { Secret } from "../settings";
import { Tenant } from "./tenant";
export const getDefaultReactionConfiguration = (
@@ -34,23 +32,6 @@ export const getDefaultStaffConfiguration = (
label: translate(bundle, "Staff", "staff-label"),
});
export function generateRandomString(size: number, drift = 5) {
return crypto
.randomBytes(size + Math.floor(Math.random() * drift))
.toString("hex");
}
export function generateSecret(prefix: string, createdAt: Date): Secret {
// Generate a new key. We generate a key of minimum length 32 up to 37 bytes,
// as 16 was the minimum length recommended.
//
// Reference: https://security.stackexchange.com/a/96176
const secret = prefix + "_" + generateRandomString(32, 5);
const kid = generateRandomString(8, 3);
return { kid, secret, createdAt };
}
/**
* hasFeatureFlag will check to see if the Tenant has a particular feature flag
* enabled.
+3
View File
@@ -1,2 +1,5 @@
export * from "./tenant";
export * from "./helpers";
export * from "./externalModerationPhase";
export * from "./webhookEndpoint";
export * from "./sso";
+71
View File
@@ -0,0 +1,71 @@
import { Db } from "mongodb";
import { rotateSigningSecret } from "coral-server/models/settings";
import { tenants as collection } from "coral-server/services/mongodb/collections";
export async function rotateTenantSSOSigningSecret(
mongo: Db,
id: string,
inactiveAt: Date,
now: Date
) {
return rotateSigningSecret({
collection: collection(mongo),
filter: { id },
path: "auth.integrations.sso",
prefix: "ssosec",
inactiveAt,
now,
});
}
export async function deactivateTenantSSOSigningSecret(
mongo: Db,
id: string,
kid: string,
inactiveAt: Date,
now: Date
) {
// Update the tenant.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$set: {
"auth.integrations.sso.keys.$[keys].inactiveAt": inactiveAt,
"auth.integrations.sso.keys.$[keys].rotatedAt": now,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
// Add an ArrayFilter to only update one of the keys.
arrayFilters: [{ "keys.kid": kid }],
}
);
return result.value || null;
}
export async function deleteTenantSSOSigningSecret(
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;
}
+7 -386
View File
@@ -1,4 +1,3 @@
import { Redis } from "ioredis";
import { isEmpty } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
@@ -10,7 +9,11 @@ import TIME from "coral-common/time";
import { DeepPartial, 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 {
generateSigningSecret,
Settings,
SigningSecretResource,
} from "coral-server/models/settings";
import { I18n } from "coral-server/services/i18n";
import { tenants as collection } from "coral-server/services/mongodb/collections";
@@ -22,12 +25,9 @@ import {
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import { Secret, Settings } from "../settings";
import {
generateSecret,
getDefaultReactionConfiguration,
getDefaultStaffConfiguration,
getWebhookEndpoint,
} from "./helpers";
/**
@@ -42,7 +42,7 @@ export interface TenantResource {
readonly tenantID: string;
}
export interface Endpoint {
export interface Endpoint extends SigningSecretResource {
/**
* id is the unique identifier for this specific endpoint.
*/
@@ -58,11 +58,6 @@ export interface Endpoint {
*/
url: string;
/**
* signingSecret is the secret used to sign the events sent out.
*/
signingSecrets: Secret[];
/**
* all when true indicates that all events should trigger.
*/
@@ -198,7 +193,7 @@ export async function createTenant(
stream: true,
},
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
keys: [generateSecret("ssosec", now)],
signingSecrets: [generateSigningSecret("ssosec", now)],
},
oidc: {
enabled: false,
@@ -360,59 +355,6 @@ export async function updateTenant(
return result.value || null;
}
/**
* regenerateTenantSSOKey will regenerate the SSO key used for Single Sing-On
* for the specified Tenant. All existing user sessions signed with the old
* secret will be invalidated.
*/
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
// Construct the new key.
const key = generateSecret("ssosec", now);
// Update the Tenant with this new key.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$push: {
"auth.integrations.sso.keys": key,
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
return result.value || null;
}
export async function deactivateTenantSSOKey(
mongo: Db,
id: string,
kid: string,
inactiveAt: Date,
now: Date
) {
// Update the tenant.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$set: {
"auth.integrations.sso.keys.$[keys].inactiveAt": inactiveAt,
"auth.integrations.sso.keys.$[keys].rotatedAt": now,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
// Add an ArrayFilter to only update one of the keys.
arrayFilters: [{ "keys.kid": kid }],
}
);
return result.value || null;
}
export async function enableTenantFeatureFlag(
mongo: Db,
id: string,
@@ -460,24 +402,6 @@ 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;
@@ -542,306 +466,3 @@ export function retrieveAnnouncementIfEnabled(
}
return null;
}
export async function rollTenantWebhookEndpointSecret(
mongo: Db,
id: string,
endpointID: string,
inactiveAt: Date,
now: Date
) {
// Create the new secret.
const secret = generateSecret("whsec", now);
// Update the Tenant with this new secret.
let result = await collection(mongo).findOneAndUpdate(
{ id },
{
$push: { "webhooks.endpoints.$[endpoint].signingSecrets": secret },
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [
// Select the endpoint we're updating.
{ "endpoint.id": endpointID },
],
}
);
if (!result.value) {
return null;
}
// Grab the endpoint we just modified.
const endpoint = getWebhookEndpoint(result.value, endpointID);
if (!endpoint) {
return null;
}
// Get the secrets we need to deactivate...
const secretKIDsToDeprecate = endpoint.signingSecrets
// By excluding the last one (the one we just pushed)...
.splice(0, endpoint.signingSecrets.length - 1)
// And only finding keys that have not been rotated yet.
.filter((s) => !s.rotatedAt)
// And get their kid's.
.map((s) => s.kid);
if (secretKIDsToDeprecate.length > 0) {
logger.trace(
{ kids: secretKIDsToDeprecate },
"deprecating old signingSecrets"
);
// Deactivate the old keys.
result = await collection(mongo).findOneAndUpdate(
{ id },
{
$set: {
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].inactiveAt": inactiveAt,
"webhooks.endpoints.$[endpoint].signingSecrets.$[signingSecret].rotatedAt": now,
},
},
{
arrayFilters: [
// Select the endpoint we're updating.
{ "endpoint.id": endpointID },
// Select any signing secrets with the given ids.
{ "signingSecret.kid": { $in: secretKIDsToDeprecate } },
],
}
);
}
return result.value;
}
export interface CreateTenantWebhookEndpointInput {
url: string;
all: boolean;
events: GQLWEBHOOK_EVENT_NAME[];
}
export async function createTenantWebhookEndpoint(
mongo: Db,
id: string,
input: CreateTenantWebhookEndpointInput,
now: Date
) {
// Create the new endpoint.
const endpoint: Endpoint = {
...input,
id: uuid(),
enabled: true,
signingSecrets: [generateSecret("whsec", now)],
createdAt: now,
};
// Update the Tenant with this new endpoint.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $push: { "webhooks.endpoints": endpoint } },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return {
endpoint: null,
tenant: null,
};
}
throw new Error("update failed for an unexpected reason");
}
return {
endpoint,
tenant: result.value,
};
}
export interface UpdateTenantWebhookEndpointInput {
enabled?: boolean;
url?: string;
all?: boolean;
events?: GQLWEBHOOK_EVENT_NAME[];
}
export async function updateTenantWebhookEndpoint(
mongo: Db,
id: string,
endpointID: string,
update: UpdateTenantWebhookEndpointInput
) {
const $set = dotize(
{ "webhooks.endpoints.$[endpoint]": update },
{ embedArrays: true }
);
// Check to see if there is any updates that will be made.
if (isEmpty($set)) {
// No updates need to be made, abort here and just return the tenant.
return retrieveTenant(mongo, id);
}
// Perform the actual update operation.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $set },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [{ "endpoint.id": endpointID }],
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error(
`endpoint not found with id: ${endpointID} on tenant: ${id}`
);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteEndpointSecrets(
mongo: Db,
id: string,
endpointID: string,
kids: string[]
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
},
},
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error(
`endpoint not found with id: ${endpointID} on tenant: ${id}`
);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteTenantWebhookEndpoint(
mongo: Db,
id: string,
endpointID: string
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"webhooks.endpoints": { id: endpointID },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
throw new Error("update failed for an unexpected reason");
}
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);
});
}
@@ -0,0 +1,265 @@
import { Redis } from "ioredis";
import { isEmpty } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid/v4";
import { dotize } from "coral-common/utils/dotize";
import { tenants as collection } from "coral-server/services/mongodb/collections";
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
import { generateSigningSecret, rotateSigningSecret } from "../settings";
import { getWebhookEndpoint } from "./helpers";
import { Endpoint, retrieveTenant } from "./tenant";
export async function rotateTenantWebhookEndpointSigningSecret(
mongo: Db,
id: string,
endpointID: string,
inactiveAt: Date,
now: Date
) {
return rotateSigningSecret({
collection: collection(mongo),
filter: { id },
path: "webhooks.endpoints",
prefix: "whsec",
id: endpointID,
inactiveAt,
now,
});
}
export interface CreateTenantWebhookEndpointInput {
url: string;
all: boolean;
events: GQLWEBHOOK_EVENT_NAME[];
}
export async function createTenantWebhookEndpoint(
mongo: Db,
id: string,
input: CreateTenantWebhookEndpointInput,
now: Date
) {
// Create the new endpoint.
const endpoint: Endpoint = {
...input,
id: uuid(),
enabled: true,
signingSecrets: [generateSigningSecret("whsec", now)],
createdAt: now,
};
// Update the Tenant with this new endpoint.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $push: { "webhooks.endpoints": endpoint } },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return {
endpoint: null,
tenant: null,
};
}
throw new Error("update failed for an unexpected reason");
}
return {
endpoint,
tenant: result.value,
};
}
export interface UpdateTenantWebhookEndpointInput {
enabled?: boolean;
url?: string;
all?: boolean;
events?: GQLWEBHOOK_EVENT_NAME[];
}
export async function updateTenantWebhookEndpoint(
mongo: Db,
id: string,
endpointID: string,
update: UpdateTenantWebhookEndpointInput
) {
const $set = dotize(
{ "webhooks.endpoints.$[endpoint]": update },
{ embedArrays: true }
);
// Check to see if there is any updates that will be made.
if (isEmpty($set)) {
// No updates need to be made, abort here and just return the tenant.
return retrieveTenant(mongo, id);
}
// Perform the actual update operation.
const result = await collection(mongo).findOneAndUpdate(
{ id },
{ $set },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
arrayFilters: [{ "endpoint.id": endpointID }],
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error(
`endpoint not found with id: ${endpointID} on tenant: ${id}`
);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteTenantWebhookEndpointSigningSecrets(
mongo: Db,
id: string,
endpointID: string,
kids: string[]
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"webhooks.endpoints.$[endpoint].signingSecrets": { kid: { $in: kids } },
},
},
{ returnOriginal: false, arrayFilters: [{ "endpoint.id": endpointID }] }
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
const endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error(
`endpoint not found with id: ${endpointID} on tenant: ${id}`
);
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
export async function deleteTenantWebhookEndpoint(
mongo: Db,
id: string,
endpointID: string
) {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: {
"webhooks.endpoints": { id: endpointID },
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const tenant = await retrieveTenant(mongo, id);
if (!tenant) {
return null;
}
throw new Error("update failed for an unexpected reason");
}
return result.value;
}
function lastUsedAtTenantSSOSigningSecret(id: string): string {
return `${id}:lastUsedSSOSigningSecret`;
}
/**
* updateLastUsedAtTenantSSOSigningSecret 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 updateLastUsedAtTenantSSOSigningSecret(
redis: Redis,
id: string,
kid: string,
when: Date
) {
await redis.hset(
lastUsedAtTenantSSOSigningSecret(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 deleteLastUsedAtTenantSSOSigningSecret(
redis: Redis,
id: string,
kid: string
) {
await redis.hdel(lastUsedAtTenantSSOSigningSecret(id), kid);
}
/**
* retrieveLastUsedAtTenantSSOSigningSecrets 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 retrieveLastUsedAtTenantSSOSigningSecrets(
redis: Redis,
id: string,
kids: string[]
) {
const results: Array<string | null> = await redis.hmget(
lastUsedAtTenantSSOSigningSecret(id),
...kids
);
return results.map((lastUsedAt) => {
if (!lastUsedAt) {
return null;
}
return new Date(lastUsedAt);
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ import { Config } from "coral-server/config";
import { I18n } from "coral-server/services/i18n";
import { JWTSigningConfig } from "coral-server/services/jwt";
import { AugmentedRedis, createRedisClient } from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { createMailerTask, MailerQueue } from "./tasks/mailer";
import { createNotifierTask, NotifierQueue } from "./tasks/notifier";
@@ -3,8 +3,10 @@ import nunjucks, { Environment, ILoader } from "nunjucks";
import path from "path";
import { Config } from "coral-server/config";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import {
TenantCache,
TenantCacheAdapter,
} from "coral-server/services/tenant/cache";
import { Tenant } from "coral-server/models/tenant";
import { EmailTemplate } from "./templates";
+1 -1
View File
@@ -4,7 +4,7 @@ import { createTimer } from "coral-server/helpers";
import logger from "coral-server/logger";
import Task from "coral-server/queue/Task";
import MailerContent from "coral-server/queue/tasks/mailer/content";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import {
createJobProcessor,
@@ -12,15 +12,17 @@ import { Db } from "mongodb";
import { createTransport } from "nodemailer";
import { Options } from "nodemailer/lib/smtp-connection";
import { LanguageCode } from "coral-common/helpers/i18n/locales";
import { LanguageCode } from "coral-common/helpers";
import { Config } from "coral-server/config";
import { InternalError } from "coral-server/errors";
import { WrappedInternalError } from "coral-server/errors";
import { createTimer } from "coral-server/helpers";
import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import { I18n, translate } from "coral-server/services/i18n";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCacheAdapter } from "coral-server/services/tenant/cache/adapter";
import {
TenantCache,
TenantCacheAdapter,
} from "coral-server/services/tenant/cache";
export const JOB_NAME = "mailer";
@@ -254,7 +256,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
data
);
} catch (e) {
throw new InternalError(e, "could not translate the message");
throw new WrappedInternalError(e, "could not translate the message");
}
log.trace(
@@ -284,7 +286,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
// Create the transport based on the smtp uri.
transport = createTransport(opts);
} catch (e) {
throw new InternalError(e, "could not create email transport");
throw new WrappedInternalError(e, "could not create email transport");
}
// Set the transport back into the cache.
@@ -303,7 +305,7 @@ export const createJobProcessor = (options: MailProcessorOptions) => {
// Send the mail message.
await transport.sendMail(message);
} catch (e) {
throw new InternalError(e, "could not send email");
throw new WrappedInternalError(e, "could not send email");
}
log.debug({ responseTime: messageSendTimer() }, "sent the email");
@@ -10,7 +10,7 @@ import {
categories,
NotificationCategory,
} from "coral-server/services/notifications/categories";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { createJobProcessor, JOB_NAME, NotifierData } from "./processor";
@@ -10,7 +10,7 @@ import { JWTSigningConfig } from "coral-server/services/jwt";
import { NotificationCategory } from "coral-server/services/notifications/categories";
import NotificationContext from "coral-server/services/notifications/context";
import { Notification } from "coral-server/services/notifications/notification";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import {
filterSuperseded,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
import { Connection } from "coral-server/models/helpers";
import Task from "coral-server/queue/Task";
import { AugmentedRedis } from "coral-server/services/redis";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import { rejectComment } from "coral-server/stacks";
import { GQLCOMMENT_SORT } from "coral-server/graph/schema/__generated__/types";
@@ -1,4 +1,3 @@
import crypto from "crypto";
import { Redis } from "ioredis";
import { Db } from "mongodb";
@@ -6,19 +5,15 @@ import { Config } from "coral-server/config";
import { CoralEventPayload } from "coral-server/events/event";
import { createTimer } from "coral-server/helpers";
import logger from "coral-server/logger";
import { filterExpiredSigningSecrets } from "coral-server/models/settings";
import {
filterActiveSecrets,
filterExpiredSecrets,
} from "coral-server/models/settings";
import {
deleteEndpointSecrets,
Endpoint,
deleteTenantWebhookEndpointSigningSecrets,
getWebhookEndpoint,
} from "coral-server/models/tenant";
import { JobProcessor } from "coral-server/queue/Task";
import { createFetch, FetchOptions } from "coral-server/services/fetch";
import { createFetch, generateFetchOptions } from "coral-server/services/fetch";
import { disableWebhookEndpoint } from "coral-server/services/tenant";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
export const JOB_NAME = "webhook";
@@ -53,36 +48,7 @@ export interface WebhookDelivery {
createdAt: Date;
}
/**
* generateSignature will generate a signature used to assist clients to
* validate that the request came from Coral.
*
* @param secret the secret used to sign the body with
* @param body the body to use when signing
*/
export function generateSignature(secret: string, body: string) {
return crypto
.createHmac("sha256", secret)
.update(body)
.digest()
.toString("hex");
}
export function generateSignatures(
endpoint: Pick<Endpoint, "signingSecrets">,
body: string,
now: Date
) {
// For each of the signatures, we only want to sign the body with secrets that
// are still active.
return endpoint.signingSecrets
.filter(filterActiveSecrets(now))
.map(({ secret }) => generateSignature(secret, body))
.map((signature) => `sha256=${signature}`)
.join(",");
}
type CoralWebhookEventPayload = CoralEventPayload & {
interface CoralWebhookEventPayload extends CoralEventPayload {
/**
* tenantID is the ID of the Tenant that this event originated at.
*/
@@ -92,28 +58,6 @@ type CoralWebhookEventPayload = CoralEventPayload & {
* tenantDomain is the domain that is associated with this Tenant that this event originated at.
*/
readonly tenantDomain: string;
};
export function generateFetchOptions(
endpoint: Pick<Endpoint, "signingSecrets">,
data: CoralWebhookEventPayload,
now: Date
): FetchOptions {
// Serialize the body and signature to include in the request.
const body = JSON.stringify(data, null, 2);
const signature = generateSignatures(endpoint, body, now);
const headers: Record<string, any> = {
"Content-Type": "application/json",
"X-Coral-Event": data.type,
"X-Coral-Signature": signature,
};
return {
method: "POST",
headers,
body,
};
}
export function createJobProcessor({
@@ -162,12 +106,21 @@ export function createJobProcessor({
// Get the current date.
const now = new Date();
// Generate the payload.
const payload: CoralWebhookEventPayload = {
...event,
tenantID,
tenantDomain: tenant.domain,
};
// Get the fetch options.
const options = generateFetchOptions(
endpoint,
{ ...event, tenantID, tenantDomain: tenant.domain },
now
);
const options = generateFetchOptions(endpoint.signingSecrets, payload, now);
// Add the X-Coral-Event header.
options.headers = {
...options.headers,
"X-Coral-Event": event.type,
};
// Send the request.
const timer = createTimer();
@@ -240,35 +193,34 @@ export function createJobProcessor({
tenant,
endpointID
);
} else {
// TODO: (wyattjoh) maybe schedule a retry?
}
// TODO: (wyattjoh) maybe schedule a retry?
}
// Remove the expired secrets in the next tick so that it does not affect
// the sending performance of this job, and errors do not impact the
// sending.
const expiredSigningSecrets = endpoint.signingSecrets.filter(
filterExpiredSecrets(now)
);
if (expiredSigningSecrets.length > 0) {
const expiredSigningSecretKIDs = endpoint.signingSecrets
.filter(filterExpiredSigningSecrets(now))
.map((s) => s.kid);
if (expiredSigningSecretKIDs.length > 0) {
process.nextTick(() => {
deleteEndpointSecrets(
deleteTenantWebhookEndpointSigningSecrets(
mongo,
tenantID,
endpoint.id,
expiredSigningSecrets.map((s) => s.kid)
expiredSigningSecretKIDs
)
.then(() => {
log.info(
{ secrets: expiredSigningSecrets.length },
{ endpointID: endpoint.id, kids: expiredSigningSecretKIDs },
"removed expired secrets from endpoint"
);
})
.catch((err) => {
log.error(
{ err },
"an error occurred when trying to remove expired secrets"
"an error occurred when trying to remove expired endpoint secrets"
);
});
});
@@ -0,0 +1,43 @@
import { PhaseResult } from "./pipeline";
export function mergePhaseResult(
result: Partial<PhaseResult>,
final: Partial<PhaseResult>
) {
const { actions = [], tags = [], metadata = {} } = final;
// If this result contained actions, then we should push it into the
// other actions.
if (result.actions) {
final.actions = [...actions, ...result.actions];
}
// If this result contained metadata, then we should merge it into the
// other metadata.
if (result.metadata) {
final.metadata = { ...metadata, ...result.metadata };
}
// If the result modified the comment body, we should replace it.
if (result.body) {
final.body = result.body;
}
// If the result added any tags, we should push it into the existing tags.
if (result.tags && result.tags.length > 0) {
final.tags = [
...tags,
// Only push in tags that we haven't already added.
...result.tags.filter((tag) => !tags.includes(tag)),
];
}
// If this result contained a status, then we've finished resolving
// phases!
if (result.status) {
final.status = result.status;
return true;
}
return false;
}
@@ -20,9 +20,7 @@ export const approve: IntermediateModerationPhase = ({
// automatically approved. We will only see EXPERT
// tags assigned when we are in Q&A mode, so we can
// trust this simple tag type check.
if (
tags.some((tag) => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT)
) {
if (tags.includes(GQLTAG.STAFF) || tags.includes(GQLTAG.EXPERT)) {
return {
status: GQLCOMMENT_STATUS.APPROVED,
};
@@ -32,7 +32,6 @@ export const detectLinks: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_LINKS,
},
@@ -0,0 +1,82 @@
import { validateResponse } from "./external";
describe("validateResponse", () => {
it("allows an empty response", () => {
expect(validateResponse({})).toEqual({});
});
it("allows a valid status response", () => {
expect(validateResponse({ status: "NONE" })).toEqual({ status: "NONE" });
expect(validateResponse({ status: "APPROVED" })).toEqual({
status: "APPROVED",
});
expect(validateResponse({ status: "REJECTED" })).toEqual({
status: "REJECTED",
});
expect(validateResponse({ status: "SYSTEM_WITHHELD" })).toEqual({
status: "SYSTEM_WITHHELD",
});
});
it("allows a valid tag response", () => {
expect(validateResponse({ tags: ["FEATURED"] })).toEqual({
tags: ["FEATURED"],
});
expect(validateResponse({ tags: ["STAFF"] })).toEqual({
tags: ["STAFF"],
});
});
it("allows and strips unknown fields from the response", () => {
expect(validateResponse({ willFail: false })).toEqual({});
});
it("allows a valid action response", () => {
expect(
validateResponse({
actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_SPAM" }],
})
).toEqual({
actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_SPAM" }],
});
});
it("allows a valid action response and filters undefined fields", () => {
expect(
validateResponse({
actions: [
{
actionType: "FLAG",
reason: "COMMENT_DETECTED_TOXIC",
additionalDetails: "This is additional details",
metadata: { this: { is: { a: { deep: "object" } } } },
},
],
})
).toEqual({
actions: [
{
actionType: "FLAG",
reason: "COMMENT_DETECTED_TOXIC",
},
],
});
});
it("disallows incorrect reasons in the actions from the response", () => {
expect(() =>
validateResponse({
actions: [{ actionType: "FLAG", reason: "COMMENT_DETECTED_NOT_REAL" }],
})
).toThrow();
expect(() =>
validateResponse({
actions: [
{ actionType: "ALSO_NOT_REAL", reason: "COMMENT_DETECTED_NOT_REAL" },
],
})
).toThrow();
});
it("disallows incorrect tag types in the tags from the response", () => {
expect(() => validateResponse({ tags: ["NOT_REAL"] })).toThrow();
expect(() => validateResponse({ tags: [""] })).toThrow();
});
it("disallows incorrect values in the status response", () => {
expect(() => validateResponse({ status: "fail" })).toThrow();
expect(() => validateResponse({ status: "approved" })).toThrow();
});
});
@@ -0,0 +1,316 @@
import Joi from "@hapi/joi";
import { ACTION_TYPE } from "coral-server/models/action/comment";
import {
ExternalModerationPhase,
filterActivePhase,
filterExpiredSigningSecrets,
} from "coral-server/models/settings";
import { deleteTenantExternalModerationPhaseSigningSecrets } from "coral-server/models/tenant";
import {
IntermediateModerationPhase,
PhaseResult,
} from "coral-server/services/comments/pipeline";
import { createFetch, generateFetchOptions } from "coral-server/services/fetch";
import {
GQLCOMMENT_BODY_FORMAT,
GQLCOMMENT_FLAG_DETECTED_REASON,
GQLCOMMENT_STATUS,
GQLTAG,
GQLUSER_ROLE,
} from "coral-server/graph/schema/__generated__/types";
import { mergePhaseResult } from "../helpers";
import { IntermediateModerationPhaseContext } from "../pipeline";
export interface ExternalModerationRequest {
/**
* action refers to the specific operation being performed. If `NEW`, this
* is referring to a new comment being created. If `EDIT`, then this refers to
* an operation involving an edit operation on an existing Comment.
*/
action: "NEW" | "EDIT";
/**
* comment refers to the actual Comment data for the Comment being
* created/edited.
*/
comment: {
/**
* body refers to the actual body text of the Comment being created/edited.
*/
body: string;
/**
* parentID is the identifier for the parent comment (if this Comment is a
* reply, null otherwise).
*/
parentID: string | null;
};
/**
* author refers to the User that is creating/editing the Comment.
*/
author: {
/**
* id is the identifier for this User.
*/
id: string;
/**
* role refers to the role of this User.
*/
role: GQLUSER_ROLE;
};
/**
* story refers to the Story being commented on.
*/
story: {
/**
* id is the identifier for this Story.
*/
id: string;
/**
* url is the URL for this Story.
*/
url: string;
};
/**
* site refers to the Site that the story being commented on belongs to.
*/
site: {
/**
* id is the identifier for this Site.
*/
id: string;
};
/**
* tenantID is the identifer of the Tenant that this Comment is being
* created/edited on.
*/
tenantID: string;
/**
* tenantDomain is the domain that is associated with this Tenant that this
* Comment is being created/edited on.
*/
tenantDomain: string;
}
export type ExternalModerationResponse = Partial<
Pick<PhaseResult, "actions" | "status" | "tags">
>;
const ExternalModerationResponseSchema = Joi.object().keys({
actions: Joi.array().items(
Joi.object().keys({
actionType: Joi.string().only().allow(ACTION_TYPE.FLAG).required(),
reason: Joi.string()
.only()
.allow(
GQLCOMMENT_FLAG_DETECTED_REASON.COMMENT_DETECTED_TOXIC,
GQLCOMMENT_FLAG_DETECTED_REASON.COMMENT_DETECTED_SPAM
)
.required(),
})
),
status: Joi.string()
.only()
.allow(...Object.keys(GQLCOMMENT_STATUS)),
tags: Joi.array().items(
Joi.string().only().allow(GQLTAG.FEATURED, GQLTAG.STAFF).required()
),
});
/**
* validate will validate the `ExternalModerationResponse`.
*
* @param body the input body that is being coerced into an `ExternalModerationResponse`.
*/
export function validateResponse(body: object): ExternalModerationResponse {
const { value, error: err } = ExternalModerationResponseSchema.validate(
body,
{
stripUnknown: true,
presence: "optional",
abortEarly: false,
}
);
if (err) {
throw err;
}
return value;
}
const fetch = createFetch({ name: "Moderation" });
/**
* processPhase will execute the request for moderation for this particular
* phase.
*
* @param ctx the context for the moderation request.
* @param phase the current phase associated with this request.
*/
async function processPhase(
{
mongo,
action,
comment,
htmlStripped,
author,
tenant,
story,
now,
log,
}: IntermediateModerationPhaseContext,
phase: ExternalModerationPhase
) {
// Create the crafted input payload to be used.
const request: ExternalModerationRequest = {
action,
comment: {
body:
// Depending on the selected format, the comment body could be in an
// HTML or HTML stripped format.
phase.format === GQLCOMMENT_BODY_FORMAT.HTML
? comment.body
: htmlStripped,
// We're casting this to a `string | null` here because it's more
// actionable to get a `null` rather than an undefined value in a
// request.
parentID: comment.parentID || null,
},
author: {
id: author.id,
role: author.role,
},
story: {
id: story.id,
url: story.url,
},
site: {
id: story.siteID,
},
tenantID: tenant.id,
tenantDomain: tenant.domain,
};
// Craft the request options now to use.
const options = generateFetchOptions(phase.signingSecrets, request, now);
// Send off the request, with the correct timeout.
const res = await fetch(phase.url, {
...options,
timeout: phase.timeout,
});
if (!res.ok) {
// The phase did not respond correctly, continue.
log.warn(
{ status: res.status, phaseID: phase.id },
"failed to get moderation response"
);
return;
}
// Try to parse the response.
const text = await res.text();
// If the moderation phase responded 204, or there was no response from
// the request, just continue.
if (res.status === 204 || text === "" || text === "{}") {
log.debug(
{ status: res.status, phaseID: phase.id },
"empty response received"
);
return;
}
// Try to parse the response as JSON.
const body = JSON.parse(text);
// Remove the expired secrets in the next tick so that it does not affect
// the sending performance of this job, and errors do not impact the
// sending.
const expiredSigningSecretKIDs = phase.signingSecrets
.filter(filterExpiredSigningSecrets(now))
.map((s) => s.kid);
if (expiredSigningSecretKIDs.length > 0) {
process.nextTick(() => {
deleteTenantExternalModerationPhaseSigningSecrets(
mongo,
tenant.id,
phase.id,
expiredSigningSecretKIDs
)
.then(() => {
log.info(
{ phaseID: phase.id, kids: expiredSigningSecretKIDs },
"removed expired secrets from phase"
);
})
.catch((err) => {
log.error(
{ err },
"an error occurred when trying to remove expired phase secrets"
);
});
});
}
// Validate will throw an error if the body does not conform to the
// specification.
return validateResponse(body);
}
export const external: IntermediateModerationPhase = async (ctx) => {
// Check to see if any custom moderation phases have been defined, if there is
// none, exit now.
if (
!ctx.tenant.integrations.external ||
ctx.tenant.integrations.external.phases.length === 0
) {
return;
}
// Get the enabled phases.
const phases = ctx.tenant.integrations.external.phases.filter(
filterActivePhase()
);
if (phases.length === 0) {
return;
}
// Collect the response we're going to make into this partial object.
const result: Partial<PhaseResult> = {};
// Send the input to each of the phases.
for (const phase of phases) {
try {
// Get the response from the phase.
const response = await processPhase(ctx, phase);
if (!response) {
continue;
}
// Merge the results in. If we're finished, return now!
const finished = mergePhaseResult(response, result);
if (finished) {
return result;
}
} catch (err) {
ctx.log.error(
{ err, phaseID: phase.id },
"failed to process custom moderation phase"
);
}
}
return result;
};
@@ -4,6 +4,7 @@ import { approve } from "./approve";
import { commentingDisabled } from "./commentingDisabled";
import { commentLength } from "./commentLength";
import { detectLinks } from "./detectLinks";
import { external } from "./external";
import { linkify } from "./linkify";
import { preModerate } from "./preModerate";
import { premodUser } from "./preModerateUser";
@@ -41,4 +42,5 @@ export const moderationPhases: IntermediateModerationPhase[] = [
preModerate,
premodUser,
premodNewCommenter,
external,
];
@@ -30,7 +30,6 @@ export const premodNewCommenter = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_NEW_COMMENTER,
metadata: {
@@ -48,7 +48,6 @@ export const recentCommentHistory = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_RECENT_HISTORY,
metadata: {
@@ -63,7 +63,6 @@ export const repeatPost: IntermediateModerationPhase = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_REPEAT_POST,
},
@@ -105,7 +105,6 @@ export const spam: IntermediateModerationPhase = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SPAM,
},
@@ -9,16 +9,10 @@ import { GQLTAG } from "coral-server/graph/schema/__generated__/types";
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
author,
now,
}): IntermediatePhaseResult | void => {
if (hasStaffRole(author)) {
return {
tags: [
{
type: GQLTAG.STAFF,
createdAt: now,
},
],
tags: [GQLTAG.STAFF],
};
}
};
@@ -1,4 +1,4 @@
import { CommentTag } from "coral-server/models/comment/tag";
import { getDepth } from "coral-server/models/comment";
import {
IntermediateModerationPhase,
IntermediatePhaseResult,
@@ -11,7 +11,6 @@ import {
export const tagExpertAnswers: IntermediateModerationPhase = ({
author,
now,
story,
comment,
}): IntermediatePhaseResult | void => {
@@ -24,20 +23,12 @@ export const tagExpertAnswers: IntermediateModerationPhase = ({
story.settings.expertIDs.some((id) => id === author.id)
) {
// Assign this comment an expert tag!
const tags: CommentTag[] = [
{
type: GQLTAG.EXPERT,
createdAt: now,
},
];
const tags: GQLTAG[] = [GQLTAG.EXPERT];
// If this comment is the first reply in a thread (depth of 1)...
if (comment.ancestorIDs.length === 1) {
if (getDepth(comment) === 1) {
// Add the featured tag!
tags.push({
type: GQLTAG.FEATURED,
createdAt: now,
});
tags.push(GQLTAG.FEATURED);
}
return { tags };
@@ -31,12 +31,7 @@ export const tagUnansweredQuestions: IntermediateModerationPhase = ({
story.settings.expertIDs.every((id) => id !== comment.authorID)
) {
return {
tags: [
{
type: GQLTAG.UNANSWERED,
createdAt: now,
},
],
tags: [GQLTAG.UNANSWERED],
};
}
};
@@ -131,7 +131,6 @@ export const toxic: IntermediateModerationPhase = async ({
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_TOXIC,
},
@@ -35,7 +35,6 @@ export const wordList: IntermediateModerationPhase = ({
status: GQLCOMMENT_STATUS.REJECTED,
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_BANNED_WORD,
},
@@ -53,7 +52,6 @@ export const wordList: IntermediateModerationPhase = ({
return {
actions: [
{
userID: null,
actionType: ACTION_TYPE.FLAG,
reason: GQLCOMMENT_FLAG_REASON.COMMENT_DETECTED_SUSPECT_WORD,
},
@@ -9,28 +9,52 @@ import {
CreateCommentInput,
RevisionMetadata,
} from "coral-server/models/comment";
import { CommentTag } from "coral-server/models/comment/tag";
import { Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { AugmentedRedis } from "coral-server/services/redis";
import { Request } from "coral-server/types/express";
import { GQLCOMMENT_STATUS } from "coral-server/graph/schema/__generated__/types";
import {
GQLCOMMENT_STATUS,
GQLTAG,
} from "coral-server/graph/schema/__generated__/types";
import { mergePhaseResult } from "./helpers";
import { moderationPhases } from "./phases";
export type ModerationAction = Omit<
CreateActionInput,
"commentID" | "commentRevisionID" | "storyID" | "siteID"
"commentID" | "commentRevisionID" | "storyID" | "siteID" | "userID"
>;
export interface PhaseResult {
/**
* actions are moderation actions that are added to the comment revision.
*/
actions: ModerationAction[];
/**
* status when provided decides and terminates the moderation process by
* setting the status of the comment.
*/
status: GQLCOMMENT_STATUS;
/**
* metadata should be added to the comment revision when it is created/edited.
*/
metadata: RevisionMetadata;
/**
* body when returned should replace the comment body as it is currently.
*/
body: string;
tags: CommentTag[];
/**
* tags should be added to the comment when it is created. Tags are not added
* when a comment is edited.
*/
tags: GQLTAG[];
}
export interface ModerationPhaseContextInput {
@@ -64,7 +88,7 @@ export type IntermediatePhaseResult = Partial<PhaseResult> | void;
export interface IntermediateModerationPhaseContext
extends ModerationPhaseContext {
metadata: RevisionMetadata;
tags: CommentTag[];
tags: GQLTAG[];
}
export type IntermediateModerationPhase = (
@@ -109,46 +133,10 @@ export const compose = (
metadata: final.metadata,
});
if (result) {
// If this result contained actions, then we should push it into the
// other actions.
const { actions } = result;
if (actions) {
final.actions.push(...actions);
}
// If this result contained metadata, then we should merge it into the
// other metadata.
const { metadata } = result;
if (metadata) {
final.metadata = {
...final.metadata,
...metadata,
};
}
// If the result modified the comment body, we should replace it.
const { body } = result;
if (body) {
final.body = body;
}
// If the result added any tags, we should push it into the existing tags.
const { tags } = result;
if (tags && tags.length > 0) {
final.tags.push(
// Only push in tags that we haven't already added.
...tags.filter(
({ type }) => !final.tags.some((tag) => tag.type === type)
)
);
}
// If this result contained a status, then we've finished resolving
// phases!
const { status } = result;
if (status) {
final.status = status;
break;
// Merge the results in. If we're finished, break now!
const finished = mergePhaseResult(result, final);
if (finished) {
return final;
}
}
}
+2 -1
View File
@@ -11,6 +11,7 @@ import { CoralEventPublisherBroker } from "coral-server/events/publisher";
import {
Comment,
CommentModerationQueueCounts,
getDepth,
hasPublishedStatus,
} from "coral-server/models/comment";
@@ -42,7 +43,7 @@ export async function publishCommentReplyCreated(
broker: CoralEventPublisherBroker,
comment: Pick<Comment, "id" | "status" | "ancestorIDs">
) {
if (comment.ancestorIDs.length > 0 && hasPublishedStatus(comment)) {
if (getDepth(comment) > 0 && hasPublishedStatus(comment)) {
await CommentReplyCreatedCoralEvent.publish(broker, {
ancestorIDs: comment.ancestorIDs,
commentID: comment.id,
+51 -10
View File
@@ -5,20 +5,15 @@ import fetch, { RequestInit, Response } from "node-fetch";
import { URL } from "url";
import { version } from "coral-common/version";
import {
generateSignatures,
SigningSecret,
} from "coral-server/models/settings";
import abortAfter from "./abortAfter";
export type Fetch = (url: string, options?: FetchOptions) => Promise<Response>;
export interface CreateFetchOptions {
/**
* name is the string that is attached to the `User-Agent` header as:
*
* `Coral ${name}/${version}`
*/
name: string;
}
export type FetchOptions = RequestInit & {
/**
* timeout is the number of seconds that the request will wait for a response
@@ -27,7 +22,45 @@ export type FetchOptions = RequestInit & {
timeout?: number;
};
export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
export interface CreateFetchOptions {
/**
* name is the string that is attached to the `User-Agent` header as:
*
* `Coral ${name}/${version}`
*/
name: string;
/**
* options to provide defaults for requests made using this fetcher.
*/
options?: Omit<FetchOptions, "agent" | "body" | "signal">;
}
export function generateFetchOptions(
signingSecrets: SigningSecret[],
data: object,
now: Date
): FetchOptions {
// Serialize the body and signature to include in the request.
const body = JSON.stringify(data, null, 2);
const signature = generateSignatures(signingSecrets, body, now);
const headers: Record<string, any> = {
"Content-Type": "application/json",
"X-Coral-Signature": signature,
};
return {
method: "POST",
headers,
body,
};
}
export const createFetch = ({
name,
options: { headers: defaultBaseHeaders = {}, ...defaultOptions } = {},
}: CreateFetchOptions): Fetch => {
// Create HTTP agents to improve connection performance.
const agents = {
https: new https.Agent({
@@ -46,6 +79,7 @@ export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
// overridden).
const defaultHeaders = {
"User-Agent": `Coral ${capitalize(name)}/${version}`,
...defaultBaseHeaders,
};
// Return the actual fetcher that just uses fetch under the hood.
@@ -69,10 +103,17 @@ export const createFetch = ({ name }: CreateFetchOptions): Fetch => {
...defaultHeaders,
...headers,
},
// Limit response sizes to 2MB of response data. 1e6B is 1MB.
size: 2e6,
// Do not follow redirects automatically, and do not error if we
// encounter one. We'll treat the response from the request as the
// endpoints final response.
redirect: "manual",
// Attach the controller signal to abort the request after the timeout
// is reached.
signal: abort.controller.signal,
// Merge in the passed options.
...defaultOptions,
...options,
});
+1 -1
View File
@@ -14,7 +14,7 @@ import {
startMigration,
} from "coral-server/models/migration";
import { I18n } from "coral-server/services/i18n";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
import {
FailedMigrationDetectedError,
@@ -0,0 +1,17 @@
import { Db } from "mongodb";
import Migration from "coral-server/services/migrate/migration";
import collections from "coral-server/services/mongodb/collections";
export default class extends Migration {
public async up(mongo: Db, tenantID: string) {
await collections.tenants(mongo).updateOne(
{ id: tenantID },
{
$rename: {
"auth.integrations.sso.keys": "auth.integrations.sso.signingSecrets",
},
}
);
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Db, MongoClient } from "mongodb";
import { Config } from "coral-server/config";
import { InternalError } from "coral-server/errors";
import { WrappedInternalError } from "coral-server/errors";
export async function createMongoClient(config: Config): Promise<MongoClient> {
try {
@@ -10,7 +10,7 @@ export async function createMongoClient(config: Config): Promise<MongoClient> {
ignoreUndefined: true,
});
} catch (err) {
throw new InternalError(err, "could not connect to mongodb");
throw new WrappedInternalError(err, "could not connect to mongodb");
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import RedisClient, { Pipeline, Redis } from "ioredis";
import { Config } from "coral-server/config";
import { InternalError } from "coral-server/errors";
import { WrappedInternalError } from "coral-server/errors";
import logger from "coral-server/logger";
export interface AugmentedRedisCommands {
@@ -52,7 +52,7 @@ export function createRedisClient(config: Config, lazyConnect = false): Redis {
return redis;
} catch (err) {
throw new InternalError(err, "could not connect to redis");
throw new WrappedInternalError(err, "could not connect to redis");
}
}
@@ -73,6 +73,6 @@ export async function createAugmentedRedisClient(
return redis;
} catch (err) {
throw new InternalError(err, "could not connect to redis");
throw new WrappedInternalError(err, "could not connect to redis");
}
}
@@ -1,4 +1,3 @@
import Logger from "bunyan";
import cheerio from "cheerio";
import authorScraper from "metascraper-author";
import descriptionScraper from "metascraper-description";
@@ -12,7 +11,7 @@ import { ScrapeFailed } from "coral-server/errors";
import logger from "coral-server/logger";
import { retrieveStory, updateStory } from "coral-server/models/story";
import { retrieveTenant } from "coral-server/models/tenant";
import { createFetch, Fetch, FetchOptions } from "coral-server/services/fetch";
import { createFetch, FetchOptions } from "coral-server/services/fetch";
import { GQLStoryMetadata } from "coral-server/graph/schema/__generated__/types";
@@ -27,15 +26,21 @@ export type Rule = Record<
>
>;
interface ScrapeOptions {
url: string;
timeout: number;
size: number;
customUserAgent?: string;
proxyURL?: string;
}
class Scraper {
private readonly rules: Rule[];
private readonly log: Logger;
private readonly fetch: Fetch;
private readonly log = logger.child({ taskName: "scraper" }, true);
private readonly fetch = createFetch({ name: "Scraper" });
constructor(rules: Rule[]) {
this.fetch = createFetch({ name: "Scraper" });
this.rules = rules;
this.log = logger.child({ taskName: "scraper" }, true);
}
public parse(url: string, html: string): GQLStoryMetadata {
@@ -83,15 +88,15 @@ class Scraper {
};
}
public async download(
url: string,
timeout: number,
customUserAgent?: string,
proxyURL?: string
) {
public async download({
url,
timeout,
customUserAgent,
proxyURL,
}: ScrapeOptions) {
const log = this.log.child({ storyURL: url }, true);
const options: FetchOptions = { timeout };
const options: FetchOptions = { method: "GET", timeout };
if (customUserAgent) {
options.headers = {
...options.headers,
@@ -131,22 +136,14 @@ class Scraper {
}
public async scrape(
url: string,
abortAfterMilliseconds: number,
customUserAgent?: string,
proxyURL?: string
options: ScrapeOptions
): Promise<GQLStoryMetadata | null> {
const html = await this.download(
url,
abortAfterMilliseconds,
customUserAgent,
proxyURL
);
const html = await this.download(options);
if (!html) {
return null;
}
return this.parse(url, html);
return this.parse(options.url, html);
}
}
@@ -195,14 +192,16 @@ export async function scrape(
// This typecast is needed because the custom `ms` format does not return the
// desired `number` type even though that's the only type it can output.
const timeout = (config.get("scrape_timeout") as unknown) as number;
const size = config.get("scrape_max_response_size");
// Get the metadata from the scraped html.
const metadata = await scraper.scrape(
storyURL,
const metadata = await scraper.scrape({
url: storyURL,
timeout,
tenant.stories.scraping.customUserAgent,
tenant.stories.scraping.proxyURL
);
size,
customUserAgent: tenant.stories.scraping.customUserAgent,
proxyURL: tenant.stories.scraping.proxyURL,
});
if (!metadata) {
throw new Error("story at specified url not found");
}
+2 -2
View File
@@ -1,4 +1,4 @@
import TenantCache from "coral-server/services/tenant/cache";
import TenantCache from "./cache";
export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
@@ -8,7 +8,7 @@ export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
* tenants are enabled, this acts as a map to store entries, and will
* automatically invalidate tenants that have been updated.
*/
export class TenantCacheAdapter<T> {
export default class TenantCacheAdapter<T> {
private readonly cache = new Map<string, T>();
private readonly tenantCache: TenantCache;
+376
View File
@@ -0,0 +1,376 @@
import DataLoader from "dataloader";
import { EventEmitter } from "events";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { v4 as uuid } from "uuid";
import { Config } from "coral-server/config";
import logger from "coral-server/logger";
import {
countTenants,
retrieveAllTenants,
retrieveManyTenants,
retrieveManyTenantsByDomain,
Tenant,
} from "coral-server/models/tenant";
const TENANT_CACHE_CHANNEL = "TENANT_CACHE_CHANNEL";
enum EVENTS {
UPDATE = "UPDATE",
DELETE = "DELETE",
}
type UpdateSubscribeCallback = (tenant: Tenant) => void;
type DeleteSubscribeCallback = (tenantID: string, tenantDomain: string) => void;
type Message = UpdateMessage | DeleteMessage;
interface DeleteMessage {
event: EVENTS.DELETE;
tenantID: string;
tenantDomain: string;
clientApplicationID: string;
}
interface UpdateMessage {
event: EVENTS.UPDATE;
tenant: Tenant;
clientApplicationID: string;
}
/**
* MessageData is a type that is used to select only the data parts of the
* message.
*/
type MessageData<T extends Message> = Omit<T, "clientApplicationID" | "event">;
// TenantCache provides an interface for retrieving tenant stored in local
// memory rather than grabbing it from the database every single call.
export default class TenantCache {
/**
* tenantsByID reference the tenants that have been cached/retrieved by ID.
*/
private readonly tenantsByID: DataLoader<string, Readonly<Tenant> | null>;
/**
* tenantsByDomain reference the tenants that have been cached/retrieved by
* Domain.
*/
private readonly tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
/**
* tenantCountCache stores all the id's of all the Tenant's that have crossed
* it.
*/
private readonly tenantCountCache = new Set<string>();
/**
* primed is true when the cache has already been fully primed.
*/
private primed = false;
/**
* Create a new client application ID. This prevents duplicated messages
* generated by this application from being handled as external messages
* as we should have already processed it.
*/
private readonly clientApplicationID = uuid();
private readonly mongo: Db;
private readonly emitter = new EventEmitter();
/**
* cachingEnabled is true when tenant caching has been enabled.
*/
public readonly cachingEnabled: boolean;
constructor(mongo: Db, subscriber: Redis, config: Config) {
this.cachingEnabled = !config.get("disable_tenant_caching");
if (!this.cachingEnabled) {
logger.warn("tenant caching is disabled");
} else {
logger.debug("tenant caching is enabled");
}
// Save the Db reference.
this.mongo = mongo;
// Configure the data loaders.
this.tenantsByID = new DataLoader(
async (ids) => {
logger.debug({ ids: ids.length }, "now loading tenants");
const tenants = await retrieveManyTenants(this.mongo, ids);
logger.debug(
{ tenants: tenants.filter((t) => t !== null).length },
"loaded tenants"
);
tenants
.filter((t) => t !== null)
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
return tenants;
},
{
cache: this.cachingEnabled,
}
);
this.tenantsByDomain = new DataLoader(
async (domains) => {
logger.debug({ domains: domains.length }, "now loading tenants");
const tenants = await retrieveManyTenantsByDomain(this.mongo, domains);
logger.debug(
{ tenants: tenants.filter((t) => t !== null).length },
"loaded tenants"
);
tenants
.filter((t) => t !== null)
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
return tenants;
},
{
cache: this.cachingEnabled,
}
);
// We don't need updates if we aren't synced to tenant updates.
if (this.cachingEnabled) {
// Attach to messages on this connection so we can receive updates when
// the tenant are changed.
subscriber.on("message", this.onMessage);
// Subscribe to tenant notifications.
subscriber.subscribe(TENANT_CACHE_CHANNEL);
}
}
/**
* count will return the number of Tenant's.
*/
public async count(): Promise<number> {
if (!this.cachingEnabled) {
return countTenants(this.mongo);
}
if (!this.primed) {
await this.primeAll();
}
return this.tenantCountCache.size;
}
/**
* primeAll will load all the tenants into the cache on startup.
*/
public async primeAll() {
if (!this.cachingEnabled) {
logger.debug("tenants not primed, caching disabled");
return;
}
// Grab all the tenants for this node.
const tenants = await retrieveAllTenants(this.mongo);
// Clear out all the items in the cache.
this.tenantsByID.clearAll();
this.tenantsByDomain.clearAll();
this.tenantCountCache.clear();
// Prime the cache with each of these tenants.
tenants.forEach((tenant) => {
this.tenantsByID.prime(tenant.id, tenant);
this.tenantsByDomain.prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
});
logger.debug({ tenants: tenants.length }, "primed all tenants");
this.primed = true;
}
/**
* Symbol.asyncIterator implements the asyncIterator interface for the
* TenantCache. This allows you to use the TenantCache as a asyncIterator with
* a `for await (const tenant of tenants) {}` pattern to iterate over all the
* tenant's on the cache. If the cache is cacheable, and not primed, the cache
* will be primed at the first async iteration process. If caching is
* disabled, then the tenants will bne loaded on demand and not persisted
* after the iteration.
*/
public async *[Symbol.asyncIterator]() {
// If the cache isn't primed, and caching is enabled, then prime the cache
// now, as this will increase performance dramatically.
if (!this.primed && this.cachingEnabled) {
await this.primeAll();
}
// Copy the tenant count cache to prevent race conditions related to
// clearing during iteration.
const cache = new Set(this.tenantCountCache);
// If the tenant's are primed in the cache, then just use the count cache as
// the iteration source.
if (this.primed) {
for (const tenantID of cache) {
const tenant = await this.tenantsByID.load(tenantID);
if (!tenant) {
continue;
}
yield tenant;
}
return;
}
// Caching must be disabled, so just grab all the tenants for this node and
// iterate through each of them as we handle it.
const tenants = await retrieveAllTenants(this.mongo);
for (const tenant of tenants) {
yield tenant;
}
}
private onUpdateMessage({ tenant }: MessageData<UpdateMessage>) {
// Update the tenant cache.
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
// Publish the event for the connected listeners.
this.emitter.emit(EVENTS.UPDATE, tenant);
}
private onDeleteMessage({
tenantID,
tenantDomain,
}: MessageData<DeleteMessage>) {
// Delete the tenant in the local cache.
this.tenantsByID.clear(tenantID);
this.tenantsByDomain.clear(tenantDomain);
this.tenantCountCache.delete(tenantID);
// Publish the event for the connected listeners.
this.emitter.emit(EVENTS.DELETE, tenantID, tenantDomain);
}
/**
* onMessage is fired every time the client gets a subscription event.
*/
private onMessage = async (channel: string, data: string): Promise<void> => {
// Only do things when the message is for tenant.
if (channel !== TENANT_CACHE_CHANNEL) {
return;
}
try {
// Parse the message (which is JSON).
const message: Message = JSON.parse(data);
// Extract some known parameters.
const { clientApplicationID } = message;
// Check to see if this was the update issued by this instance.
if (clientApplicationID === this.clientApplicationID) {
// It was, so just return here, we already updated/handled it.
return;
}
const log = logger.child({ eventName: message.event }, true);
log.debug("received tenant message");
// Send the message to the correct handler.
switch (message.event) {
case EVENTS.UPDATE:
return this.onUpdateMessage(message);
case EVENTS.DELETE:
return this.onDeleteMessage(message);
default:
log.warn("received unknown event");
return;
}
} catch (err) {
logger.error(
{ err },
"an error occurred while trying to handle a message"
);
}
};
public async retrieveByID(id: string): Promise<Readonly<Tenant> | null> {
return this.tenantsByID.load(id);
}
public async retrieveByDomain(
domain: string
): Promise<Readonly<Tenant> | null> {
return this.tenantsByDomain.load(domain);
}
/**
* This allows you to subscribe to new Tenant updates. This will also return
* a function that when called, unsubscribes you from updates.
*
* @param updateCallback the function to be called when there is an updated Tenant.
* @param deleteCallback the function to be called when a tenant needs to be purged
*/
public subscribe(
updateCallback: UpdateSubscribeCallback,
deleteCallback: DeleteSubscribeCallback
) {
this.emitter.on(EVENTS.UPDATE, updateCallback);
this.emitter.on(EVENTS.DELETE, deleteCallback);
// Return the unsubscribe function.
return () => {
this.emitter.removeListener(EVENTS.UPDATE, updateCallback);
this.emitter.removeListener(EVENTS.DELETE, deleteCallback);
};
}
private async publish(tenantID: string, conn: Redis, message: Message) {
const subscribers = await conn.publish(
TENANT_CACHE_CHANNEL,
JSON.stringify(message)
);
logger.debug(
{ tenantID, subscribers, eventName: message.event },
"updated tenant in cache"
);
}
/**
* update will update the value for Tenant in the local cache and publish
* a change notification that will be used to keep the other nodes in sync.
*
* @param conn a redis connection used to publish the change notification
* @param tenant the updated Tenant object
*/
public async update(conn: Redis, tenant: Tenant): Promise<void> {
// Process the tenant update on this node.
this.onUpdateMessage({ tenant });
// Notify the other nodes about the tenant change.
await this.publish(tenant.id, conn, {
event: EVENTS.UPDATE,
tenant,
clientApplicationID: this.clientApplicationID,
});
}
public async delete(conn: Redis, tenantID: string, tenantDomain: string) {
// Process the tenant update on this node.
this.onDeleteMessage({ tenantID, tenantDomain });
// Notify the other nodes about the tenant change.
await this.publish(tenantID, conn, {
event: EVENTS.DELETE,
tenantID,
tenantDomain,
clientApplicationID: this.clientApplicationID,
});
}
}
+2 -376
View File
@@ -1,376 +1,2 @@
import DataLoader from "dataloader";
import { EventEmitter } from "events";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { v4 as uuid } from "uuid";
import { Config } from "coral-server/config";
import logger from "coral-server/logger";
import {
countTenants,
retrieveAllTenants,
retrieveManyTenants,
retrieveManyTenantsByDomain,
Tenant,
} from "coral-server/models/tenant";
const TENANT_CACHE_CHANNEL = "TENANT_CACHE_CHANNEL";
enum EVENTS {
UPDATE = "UPDATE",
DELETE = "DELETE",
}
type UpdateSubscribeCallback = (tenant: Tenant) => void;
type DeleteSubscribeCallback = (tenantID: string, tenantDomain: string) => void;
type Message = UpdateMessage | DeleteMessage;
interface DeleteMessage {
event: EVENTS.DELETE;
tenantID: string;
tenantDomain: string;
clientApplicationID: string;
}
interface UpdateMessage {
event: EVENTS.UPDATE;
tenant: Tenant;
clientApplicationID: string;
}
/**
* MessageData is a type that is used to select only the data parts of the
* message.
*/
type MessageData<T extends Message> = Omit<T, "clientApplicationID" | "event">;
// TenantCache provides an interface for retrieving tenant stored in local
// memory rather than grabbing it from the database every single call.
export default class TenantCache {
/**
* tenantsByID reference the tenants that have been cached/retrieved by ID.
*/
private readonly tenantsByID: DataLoader<string, Readonly<Tenant> | null>;
/**
* tenantsByDomain reference the tenants that have been cached/retrieved by
* Domain.
*/
private readonly tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
/**
* tenantCountCache stores all the id's of all the Tenant's that have crossed
* it.
*/
private readonly tenantCountCache = new Set<string>();
/**
* primed is true when the cache has already been fully primed.
*/
private primed = false;
/**
* Create a new client application ID. This prevents duplicated messages
* generated by this application from being handled as external messages
* as we should have already processed it.
*/
private readonly clientApplicationID = uuid();
private readonly mongo: Db;
private readonly emitter = new EventEmitter();
/**
* cachingEnabled is true when tenant caching has been enabled.
*/
public readonly cachingEnabled: boolean;
constructor(mongo: Db, subscriber: Redis, config: Config) {
this.cachingEnabled = !config.get("disable_tenant_caching");
if (!this.cachingEnabled) {
logger.warn("tenant caching is disabled");
} else {
logger.debug("tenant caching is enabled");
}
// Save the Db reference.
this.mongo = mongo;
// Configure the data loaders.
this.tenantsByID = new DataLoader(
async (ids) => {
logger.debug({ ids: ids.length }, "now loading tenants");
const tenants = await retrieveManyTenants(this.mongo, ids);
logger.debug(
{ tenants: tenants.filter((t) => t !== null).length },
"loaded tenants"
);
tenants
.filter((t) => t !== null)
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
return tenants;
},
{
cache: this.cachingEnabled,
}
);
this.tenantsByDomain = new DataLoader(
async (domains) => {
logger.debug({ domains: domains.length }, "now loading tenants");
const tenants = await retrieveManyTenantsByDomain(this.mongo, domains);
logger.debug(
{ tenants: tenants.filter((t) => t !== null).length },
"loaded tenants"
);
tenants
.filter((t) => t !== null)
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
return tenants;
},
{
cache: this.cachingEnabled,
}
);
// We don't need updates if we aren't synced to tenant updates.
if (this.cachingEnabled) {
// Attach to messages on this connection so we can receive updates when
// the tenant are changed.
subscriber.on("message", this.onMessage);
// Subscribe to tenant notifications.
subscriber.subscribe(TENANT_CACHE_CHANNEL);
}
}
/**
* count will return the number of Tenant's.
*/
public async count(): Promise<number> {
if (!this.cachingEnabled) {
return countTenants(this.mongo);
}
if (!this.primed) {
await this.primeAll();
}
return this.tenantCountCache.size;
}
/**
* primeAll will load all the tenants into the cache on startup.
*/
public async primeAll() {
if (!this.cachingEnabled) {
logger.debug("tenants not primed, caching disabled");
return;
}
// Grab all the tenants for this node.
const tenants = await retrieveAllTenants(this.mongo);
// Clear out all the items in the cache.
this.tenantsByID.clearAll();
this.tenantsByDomain.clearAll();
this.tenantCountCache.clear();
// Prime the cache with each of these tenants.
tenants.forEach((tenant) => {
this.tenantsByID.prime(tenant.id, tenant);
this.tenantsByDomain.prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
});
logger.debug({ tenants: tenants.length }, "primed all tenants");
this.primed = true;
}
/**
* Symbol.asyncIterator implements the asyncIterator interface for the
* TenantCache. This allows you to use the TenantCache as a asyncIterator with
* a `for await (const tenant of tenants) {}` pattern to iterate over all the
* tenant's on the cache. If the cache is cacheable, and not primed, the cache
* will be primed at the first async iteration process. If caching is
* disabled, then the tenants will bne loaded on demand and not persisted
* after the iteration.
*/
public async *[Symbol.asyncIterator]() {
// If the cache isn't primed, and caching is enabled, then prime the cache
// now, as this will increase performance dramatically.
if (!this.primed && this.cachingEnabled) {
await this.primeAll();
}
// Copy the tenant count cache to prevent race conditions related to
// clearing during iteration.
const cache = new Set(this.tenantCountCache);
// If the tenant's are primed in the cache, then just use the count cache as
// the iteration source.
if (this.primed) {
for (const tenantID of cache) {
const tenant = await this.tenantsByID.load(tenantID);
if (!tenant) {
continue;
}
yield tenant;
}
return;
}
// Caching must be disabled, so just grab all the tenants for this node and
// iterate through each of them as we handle it.
const tenants = await retrieveAllTenants(this.mongo);
for (const tenant of tenants) {
yield tenant;
}
}
private onUpdateMessage({ tenant }: MessageData<UpdateMessage>) {
// Update the tenant cache.
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
// Publish the event for the connected listeners.
this.emitter.emit(EVENTS.UPDATE, tenant);
}
private onDeleteMessage({
tenantID,
tenantDomain,
}: MessageData<DeleteMessage>) {
// Delete the tenant in the local cache.
this.tenantsByID.clear(tenantID);
this.tenantsByDomain.clear(tenantDomain);
this.tenantCountCache.delete(tenantID);
// Publish the event for the connected listeners.
this.emitter.emit(EVENTS.DELETE, tenantID, tenantDomain);
}
/**
* onMessage is fired every time the client gets a subscription event.
*/
private onMessage = async (channel: string, data: string): Promise<void> => {
// Only do things when the message is for tenant.
if (channel !== TENANT_CACHE_CHANNEL) {
return;
}
try {
// Parse the message (which is JSON).
const message: Message = JSON.parse(data);
// Extract some known parameters.
const { clientApplicationID } = message;
// Check to see if this was the update issued by this instance.
if (clientApplicationID === this.clientApplicationID) {
// It was, so just return here, we already updated/handled it.
return;
}
const log = logger.child({ eventName: message.event }, true);
log.debug("received tenant message");
// Send the message to the correct handler.
switch (message.event) {
case EVENTS.UPDATE:
return this.onUpdateMessage(message);
case EVENTS.DELETE:
return this.onDeleteMessage(message);
default:
log.warn("received unknown event");
return;
}
} catch (err) {
logger.error(
{ err },
"an error occurred while trying to handle a message"
);
}
};
public async retrieveByID(id: string): Promise<Readonly<Tenant> | null> {
return this.tenantsByID.load(id);
}
public async retrieveByDomain(
domain: string
): Promise<Readonly<Tenant> | null> {
return this.tenantsByDomain.load(domain);
}
/**
* This allows you to subscribe to new Tenant updates. This will also return
* a function that when called, unsubscribes you from updates.
*
* @param updateCallback the function to be called when there is an updated Tenant.
* @param deleteCallback the function to be called when a tenant needs to be purged
*/
public subscribe(
updateCallback: UpdateSubscribeCallback,
deleteCallback: DeleteSubscribeCallback
) {
this.emitter.on(EVENTS.UPDATE, updateCallback);
this.emitter.on(EVENTS.DELETE, deleteCallback);
// Return the unsubscribe function.
return () => {
this.emitter.removeListener(EVENTS.UPDATE, updateCallback);
this.emitter.removeListener(EVENTS.DELETE, deleteCallback);
};
}
private async publish(tenantID: string, conn: Redis, message: Message) {
const subscribers = await conn.publish(
TENANT_CACHE_CHANNEL,
JSON.stringify(message)
);
logger.debug(
{ tenantID, subscribers, eventName: message.event },
"updated tenant in cache"
);
}
/**
* update will update the value for Tenant in the local cache and publish
* a change notification that will be used to keep the other nodes in sync.
*
* @param conn a redis connection used to publish the change notification
* @param tenant the updated Tenant object
*/
public async update(conn: Redis, tenant: Tenant): Promise<void> {
// Process the tenant update on this node.
this.onUpdateMessage({ tenant });
// Notify the other nodes about the tenant change.
await this.publish(tenant.id, conn, {
event: EVENTS.UPDATE,
tenant,
clientApplicationID: this.clientApplicationID,
});
}
public async delete(conn: Redis, tenantID: string, tenantDomain: string) {
// Process the tenant update on this node.
this.onDeleteMessage({ tenantID, tenantDomain });
// Notify the other nodes about the tenant change.
await this.publish(tenantID, conn, {
event: EVENTS.DELETE,
tenantID,
tenantDomain,
clientApplicationID: this.clientApplicationID,
});
}
}
export { default as TenantCache } from "./cache";
export { default as TenantCacheAdapter } from "./adapter";
@@ -0,0 +1,322 @@
import { Redis } from "ioredis";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import { getExternalModerationPhase } from "coral-server/models/settings";
import {
createTenantExternalModerationPhase,
CreateTenantExternalModerationPhaseInput,
deleteTenantExternalModerationPhase,
rotateTenantExternalModerationPhaseSigningSecret,
Tenant,
updateTenantExternalModerationPhase,
UpdateTenantExternalModerationPhaseInput,
} from "coral-server/models/tenant";
import { TenantCache } from "./cache";
interface ExternalModerationPhaseInput {
url: string;
timeout: number;
}
export function validateExternalModerationPhaseInput(
config: Config,
input: ExternalModerationPhaseInput
) {
// 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}`);
}
// Check to see if the timeout value is within range.
if (input.timeout < 100) {
throw new Error("timeout value too low");
} else if (input.timeout > 10000) {
throw new Error("timeout value too high");
}
}
export async function createExternalModerationPhase(
mongo: Db,
redis: Redis,
config: Config,
cache: TenantCache,
tenant: Tenant,
input: CreateTenantExternalModerationPhaseInput,
now: Date
) {
// Validate the input.
validateExternalModerationPhaseInput(config, input);
// Looks good in create this, send it off to be created.
const result = await createTenantExternalModerationPhase(
mongo,
tenant.id,
input,
now
);
if (!result.tenant) {
throw new Error("could not create the tenant phase, tenant not found");
}
// Update the tenant cache.
await cache.update(redis, result.tenant);
return {
phase: result.phase,
settings: result.tenant,
};
}
export async function updateExternalModerationPhase(
mongo: Db,
redis: Redis,
config: Config,
cache: TenantCache,
tenant: Tenant,
phaseID: string,
input: UpdateTenantExternalModerationPhaseInput
) {
// Find the phase.
if (!tenant.integrations.external) {
throw new Error(
"referenced phase was not found on tenant, none configured"
);
}
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
// Extract the input.
const { url = phase.url, timeout = phase.timeout } = input;
// Validate the input.
validateExternalModerationPhaseInput(config, {
url,
timeout,
});
const updatedTenant = await updateTenantExternalModerationPhase(
mongo,
tenant.id,
phaseID,
input
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated phase.
phase = getExternalModerationPhase(
// We know that `external` is provided because we already verified it earlier.
updatedTenant.integrations.external!,
phaseID
);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
return phase;
}
export async function deleteExternalModerationPhase(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
phaseID: string
) {
// Find the phase.
if (!tenant.integrations.external) {
throw new Error(
"referenced phase was not found on tenant, none configured"
);
}
const phase = getExternalModerationPhase(
tenant.integrations.external,
phaseID
);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
const updatedTenant = await deleteTenantExternalModerationPhase(
mongo,
tenant.id,
phaseID
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
return phase;
}
export async function enableExternalModerationPhase(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
phaseID: string
) {
// Find the phase.
if (!tenant.integrations.external) {
throw new Error(
"referenced phase was not found on tenant, none configured"
);
}
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
// Phase is already enabled.
if (phase.enabled === true) {
return phase;
}
const updatedTenant = await updateTenantExternalModerationPhase(
mongo,
tenant.id,
phaseID,
{ enabled: true }
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated phase.
phase = getExternalModerationPhase(
// We know that `external` is provided because we already verified it earlier.
updatedTenant.integrations.external!,
phaseID
);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
return phase;
}
export async function disableExternalModerationPhase(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
phaseID: string
) {
// Find the phase.
if (!tenant.integrations.external) {
throw new Error(
"referenced phase was not found on tenant, none configured"
);
}
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
// Phase is already disabled.
if (phase.enabled === false) {
return phase;
}
const updatedTenant = await updateTenantExternalModerationPhase(
mongo,
tenant.id,
phaseID,
{ enabled: false }
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated phase.
phase = getExternalModerationPhase(
// We know that `external` is provided because we already verified it earlier.
updatedTenant.integrations.external!,
phaseID
);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
return phase;
}
export async function rotateExternalModerationPhaseSigningSecret(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
phaseID: string,
inactiveIn: number,
now: Date
) {
// Find the phase.
if (!tenant.integrations.external) {
throw new Error(
"referenced phase was not found on tenant, none configured"
);
}
let phase = getExternalModerationPhase(tenant.integrations.external, phaseID);
if (!phase) {
throw new Error("referenced phase was not found on tenant");
}
if (inactiveIn < 0 || inactiveIn > 86400) {
throw new Error(`invalid inactiveIn passed: ${inactiveIn}`);
}
// Compute the inactiveAt dates for the current active secrets.
const inactiveAt =
inactiveIn === 0
? now
: DateTime.fromJSDate(now).plus({ seconds: inactiveIn }).toJSDate();
// Rotate the secrets.
const updatedTenant = await rotateTenantExternalModerationPhaseSigningSecret(
mongo,
tenant.id,
phaseID,
inactiveAt,
now
);
if (!updatedTenant) {
throw new Error("tenant not found");
}
// Update the tenant cache.
await cache.update(redis, updatedTenant);
// Find the updated endpoint.
phase = getExternalModerationPhase(
// We know that `external` is provided because we already verified it earlier.
updatedTenant.integrations.external!,
phaseID
);
if (!phase) {
throw new Error("referenced endpoint was not found on tenant");
}
return phase;
}
+2
View File
@@ -1,2 +1,4 @@
export * from "./tenant";
export * from "./sso";
export * from "./externalModerationPhases";
export * from "./webhookEndpoints";
+40 -42
View File
@@ -3,18 +3,20 @@ import { DateTime } from "luxon";
import { Db } from "mongodb";
import {
createTenantSSOKey,
deactivateTenantSSOKey,
deleteLastUsedAtTenantSSOKey,
deleteTenantSSOKey,
deactivateTenantSSOSigningSecret,
deleteLastUsedAtTenantSSOSigningSecret,
deleteTenantSSOSigningSecret,
rotateTenantSSOSigningSecret,
Tenant,
} from "coral-server/models/tenant";
import TenantCache from "./cache";
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.
*
* DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
*/
export async function regenerateSSOKey(
mongo: Db,
@@ -24,10 +26,17 @@ export async function regenerateSSOKey(
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);
return rotateSSOSigningSecret(
mongo,
redis,
cache,
tenant,
30 * 24 * 60 * 60,
now
);
}
export async function rotateSSOKey(
export async function rotateSSOSigningSecret(
mongo: Db,
redis: Redis,
cache: TenantCache,
@@ -35,35 +44,16 @@ export async function rotateSSOKey(
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;
});
const inactiveAt = DateTime.fromJSDate(now)
.plus({ seconds: inactiveIn })
.toJSDate();
// 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);
const updatedTenant = await rotateTenantSSOSigningSecret(
mongo,
tenant.id,
inactiveAt,
now
);
if (!updatedTenant) {
return null;
}
@@ -74,7 +64,7 @@ export async function rotateSSOKey(
return updatedTenant;
}
export async function deactivateSSOKey(
export async function deactivateSSOSigningSecret(
mongo: Db,
redis: Redis,
cache: TenantCache,
@@ -82,13 +72,15 @@ export async function deactivateSSOKey(
kid: string,
now: Date
) {
const key = tenant.auth.integrations.sso.keys.find((k) => k.kid === kid);
const key = tenant.auth.integrations.sso.signingSecrets.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(
const updatedTenant = await deactivateTenantSSOSigningSecret(
mongo,
tenant.id,
kid,
@@ -105,26 +97,32 @@ export async function deactivateSSOKey(
return updatedTenant;
}
export async function deleteSSOKey(
export async function deleteSSOSigningSecret(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
kid: string
) {
const key = tenant.auth.integrations.sso.keys.find((k) => k.kid === kid);
const key = tenant.auth.integrations.sso.signingSecrets.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);
const updatedTenant = await deleteTenantSSOSigningSecret(
mongo,
tenant.id,
kid
);
if (!updatedTenant) {
return null;
}
// Remove the last used date entry from the Redis hash.
await deleteLastUsedAtTenantSSOKey(redis, tenant.id, kid);
await deleteLastUsedAtTenantSSOSigningSecret(redis, tenant.id, kid);
// Update the tenant cache.
await cache.update(redis, updatedTenant);
+2 -262
View File
@@ -1,6 +1,5 @@
import { Redis } from "ioredis";
import { isUndefined, lowerCase, uniqBy } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { URL } from "url";
@@ -13,18 +12,11 @@ import {
createTenant,
createTenantAnnouncement,
CreateTenantInput,
createTenantWebhookEndpoint,
CreateTenantWebhookEndpointInput,
deleteTenantAnnouncement,
deleteTenantWebhookEndpoint,
disableTenantFeatureFlag,
enableTenantFeatureFlag,
getWebhookEndpoint,
rollTenantWebhookEndpointSecret,
Tenant,
updateTenant,
updateTenantWebhookEndpoint,
UpdateTenantWebhookEndpointInput,
} from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
@@ -34,10 +26,9 @@ import {
GQLFEATURE_FLAG,
GQLSettingsInput,
GQLSettingsWordListInput,
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import TenantCache from "./cache";
import TenantCache from "./cache/cache";
export type UpdateTenant = GQLSettingsInput;
@@ -165,258 +156,6 @@ export async function discoverOIDCConfiguration(issuerString: string) {
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,
@@ -484,6 +223,7 @@ export async function createAnnouncement(
throw new Error("tenant not found");
}
await cache.update(redis, updated);
return updated;
}
@@ -0,0 +1,281 @@
import { Redis } from "ioredis";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { Config } from "coral-server/config";
import {
createTenantWebhookEndpoint,
CreateTenantWebhookEndpointInput,
deleteTenantWebhookEndpoint,
getWebhookEndpoint,
rotateTenantWebhookEndpointSigningSecret,
Tenant,
updateTenantWebhookEndpoint,
UpdateTenantWebhookEndpointInput,
} from "coral-server/models/tenant";
import { GQLWEBHOOK_EVENT_NAME } from "coral-server/graph/schema/__generated__/types";
import { TenantCache } from "./cache";
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 rotateWebhookEndpointSigningSecret(
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
endpointID: string,
inactiveIn: number,
now: Date
) {
// Find the endpoint.
let endpoint = getWebhookEndpoint(tenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
if (inactiveIn < 0 || inactiveIn > 86400) {
throw new Error(`invalid inactiveIn passed: ${inactiveIn}`);
}
// Compute the inactiveAt dates for the current active secrets.
const inactiveAt =
inactiveIn === 0
? now
: DateTime.fromJSDate(now).plus({ seconds: inactiveIn }).toJSDate();
// Rotate the secrets.
const updatedTenant = await rotateTenantWebhookEndpointSigningSecret(
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.
endpoint = getWebhookEndpoint(updatedTenant, endpointID);
if (!endpoint) {
throw new Error("referenced endpoint was not found on tenant");
}
return endpoint;
}
+10 -7
View File
@@ -23,6 +23,7 @@ import {
retrieveComment,
} from "coral-server/models/comment";
import {
getDepth,
hasAncestors,
hasPublishedStatus,
} from "coral-server/models/comment/helpers";
@@ -91,7 +92,7 @@ const markCommentAsAnswered = async (
// If we are the export on this story...
story.settings.expertIDs.some((id) => id === author.id) &&
// And this is the first reply (depth of 1)...
comment.ancestorIDs.length === 1
getDepth(comment) === 1
) {
// We need to mark the parent question as answered.
// - Remove the unanswered tag.
@@ -170,14 +171,14 @@ export default async function create(
try {
// Run the comment through the moderation phases.
result = await processForModeration({
action: "NEW",
log,
mongo,
redis,
config,
nudge,
story,
action: "NEW",
tenant,
story,
nudge,
comment: { ...input, ancestorIDs },
author,
req,
@@ -221,7 +222,8 @@ export default async function create(
{
...input,
siteID: story.siteID,
tags,
// Remap the tags to include the createdAt.
tags: tags.map((tag) => ({ type: tag, createdAt: now })),
body,
status,
ancestorIDs,
@@ -277,10 +279,11 @@ export default async function create(
...action,
commentID: comment.id,
commentRevisionID: revision.id,
// Store the Story ID on the action.
storyID: story.id,
siteID: story.siteID,
// All these actions are created by the system.
userID: null,
})
),
now
+5 -2
View File
@@ -103,13 +103,13 @@ export default async function edit(
// Run the comment through the moderation phases.
const { body, status, metadata, actions } = await processForModeration({
action: "EDIT",
log,
mongo,
redis,
config,
story,
action: "EDIT",
tenant,
story,
comment: {
...originalStaleComment,
...input,
@@ -161,6 +161,9 @@ export default async function edit(
commentRevisionID: result.revision.id,
storyID: story.id,
siteID: story.siteID,
// All these actions are created by the system.
userID: null,
})
),
now
+1 -1
View File
@@ -4,7 +4,7 @@ import { Logger } from "coral-server/logger";
import { PersistedQuery } from "coral-server/models/queries";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import TenantCache from "coral-server/services/tenant/cache";
import { TenantCache } from "coral-server/services/tenant/cache";
export interface CoralRequest {
id: string;