diff --git a/.changeset/poor-cheetahs-work.md b/.changeset/poor-cheetahs-work.md new file mode 100644 index 0000000000..0fa29949d2 --- /dev/null +++ b/.changeset/poor-cheetahs-work.md @@ -0,0 +1,17 @@ +--- +'@backstage/plugin-events-backend': minor +'@backstage/plugin-events-node': minor +--- + +Support events received via HTTP endpoints at plugin-events-backend. + +The plugin provides an event publisher `HttpPostIngressEventPublisher` +which will allow you to receive events via +HTTP endpoints `POST /api/events/http/{topic}` +and will publish these to the used event broker. + +Using a provided custom validator, you can participate in the decision +which events are accepted, e.g. by verifying the source of the request. + +Please find more information at +https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md. diff --git a/plugins/events-backend/README.md b/plugins/events-backend/README.md index 1a71a68944..f71df21517 100644 --- a/plugins/events-backend/README.md +++ b/plugins/events-backend/README.md @@ -13,6 +13,10 @@ implementation of your choice as you need (e.g., via module). Some of these (non-exhaustive) may provide added persistence, or use external systems like AWS EventBridge, AWS SNS, Kafka, etc. +By default, the plugin ships with support to receive events via HTTP endpoints +`POST /api/events/http/{topic}` and will publish these +to the used event broker. + ## Installation ```bash @@ -81,6 +85,36 @@ yarn add --cwd packages/backend @backstage/plugin-events-backend } ``` +## Configuration + +In order to create HTTP endpoints to receive events for a certain +topic, you need to add them at your configuration: + +```yaml +events: + http: + topics: + - bitbucketCloud + - github + - whatever +``` + +Only those topics added to the configuration will result in +available endpoints. + +The example above would result in the following endpoints: + +``` +POST /api/events/http/bitbucketCloud +POST /api/events/http/github +POST /api/events/http/whatever +``` + +You may want to use these for webhooks by SCM providers +in combination with suitable event subscribers. + +However, it is not limited to these use cases. + ## Use Cases ### Custom Event Broker @@ -122,3 +156,55 @@ export const yourModuleEventsModule = createBackendModule({ }, }); ``` + +### Request Validator + +Example using the `EventsBackend`: + +```ts +const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + ingresses: { + yourTopic: { + validator: yourValidator, + }, + }, + logger: env.logger, + router: httpRouter, +}); + +await new EventsBackend(env.logger) + .addPublishers(http) + // [...] + .start(); +``` + +Example using a module: + +```ts +import { eventsExtensionPoint } from '@backstage/plugin-events-node'; + +// [...] + +export const yourModuleEventsModule = createBackendModule({ + pluginId: 'events', + moduleId: 'yourModule', + register(env) { + // [...] + env.registerInit({ + deps: { + // [...] + events: eventsExtensionPoint, + // [...] + }, + async init({ /* ... */ events /*, ... */ }) { + // [...] + events.addHttpPostIngress({ + topic: 'your-topic', + validator: yourValidator, + }); + }, + }); + }, +}); +``` diff --git a/plugins/events-backend/api-report.md b/plugins/events-backend/api-report.md index e61664b428..4154168fa1 100644 --- a/plugins/events-backend/api-report.md +++ b/plugins/events-backend/api-report.md @@ -4,9 +4,12 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; import { EventBroker } from '@backstage/plugin-events-node'; import { EventPublisher } from '@backstage/plugin-events-node'; import { EventSubscriber } from '@backstage/plugin-events-node'; +import express from 'express'; +import { HttpPostIngressOptions } from '@backstage/plugin-events-node'; import { Logger } from 'winston'; // @public @@ -27,4 +30,19 @@ export class EventsBackend { // @alpha export const eventsPlugin: (options?: undefined) => BackendFeature; + +// @public +export class HttpPostIngressEventPublisher implements EventPublisher { + // (undocumented) + static fromConfig(env: { + config: Config; + ingresses?: { + [topic: string]: Omit; + }; + logger: Logger; + router: express.Router; + }): HttpPostIngressEventPublisher; + // (undocumented) + setEventBroker(eventBroker: EventBroker): Promise; +} ``` diff --git a/plugins/events-backend/config.d.ts b/plugins/events-backend/config.d.ts new file mode 100644 index 0000000000..3d26661855 --- /dev/null +++ b/plugins/events-backend/config.d.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface Config { + events?: { + http?: { + /** + * Topics for which a route has to be registered + * at which we can receive events via HTTP POST requests + * (i.e. received from webhooks). + */ + topics?: string[]; + }; + }; +} diff --git a/plugins/events-backend/package.json b/plugins/events-backend/package.json index 3c93ef48c1..2d82953e71 100644 --- a/plugins/events-backend/package.json +++ b/plugins/events-backend/package.json @@ -23,18 +23,26 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", "@backstage/plugin-events-node": "workspace:^", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", "winston": "^3.2.1" }, "devDependencies": { "@backstage/backend-common": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/plugin-events-backend-test-utils": "workspace:^" + "@backstage/plugin-events-backend-test-utils": "workspace:^", + "supertest": "^6.1.3" }, "files": [ "alpha", + "config.d.ts", "dist" - ] + ], + "configSchema": "config.d.ts" } diff --git a/plugins/events-backend/src/index.ts b/plugins/events-backend/src/index.ts index 121d9cdc46..5026729015 100644 --- a/plugins/events-backend/src/index.ts +++ b/plugins/events-backend/src/index.ts @@ -22,3 +22,4 @@ export { EventsBackend } from './service/EventsBackend'; export { eventsPlugin } from './service/EventsPlugin'; +export { HttpPostIngressEventPublisher } from './service/http'; diff --git a/plugins/events-backend/src/service/EventsPlugin.test.ts b/plugins/events-backend/src/service/EventsPlugin.test.ts index e6c7c75264..f58f081742 100644 --- a/plugins/events-backend/src/service/EventsPlugin.test.ts +++ b/plugins/events-backend/src/service/EventsPlugin.test.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { errorHandler, getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import { + configServiceRef, createBackendModule, + httpRouterServiceRef, loggerServiceRef, } from '@backstage/backend-plugin-api'; import { startTestBackend } from '@backstage/backend-test-utils'; @@ -26,13 +29,29 @@ import { TestEventPublisher, TestEventSubscriber, } from '@backstage/plugin-events-backend-test-utils'; +import express from 'express'; +import Router from 'express-promise-router'; +import request from 'supertest'; import { eventsPlugin } from './EventsPlugin'; describe('eventPlugin', () => { it('should be initialized properly', async () => { const eventBroker = new TestEventBroker(); const publisher = new TestEventPublisher(); - const subscriber = new TestEventSubscriber('sub', ['topicA']); + const subscriber = new TestEventSubscriber('sub', ['fake']); + + const config = new ConfigReader({ + events: { + http: { + topics: ['fake'], + }, + }, + }); + + const httpRouter = Router(); + httpRouter.use(express.json()); + httpRouter.use(errorHandler()); + const app = express().use(httpRouter); const testModule = createBackendModule({ pluginId: 'events', @@ -53,12 +72,26 @@ describe('eventPlugin', () => { await startTestBackend({ extensionPoints: [], - services: [[loggerServiceRef, getVoidLogger()]], + services: [ + [configServiceRef, config], + [httpRouterServiceRef, httpRouter], + [loggerServiceRef, getVoidLogger()], + ], features: [eventsPlugin(), testModule()], }); expect(publisher.eventBroker).toBe(eventBroker); expect(eventBroker.subscribed.length).toEqual(1); expect(eventBroker.subscribed[0]).toBe(subscriber); + + const response = await request(app) + .post('/http/fake') + .timeout(100) + .send({ test: 'fake' }); + expect(response.status).toBe(202); + + expect(eventBroker.published.length).toEqual(1); + expect(eventBroker.published[0].topic).toEqual('fake'); + expect(eventBroker.published[0].eventPayload).toEqual({ test: 'fake' }); }); }); diff --git a/plugins/events-backend/src/service/EventsPlugin.ts b/plugins/events-backend/src/service/EventsPlugin.ts index d2cac331f9..d5a38a7fd7 100644 --- a/plugins/events-backend/src/service/EventsPlugin.ts +++ b/plugins/events-backend/src/service/EventsPlugin.ts @@ -15,7 +15,9 @@ */ import { + configServiceRef, createBackendPlugin, + httpRouterServiceRef, loggerServiceRef, loggerToWinstonLogger, } from '@backstage/backend-plugin-api'; @@ -25,11 +27,15 @@ import { EventSubscriber, eventsExtensionPoint, EventsExtensionPoint, + HttpPostIngressOptions, } from '@backstage/plugin-events-node'; import { InMemoryEventBroker } from './InMemoryEventBroker'; +import Router from 'express-promise-router'; +import { HttpPostIngressEventPublisher } from './http'; class EventsExtensionPointImpl implements EventsExtensionPoint { #eventBroker: EventBroker | undefined; + #httpPostIngresses: HttpPostIngressOptions[] = []; #publishers: EventPublisher[] = []; #subscribers: EventSubscriber[] = []; @@ -49,6 +55,10 @@ class EventsExtensionPointImpl implements EventsExtensionPoint { this.#subscribers.push(...subscribers.flat()); } + addHttpPostIngress(options: HttpPostIngressOptions) { + this.#httpPostIngresses.push(options); + } + get eventBroker() { return this.#eventBroker; } @@ -60,6 +70,10 @@ class EventsExtensionPointImpl implements EventsExtensionPoint { get subscribers() { return this.#subscribers; } + + get httpPostIngresses() { + return this.#httpPostIngresses; + } } /** @@ -75,18 +89,42 @@ export const eventsPlugin = createBackendPlugin({ env.registerInit({ deps: { + config: configServiceRef, + httpRouter: httpRouterServiceRef, logger: loggerServiceRef, }, - async init({ logger }) { + async init({ config, httpRouter, logger }) { + const winstonLogger = loggerToWinstonLogger(logger); + const eventsRouter = Router(); + const router = Router(); + eventsRouter.use('/http', router); + + const ingresses = Object.fromEntries( + extensionPoint.httpPostIngresses.map(ingress => [ + ingress.topic, + ingress as Omit, + ]), + ); + + const http = HttpPostIngressEventPublisher.fromConfig({ + config, + logger: winstonLogger, + router, + ingresses, + }); + if (!extensionPoint.eventBroker) { - const winstonLogger = loggerToWinstonLogger(logger); extensionPoint.setEventBroker(new InMemoryEventBroker(winstonLogger)); } extensionPoint.eventBroker!.subscribe(extensionPoint.subscribers); - extensionPoint.publishers.forEach(publisher => - publisher.setEventBroker(extensionPoint.eventBroker!), - ); + [extensionPoint.publishers, http] + .flat() + .forEach(publisher => + publisher.setEventBroker(extensionPoint.eventBroker!), + ); + + httpRouter.use(eventsRouter); }, }); }, diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts new file mode 100644 index 0000000000..3d93e347b3 --- /dev/null +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.test.ts @@ -0,0 +1,225 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler, getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { TestEventBroker } from '@backstage/plugin-events-backend-test-utils'; +import express from 'express'; +import Router from 'express-promise-router'; +import request from 'supertest'; +import { HttpPostIngressEventPublisher } from './HttpPostIngressEventPublisher'; + +describe('HttpPostIngressEventPublisher', () => { + const logger = getVoidLogger(); + + it('should set up routes correctly', async () => { + const config = new ConfigReader({ + events: { + http: { + topics: ['testA'], + }, + }, + }); + + const router = Router(); + router.use(express.json()); + router.use(errorHandler()); + const app = express().use(router); + + const publisher = HttpPostIngressEventPublisher.fromConfig({ + config, + logger, + router, + ingresses: { + testB: {}, + }, + }); + + const eventBroker = new TestEventBroker(); + await publisher.setEventBroker(eventBroker); + + const notFoundResponse = await request(app) + .post('/unknown') + .timeout(100) + .send({ test: 'data' }); + expect(notFoundResponse.status).toBe(404); + + const response1 = await request(app) + .post('/testA') + .set('X-Custom-Header', 'test-value') + .timeout(100) + .send({ testA: 'data' }); + expect(response1.status).toBe(202); + + const response2 = await request(app) + .post('/testB') + .set('X-Custom-Header', 'test-value') + .timeout(100) + .send({ testB: 'data' }); + expect(response2.status).toBe(202); + + expect(eventBroker.published.length).toEqual(2); + expect(eventBroker.published[0].topic).toEqual('testA'); + expect(eventBroker.published[0].eventPayload).toEqual({ testA: 'data' }); + expect(eventBroker.published[0].metadata).toEqual( + expect.objectContaining({ + 'content-type': 'application/json', + 'x-custom-header': 'test-value', + }), + ); + expect(eventBroker.published[1].topic).toEqual('testB'); + expect(eventBroker.published[1].eventPayload).toEqual({ testB: 'data' }); + expect(eventBroker.published[1].metadata).toEqual( + expect.objectContaining({ + 'content-type': 'application/json', + 'x-custom-header': 'test-value', + }), + ); + }); + + it('with validator', async () => { + const config = new ConfigReader({ + events: { + http: { + topics: ['testA'], + }, + }, + }); + + const router = Router(); + router.use(express.json()); + router.use(errorHandler()); + const app = express().use(router); + + const publisher = HttpPostIngressEventPublisher.fromConfig({ + config, + logger, + router, + ingresses: { + testB: { + validator: async (req, context) => { + if (req.headers['x-test-signature'] === 'testB-signature') { + return; + } + + context.reject({ + status: 400, + payload: { + message: 'wrong signature', + }, + }); + }, + }, + testC: { + validator: async (req, context) => { + if (req.headers['x-test-signature'] === 'testC-signature') { + return; + } + + context.reject({ + status: 404, + // payload: {}, + }); + }, + }, + testD: { + validator: async (req, context) => { + if (req.headers['x-test-signature'] === 'testD-signature') { + return; + } + + context.reject({ + // status: 403, + // payload: {}, + }); + }, + }, + }, + }); + + const eventBroker = new TestEventBroker(); + await publisher.setEventBroker(eventBroker); + + const response1 = await request(app) + .post('/testA') + .timeout(100) + .send({ test: 'data' }); + expect(response1.status).toBe(202); + + const response2 = await request(app) + .post('/testB') + .timeout(100) + .send({ test: 'data' }); + expect(response2.status).toBe(400); + expect(response2.body).toEqual({ message: 'wrong signature' }); + + const response3 = await request(app) + .post('/testB') + .set('X-Test-Signature', 'wrong') + .timeout(100) + .send({ test: 'data' }); + expect(response3.status).toBe(400); + expect(response3.body).toEqual({ message: 'wrong signature' }); + + const response4 = await request(app) + .post('/testB') + .set('X-Test-Signature', 'testB-signature') + .timeout(100) + .send({ test: 'data' }); + expect(response4.status).toBe(202); + + const response5 = await request(app) + .post('/testC') + .timeout(100) + .send({ test: 'data' }); + expect(response5.status).toBe(404); + expect(response5.body).toEqual({}); + + const response6 = await request(app) + .post('/testD') + .timeout(100) + .send({ test: 'data' }); + expect(response6.status).toBe(403); + expect(response6.body).toEqual({}); + + expect(eventBroker.published.length).toEqual(2); + expect(eventBroker.published[0].topic).toEqual('testA'); + expect(eventBroker.published[0].eventPayload).toEqual({ test: 'data' }); + expect(eventBroker.published[1].topic).toEqual('testB'); + expect(eventBroker.published[1].eventPayload).toEqual({ test: 'data' }); + expect(eventBroker.published[1].metadata).toEqual( + expect.objectContaining({ + 'x-test-signature': 'testB-signature', + }), + ); + }); + + it('without configuration', async () => { + const config = new ConfigReader({}); + + const router = Router(); + router.use(express.json()); + router.use(errorHandler()); + + expect(() => + HttpPostIngressEventPublisher.fromConfig({ + config, + logger, + router, + }), + ).not.toThrow(); + }); +}); diff --git a/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts new file mode 100644 index 0000000000..64b7140b54 --- /dev/null +++ b/plugins/events-backend/src/service/http/HttpPostIngressEventPublisher.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { + EventBroker, + EventPublisher, + HttpPostIngressOptions, + RequestValidator, +} from '@backstage/plugin-events-node'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { RequestValidationContextImpl } from './validation'; + +/** + * Publishes events received from their origin (e.g., webhook events from an SCM system) + * via HTTP POST endpoint and passes the request body as event payload to the registered subscribers. + * + * @public + */ +// TODO(pjungermann): add prom metrics? (see plugins/catalog-backend/src/util/metrics.ts, etc.) +export class HttpPostIngressEventPublisher implements EventPublisher { + private eventBroker?: EventBroker; + + static fromConfig(env: { + config: Config; + ingresses?: { [topic: string]: Omit }; + logger: Logger; + router: express.Router; + }): HttpPostIngressEventPublisher { + const topics = + env.config.getOptionalStringArray('events.http.topics') ?? []; + + const ingresses = env.ingresses ?? {}; + topics.forEach(topic => { + // don't overwrite topic settings + // (e.g., added at the config as well as argument) + if (!ingresses[topic]) { + ingresses[topic] = {}; + } + }); + + return new HttpPostIngressEventPublisher(env.logger, env.router, ingresses); + } + + private constructor( + private logger: Logger, + router: express.Router, + ingresses: { [topic: string]: Omit }, + ) { + router.use(this.createRouter(ingresses)); + } + + async setEventBroker(eventBroker: EventBroker): Promise { + this.eventBroker = eventBroker; + } + + private createRouter(ingresses: { + [topic: string]: Omit; + }): express.Router { + const router = Router(); + router.use(express.json()); + + Object.keys(ingresses).forEach(topic => + this.addRouteForTopic(router, topic, ingresses[topic].validator), + ); + + router.use(errorHandler()); + return router; + } + + private addRouteForTopic( + router: express.Router, + topic: string, + validator?: RequestValidator, + ): void { + const path = `/${topic}`; + + router.post(path, async (request, response) => { + const context = new RequestValidationContextImpl(); + await validator?.(request, context); + if (context.wasRejected()) { + response + .status(context.rejectionDetails!.status) + .json(context.rejectionDetails!.payload); + return; + } + + const eventPayload = request.body; + await this.eventBroker!.publish({ + topic, + eventPayload, + metadata: request.headers, + }); + + response.status(202).json({ status: 'accepted' }); + }); + + // TODO(pjungermann): We don't really know the externally defined path prefix here, + // however it is more useful for users to have it. Is there a better way? + this.logger.info(`Registered /api/events/http${path} to receive events`); + } +} diff --git a/plugins/events-backend/src/service/http/index.ts b/plugins/events-backend/src/service/http/index.ts new file mode 100644 index 0000000000..fe71e49dfa --- /dev/null +++ b/plugins/events-backend/src/service/http/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { HttpPostIngressEventPublisher } from './HttpPostIngressEventPublisher'; +export * from './validation'; diff --git a/plugins/events-backend/src/service/http/validation/RequestValidationContextImpl.test.ts b/plugins/events-backend/src/service/http/validation/RequestValidationContextImpl.test.ts new file mode 100644 index 0000000000..b2f1485f65 --- /dev/null +++ b/plugins/events-backend/src/service/http/validation/RequestValidationContextImpl.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RequestValidationContextImpl } from './RequestValidationContextImpl'; + +describe('RequestValidationContextImpl', () => { + it('not rejected', () => { + const context = new RequestValidationContextImpl(); + + expect(context.wasRejected()).toBe(false); + expect(context.rejectionDetails).toBeUndefined(); + }); + + it('reject without details', () => { + const context = new RequestValidationContextImpl(); + + context.reject(); + + expect(context.wasRejected()).toBe(true); + expect(context.rejectionDetails).not.toBeUndefined(); + expect(context.rejectionDetails!.status).toBe(403); + expect(context.rejectionDetails!.payload).toEqual({}); + }); + + it('reject with partial details', () => { + const context = new RequestValidationContextImpl(); + + context.reject({ status: 404 }); + + expect(context.wasRejected()).toBe(true); + expect(context.rejectionDetails).not.toBeUndefined(); + expect(context.rejectionDetails!.status).toBe(404); + expect(context.rejectionDetails!.payload).toEqual({}); + }); + + it('reject with details', () => { + const context = new RequestValidationContextImpl(); + + context.reject({ + status: 403, + payload: { message: 'invalid signature' }, + }); + + expect(context.wasRejected()).toBe(true); + expect(context.rejectionDetails).not.toBeUndefined(); + expect(context.rejectionDetails!.status).toBe(403); + expect(context.rejectionDetails!.payload).toEqual({ + message: 'invalid signature', + }); + }); +}); diff --git a/plugins/events-backend/src/service/http/validation/RequestValidationContextImpl.ts b/plugins/events-backend/src/service/http/validation/RequestValidationContextImpl.ts new file mode 100644 index 0000000000..3dc607a683 --- /dev/null +++ b/plugins/events-backend/src/service/http/validation/RequestValidationContextImpl.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RequestRejectionDetails, + RequestValidationContext, +} from '@backstage/plugin-events-node'; + +export class RequestValidationContextImpl implements RequestValidationContext { + #rejectionDetails: RequestRejectionDetails | undefined; + + reject(details?: Partial): void { + this.#rejectionDetails = { + status: details?.status ?? 403, + payload: details?.payload ?? {}, + }; + } + + wasRejected(): boolean { + return this.#rejectionDetails !== undefined; + } + + get rejectionDetails() { + return this.#rejectionDetails; + } +} diff --git a/plugins/events-backend/src/service/http/validation/index.ts b/plugins/events-backend/src/service/http/validation/index.ts new file mode 100644 index 0000000000..7513014906 --- /dev/null +++ b/plugins/events-backend/src/service/http/validation/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { RequestValidationContextImpl } from './RequestValidationContextImpl'; diff --git a/plugins/events-node/api-report.md b/plugins/events-node/api-report.md index e0b34aa204..1054211cbc 100644 --- a/plugins/events-node/api-report.md +++ b/plugins/events-node/api-report.md @@ -4,6 +4,7 @@ ```ts import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { Request as Request_2 } from 'express'; // @public export interface EventBroker { @@ -42,6 +43,8 @@ export abstract class EventRouter implements EventPublisher, EventSubscriber { // @alpha (undocumented) export interface EventsExtensionPoint { + // (undocumented) + addHttpPostIngress(options: HttpPostIngressOptions): void; // (undocumented) addPublishers( ...publishers: Array> @@ -63,6 +66,33 @@ export interface EventSubscriber { supportsEventTopics(): string[]; } +// @public (undocumented) +export interface HttpPostIngressOptions { + // (undocumented) + topic: string; + // (undocumented) + validator?: RequestValidator; +} + +// @public +export interface RequestRejectionDetails { + // (undocumented) + payload: unknown; + // (undocumented) + status: number; +} + +// @public +export interface RequestValidationContext { + reject(details?: Partial): void; +} + +// @public +export type RequestValidator = ( + request: Request_2, + context: RequestValidationContext, +) => Promise; + // @public export abstract class SubTopicEventRouter extends EventRouter { protected constructor(topic: string); diff --git a/plugins/events-node/src/api/http/HttpPostIngressOptions.ts b/plugins/events-node/src/api/http/HttpPostIngressOptions.ts new file mode 100644 index 0000000000..d9f005d84d --- /dev/null +++ b/plugins/events-node/src/api/http/HttpPostIngressOptions.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RequestValidator } from './validation'; + +/** + * @public + */ +export interface HttpPostIngressOptions { + topic: string; + validator?: RequestValidator; +} diff --git a/plugins/events-node/src/api/http/index.ts b/plugins/events-node/src/api/http/index.ts new file mode 100644 index 0000000000..9206819a4a --- /dev/null +++ b/plugins/events-node/src/api/http/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { HttpPostIngressOptions } from './HttpPostIngressOptions'; +export * from './validation'; diff --git a/plugins/events-node/src/api/http/validation/RequestRejectionDetails.ts b/plugins/events-node/src/api/http/validation/RequestRejectionDetails.ts new file mode 100644 index 0000000000..1eca094367 --- /dev/null +++ b/plugins/events-node/src/api/http/validation/RequestRejectionDetails.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Details for how to respond to the rejection + * of the received HTTP request transmitting an event payload. + * + * @public + */ +export interface RequestRejectionDetails { + status: number; + payload: unknown; +} diff --git a/plugins/events-node/src/api/http/validation/RequestValidationContext.ts b/plugins/events-node/src/api/http/validation/RequestValidationContext.ts new file mode 100644 index 0000000000..eedb6f1f96 --- /dev/null +++ b/plugins/events-node/src/api/http/validation/RequestValidationContext.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RequestRejectionDetails } from './RequestRejectionDetails'; + +/** + * Passed context for the validation + * at which rejections can be expressed. + * + * @public + */ +export interface RequestValidationContext { + /** + * Rejects the validated request + * + * @param details - Optional details about the rejection which will be provided to the sender. + */ + reject(details?: Partial): void; +} diff --git a/plugins/events-node/src/api/http/validation/RequestValidator.ts b/plugins/events-node/src/api/http/validation/RequestValidator.ts new file mode 100644 index 0000000000..b419b855cf --- /dev/null +++ b/plugins/events-node/src/api/http/validation/RequestValidator.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Request } from 'express'; +import { RequestValidationContext } from './RequestValidationContext'; + +/** + * Validator used to check the received HTTP request + * transmitting an event payload. + * + * E.g., it can be used for signature verification like + * for GitHub webhook events + * (https://docs.github.com/en/developers/webhooks-and-events/webhooks/creating-webhooks#secret) + * or other kinds of checks. + * + * @public + */ +export type RequestValidator = ( + request: Request, + context: RequestValidationContext, +) => Promise; diff --git a/plugins/events-node/src/api/http/validation/index.ts b/plugins/events-node/src/api/http/validation/index.ts new file mode 100644 index 0000000000..95f2d474eb --- /dev/null +++ b/plugins/events-node/src/api/http/validation/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { RequestRejectionDetails } from './RequestRejectionDetails'; +export type { RequestValidationContext } from './RequestValidationContext'; +export type { RequestValidator } from './RequestValidator'; diff --git a/plugins/events-node/src/api/index.ts b/plugins/events-node/src/api/index.ts index 22b51c191d..91711c0e38 100644 --- a/plugins/events-node/src/api/index.ts +++ b/plugins/events-node/src/api/index.ts @@ -19,4 +19,5 @@ export type { EventParams } from './EventParams'; export type { EventPublisher } from './EventPublisher'; export { EventRouter } from './EventRouter'; export type { EventSubscriber } from './EventSubscriber'; +export * from './http'; export { SubTopicEventRouter } from './SubTopicEventRouter'; diff --git a/plugins/events-node/src/extensions.ts b/plugins/events-node/src/extensions.ts index f935e85be8..945d86b49c 100644 --- a/plugins/events-node/src/extensions.ts +++ b/plugins/events-node/src/extensions.ts @@ -15,7 +15,12 @@ */ import { createExtensionPoint } from '@backstage/backend-plugin-api'; -import { EventBroker, EventPublisher, EventSubscriber } from './api'; +import { + EventBroker, + EventPublisher, + EventSubscriber, + HttpPostIngressOptions, +} from './api'; /** * @alpha @@ -30,6 +35,8 @@ export interface EventsExtensionPoint { addSubscribers( ...subscribers: Array> ): void; + + addHttpPostIngress(options: HttpPostIngressOptions): void; } /** diff --git a/yarn.lock b/yarn.lock index c96addfbcc..198ab4acb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5549,8 +5549,13 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/plugin-events-backend-test-utils": "workspace:^" "@backstage/plugin-events-node": "workspace:^" + "@types/express": ^4.17.6 + express: ^4.17.1 + express-promise-router: ^4.1.0 + supertest: ^6.1.3 winston: ^3.2.1 languageName: unknown linkType: soft