[CORL-687] Webhooks (#2738)

* feat: initial webhook impl

* feat: added support for key rotation

* feat: harmonized fetcher

* feat: added expired secrets cleaning

* feat: event system refactor

* feat: added story event

* feat: simplfiied webhook handler

* feat: added ref's to locations where user events can be added

* feat: added UI to support webhooks

* fix: renaming some Webhook -> WebhookEndpoint

* fix: review comments to adjuist flow

* feat: added localizations

* fix: linting, updated snapshots

* fix: adapted for new fluent

* fix: rearranged folders

* fix: linting

* feat: added webhooks documentation

* feat: improved toc generation

* feat: added some tests to webhooks

* fix: chain transition hooks

* feat: added tests around webhook ui

* fix: renamed events

* fix: adjusted circle markdown linting

* fix: adjusted doctoc script call

* review: review fixes

* review: review comments

* review: adjusted signing secret confirmation

* review: adjusted styles to harmonize button usage

* fix: updated snapshots and tests

* review: move form out of webhooks

Moved the form out of the webhooks by relocating the layout used for the
route associated with the configure routes.

* fix: fixed bugs and snapshots with tests

* feat: revised slack message format to use block api

* fix: fixed a small text bug

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Wyatt Johnson
2020-02-18 13:25:48 -05:00
committed by GitHub
co-authored by Vinh Kim Gardner
parent 34ba2da88d
commit e42c2b925d
137 changed files with 5633 additions and 1020 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from "./settings";
export * from "./secret";
+44
View File
@@ -0,0 +1,44 @@
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);
}
@@ -13,6 +13,8 @@ import {
GQLSettings,
} from "coral-server/graph/schema/__generated__/types";
import { Secret } from "./secret";
export type LiveConfiguration = Omit<GQLLiveConfiguration, "configurable">;
export type EmailConfiguration = GQLEmailConfiguration;
@@ -38,40 +40,11 @@ export type FacebookAuthIntegration = Omit<
"callbackURL" | "redirectURL"
>;
export interface SSOKey {
/**
* 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 interface SSOAuthIntegration {
enabled: boolean;
allowRegistration: boolean;
targetFilter: GQLAuthenticationTargetFilter;
keys: SSOKey[];
keys: Secret[];
}
/**
+41 -19
View File
@@ -93,24 +93,27 @@ export interface UpsertStoryInput {
siteID: string;
}
export interface UpsertStoryResult {
story: Story;
wasUpserted: boolean;
}
export async function upsertStory(
mongo: Db,
tenantID: string,
{ id = uuid.v4(), url, siteID }: UpsertStoryInput,
now = new Date()
) {
): Promise<UpsertStoryResult> {
// Create the story, optionally sourcing the id from the input, additionally
// porting in the tenantID.
const update: { $setOnInsert: Story } = {
$setOnInsert: {
id,
url,
siteID,
tenantID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
},
const story: Story = {
id,
url,
tenantID,
siteID,
createdAt: now,
commentCounts: createEmptyRelatedCommentCounts(),
settings: {},
};
try {
@@ -121,18 +124,26 @@ export async function upsertStory(
url,
tenantID,
},
update,
{ $setOnInsert: story },
{
// Create the object if it doesn't already exist.
upsert: true,
// False to return the updated document instead of the original
// document.
returnOriginal: false,
// True to return the original document instead of the updated document.
// This will ensure that when an upsert operation adds a new Story, it
// should return null.
returnOriginal: true,
}
);
return result.value || null;
return {
// The story will either be found (via `result.value`) or upserted (via
// `story`).
story: result.value || story,
// The story was upserted if the value isn't provided.
wasUpserted: !result.value,
};
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate Story error.
@@ -172,13 +183,18 @@ export interface FindOrCreateStoryInput {
url?: string;
}
export interface FindOrCreateStoryResult {
story: Story | null;
wasUpserted: boolean;
}
export async function findOrCreateStory(
mongo: Db,
tenantID: string,
{ id, url }: FindOrCreateStoryInput,
siteID: string | null,
now = new Date()
) {
): Promise<FindOrCreateStoryResult> {
if (id) {
if (url && siteID) {
// The URL was specified, this is an upsert operation.
@@ -194,8 +210,14 @@ export async function findOrCreateStory(
);
}
// The URL and siteID were not specified, this is a lookup operation.
return retrieveStory(mongo, tenantID, id);
// The URL was not specified, this is a lookup operation.
const story = await retrieveStory(mongo, tenantID, id);
// Return the result object.
return {
story,
wasUpserted: false,
};
}
// The ID was not specified, this is an upsert operation. Check to see that
+10 -3
View File
@@ -9,7 +9,7 @@ import {
GQLStaffConfiguration,
} from "coral-server/graph/schema/__generated__/types";
import { SSOKey } from "../settings";
import { Secret } from "../settings";
import { Tenant } from "./tenant";
export const getDefaultReactionConfiguration = (
@@ -39,12 +39,12 @@ export function generateRandomString(size: number, drift = 5) {
.toString("hex");
}
export function generateSSOKey(createdAt: Date): SSOKey {
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 = generateRandomString(32, 5);
const secret = prefix + "_" + generateRandomString(32, 5);
const kid = generateRandomString(8, 3);
return { kid, secret, createdAt };
@@ -67,3 +67,10 @@ export function hasFeatureFlag(
return false;
}
export function getWebhookEndpoint(
tenant: Pick<Tenant, "webhooks">,
endpointID: string
) {
return tenant.webhooks.endpoints.find(e => e.id === endpointID) || null;
}
+308 -7
View File
@@ -9,7 +9,8 @@ import TIME from "coral-common/time";
import { DeepPartial, Omit, Sub } from "coral-common/types";
import { isBeforeDate } from "coral-common/utils";
import { dotize } from "coral-common/utils/dotize";
import { Settings } from "coral-server/models/settings";
import logger from "coral-server/logger";
import { Secret, Settings } from "coral-server/models/settings";
import { I18n } from "coral-server/services/i18n";
import { tenants as collection } from "coral-server/services/mongodb/collections";
@@ -18,12 +19,14 @@ import {
GQLFEATURE_FLAG,
GQLMODERATION_MODE,
GQLSettings,
GQLWEBHOOK_EVENT_NAME,
} from "coral-server/graph/schema/__generated__/types";
import {
generateSSOKey,
generateSecret,
getDefaultReactionConfiguration,
getDefaultStaffConfiguration,
getWebhookEndpoint,
} from "./helpers";
/**
@@ -38,6 +41,49 @@ export interface TenantResource {
readonly tenantID: string;
}
export interface Endpoint {
/**
* id is the unique identifier for this specific endpoint.
*/
id: string;
/**
* enabled when true will enable events to be sent to this endpoint.
*/
enabled: boolean;
/**
* url is the URL that we will POST event data to.
*/
url: string;
/**
* signingSecret is the secret used to sign the events sent out.
*/
signingSecrets: Secret[];
/**
* all when true indicates that all events should trigger.
*/
all: boolean;
/**
* events is the array of events that will trigger the delivery of an
* event.
*/
events: GQLWEBHOOK_EVENT_NAME[];
/**
* createdAt is the date that this endpoint was created.
*/
createdAt: Date;
/**
* modifiedAt is the date that this Endpoint was last modified at.
*/
modifiedAt?: Date;
}
export interface TenantSettings
extends Pick<GQLSettings, "domain" | "organization"> {
readonly id: string;
@@ -51,6 +97,16 @@ export interface TenantSettings
* featureFlags is the set of flags enabled on this Tenant.
*/
featureFlags?: GQLFEATURE_FLAG[];
/**
* webhooks stores the configurations for this Tenant's webhook rules.
*/
webhooks: {
/**
* endpoints is all the configured endpoints that should receive events.
*/
endpoints: Endpoint[];
};
}
/**
@@ -112,6 +168,9 @@ export async function createTenant(
enabled: false,
},
editCommentWindowLength: 30 * TIME.SECOND,
webhooks: {
endpoints: [],
},
charCount: {
enabled: false,
},
@@ -138,7 +197,7 @@ export async function createTenant(
stream: true,
},
// TODO: [CORL-754] (wyattjoh) remove this in favor of generating this when needed
keys: [generateSSOKey(now)],
keys: [generateSecret("ssosec", now)],
},
oidc: {
enabled: false,
@@ -294,9 +353,11 @@ export async function updateTenant(
{ id },
// Only update fields that have been updated.
{ $set },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
return result.value || null;
@@ -309,7 +370,7 @@ export async function updateTenant(
*/
export async function createTenantSSOKey(mongo: Db, id: string, now: Date) {
// Construct the new key.
const key = generateSSOKey(now);
const key = generateSecret("ssosec", now);
// Update the Tenant with this new key.
const result = await collection(mongo).findOneAndUpdate(
@@ -466,3 +527,243 @@ 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;
}
+10 -6
View File
@@ -576,7 +576,7 @@ export async function findOrCreateUser(
const user = await findOrCreateUserInput(tenantID, input, now);
try {
await collection(mongo).findOneAndUpdate(
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
profiles: {
@@ -588,12 +588,18 @@ export async function findOrCreateUser(
},
{ $setOnInsert: user },
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
// True to return the original document instead of the updated document.
// This will ensure that when an upsert operation adds a new User, it
// should return null.
returnOriginal: true,
upsert: true,
}
);
return {
user: result.value || user,
wasUpserted: !result.value,
};
} catch (err) {
// Evaluate the error, if it is in regards to violating the unique index,
// then return a duplicate User error.
@@ -607,8 +613,6 @@ export async function findOrCreateUser(
throw err;
}
return user;
}
export type CreateUserInput = FindOrCreateUserInput;