diff --git a/.changeset/stupid-clocks-return.md b/.changeset/stupid-clocks-return.md new file mode 100644 index 0000000000..84b061334f --- /dev/null +++ b/.changeset/stupid-clocks-return.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-events-backend-module-gitlab': patch +--- + +Add `createGitlabTokenValidator(config)` which can be used +to create a validator used at an ingress for topic `gitlab`. + +On top, there is a new `gitlabWebhookEventsModule` for the new backend plugin API +which auto-registers the `HttpPostIngress` for topic `gitlab` incl. the validator. + +Please find more information at +https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-gitlab/README.md. diff --git a/plugins/events-backend-module-gitlab/README.md b/plugins/events-backend-module-gitlab/README.md index dfbfcd9f4a..dbe9f7bfe3 100644 --- a/plugins/events-backend-module-gitlab/README.md +++ b/plugins/events-backend-module-gitlab/README.md @@ -40,3 +40,31 @@ Add the event router to the `EventsBackend`: + .addSubscribers(gitlabEventRouter); // [...] ``` + +### Token Validator + +Add the token validator for the topic `gitlab`: + +```diff +// at packages/backend/src/plugins/events.ts ++ import { createGitlabTokenValidator } from '@backstage/plugin-events-backend-module-gitlab'; +// [...] + const http = HttpPostIngressEventPublisher.fromConfig({ + config: env.config, + ingresses: { ++ gitlab: { ++ validator: createGitlabTokenValidator(env.config), ++ }, + }, + logger: env.logger, + }); +``` + +Additionally, you need to add the configuration: + +```yaml +events: + modules: + gitlab: + webhookSecret: your-secret-token +``` diff --git a/plugins/events-backend-module-gitlab/api-report.md b/plugins/events-backend-module-gitlab/api-report.md index 8b5183a8d0..8f130902a2 100644 --- a/plugins/events-backend-module-gitlab/api-report.md +++ b/plugins/events-backend-module-gitlab/api-report.md @@ -4,9 +4,14 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; import { EventParams } from '@backstage/plugin-events-node'; +import { RequestValidator } from '@backstage/plugin-events-node'; import { SubTopicEventRouter } from '@backstage/plugin-events-node'; +// @public +export function createGitlabTokenValidator(config: Config): RequestValidator; + // @public export class GitlabEventRouter extends SubTopicEventRouter { constructor(); @@ -18,4 +23,7 @@ export class GitlabEventRouter extends SubTopicEventRouter { export const gitlabEventRouterEventsModule: ( options?: undefined, ) => BackendFeature; + +// @alpha +export const gitlabWebhookEventsModule: (options?: undefined) => BackendFeature; ``` diff --git a/plugins/events-backend-module-gitlab/config.d.ts b/plugins/events-backend-module-gitlab/config.d.ts new file mode 100644 index 0000000000..6d02533845 --- /dev/null +++ b/plugins/events-backend-module-gitlab/config.d.ts @@ -0,0 +1,36 @@ +/* + * 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?: { + modules?: { + /** + * events-backend-module-gitlab plugin configuration. + */ + gitlab?: { + /** + * Secret token for webhook requests used to verify tokens. + * + * See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#validate-payloads-by-using-a-secret-token + * for more details. + * + * @visibility secret + */ + webhookSecret?: string; + }; + }; + }; +} diff --git a/plugins/events-backend-module-gitlab/package.json b/plugins/events-backend-module-gitlab/package.json index c13f9b5289..0c77d7a30e 100644 --- a/plugins/events-backend-module-gitlab/package.json +++ b/plugins/events-backend-module-gitlab/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", + "@backstage/config": "workspace:^", "@backstage/plugin-events-node": "workspace:^", "winston": "^3.2.1" }, @@ -35,6 +36,8 @@ }, "files": [ "alpha", + "config.d.ts", "dist" - ] + ], + "configSchema": "config.d.ts" } diff --git a/plugins/events-backend-module-gitlab/src/http/createGitlabTokenValidator.test.ts b/plugins/events-backend-module-gitlab/src/http/createGitlabTokenValidator.test.ts new file mode 100644 index 0000000000..33e677450e --- /dev/null +++ b/plugins/events-backend-module-gitlab/src/http/createGitlabTokenValidator.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { + RequestDetails, + RequestRejectionDetails, + RequestValidationContext, +} from '@backstage/plugin-events-node'; +import { createGitlabTokenValidator } from './createGitlabTokenValidator'; + +class TestContext implements RequestValidationContext { + #details?: Partial; + + reject(details?: Partial): void { + this.#details = details; + } + + get details() { + return this.#details; + } +} + +describe('createGitlabTokenValidator', () => { + const validToken = 'valid-token'; + const configWithoutSecret = new ConfigReader({}); + const configWithSecret = new ConfigReader({ + events: { + modules: { + gitlab: { + webhookSecret: validToken, + }, + }, + }, + }); + + const requestWithToken = (token: string | undefined) => { + return { + body: undefined, + headers: { + 'x-gitlab-token': token, + }, + } as RequestDetails; + }; + + it('no secret configured, throw error', async () => { + expect(() => createGitlabTokenValidator(configWithoutSecret)).toThrow( + "Missing required config value at 'events.modules.gitlab.webhookSecret'", + ); + }); + + it('secret configured, reject request without token', async () => { + const request = requestWithToken(undefined); + const context = new TestContext(); + + const validator = createGitlabTokenValidator(configWithSecret); + await validator(request, context); + + expect(context.details).not.toBeUndefined(); + expect(context.details?.status).toBe(403); + expect(context.details?.payload).toEqual({ message: 'invalid token' }); + }); + + it('secret configured, reject request with invalid token', async () => { + const request = requestWithToken('invalid-token'); + const context = new TestContext(); + + const validator = createGitlabTokenValidator(configWithSecret); + await validator(request, context); + + expect(context.details).not.toBeUndefined(); + expect(context.details?.status).toBe(403); + expect(context.details?.payload).toEqual({ message: 'invalid token' }); + }); + + it('secret configured, accept request with valid token', async () => { + const request = requestWithToken(validToken); + const context = new TestContext(); + + const validator = createGitlabTokenValidator(configWithSecret); + await validator(request, context); + + expect(context.details).toBeUndefined(); + }); +}); diff --git a/plugins/events-backend-module-gitlab/src/http/createGitlabTokenValidator.ts b/plugins/events-backend-module-gitlab/src/http/createGitlabTokenValidator.ts new file mode 100644 index 0000000000..9ebc054194 --- /dev/null +++ b/plugins/events-backend-module-gitlab/src/http/createGitlabTokenValidator.ts @@ -0,0 +1,49 @@ +/* + * 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 { Config } from '@backstage/config'; +import { + RequestDetails, + RequestValidationContext, + RequestValidator, +} from '@backstage/plugin-events-node'; + +/** + * Validates a configured secret token against the token received with the `x-gitlab-token` header. + * + * See https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#validate-payloads-by-using-a-secret-token + * for more details. + * + * @param config - root config + * @public + */ +export function createGitlabTokenValidator(config: Config): RequestValidator { + const secret = config.getString('events.modules.gitlab.webhookSecret'); + + return async ( + request: RequestDetails, + context: RequestValidationContext, + ): Promise => { + const token = request.headers['x-gitlab-token'] as string | undefined; + + if (secret !== token) { + context.reject({ + status: 403, + payload: { message: 'invalid token' }, + }); + } + }; +} diff --git a/plugins/events-backend-module-gitlab/src/index.ts b/plugins/events-backend-module-gitlab/src/index.ts index ea96244df4..859a6c69c0 100644 --- a/plugins/events-backend-module-gitlab/src/index.ts +++ b/plugins/events-backend-module-gitlab/src/index.ts @@ -16,10 +16,12 @@ /** * The module "gitlab" for the Backstage backend plugin "events-backend" - * adding an event router for GitLab. + * adding an event router and token validator for GitLab. * * @packageDocumentation */ +export { createGitlabTokenValidator } from './http/createGitlabTokenValidator'; export { GitlabEventRouter } from './router/GitlabEventRouter'; export { gitlabEventRouterEventsModule } from './service/GitlabEventRouterEventsModule'; +export { gitlabWebhookEventsModule } from './service/GitlabWebhookEventsModule'; diff --git a/plugins/events-backend-module-gitlab/src/service/GitlabWebhookEventsModule.test.ts b/plugins/events-backend-module-gitlab/src/service/GitlabWebhookEventsModule.test.ts new file mode 100644 index 0000000000..594be74ab8 --- /dev/null +++ b/plugins/events-backend-module-gitlab/src/service/GitlabWebhookEventsModule.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { configServiceRef } from '@backstage/backend-plugin-api'; +import { startTestBackend } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { + eventsExtensionPoint, + HttpPostIngressOptions, + RequestDetails, +} from '@backstage/plugin-events-node'; +import { gitlabWebhookEventsModule } from './GitlabWebhookEventsModule'; + +describe('gitlabWebhookEventsModule', () => { + const requestWithToken = (token?: string) => { + return { + body: undefined, + headers: { + 'x-gitlab-token': token, + }, + } as RequestDetails; + }; + + it('should be correctly wired and set up', async () => { + let addedIngress: HttpPostIngressOptions | undefined; + const extensionPoint = { + addHttpPostIngress: (ingress: any) => { + addedIngress = ingress; + }, + }; + + const config = new ConfigReader({ + events: { + modules: { + gitlab: { + webhookSecret: 'test-secret', + }, + }, + }, + }); + + await startTestBackend({ + extensionPoints: [[eventsExtensionPoint, extensionPoint]], + services: [[configServiceRef, config]], + features: [gitlabWebhookEventsModule()], + }); + + expect(addedIngress).not.toBeUndefined(); + expect(addedIngress?.topic).toEqual('gitlab'); + expect(addedIngress?.validator).not.toBeUndefined(); + const rejections: any[] = []; + const context = { + reject: (details: { status?: any; payload?: any }) => { + rejections.push(details); + }, + }; + await addedIngress!.validator!(requestWithToken(), context); + expect(rejections).toEqual([ + { + status: 403, + payload: { + message: 'invalid token', + }, + }, + ]); + await addedIngress!.validator!(requestWithToken('test-secret'), context); + expect(rejections.length).toEqual(1); + }); +}); diff --git a/plugins/events-backend-module-gitlab/src/service/GitlabWebhookEventsModule.ts b/plugins/events-backend-module-gitlab/src/service/GitlabWebhookEventsModule.ts new file mode 100644 index 0000000000..562fdb9cf5 --- /dev/null +++ b/plugins/events-backend-module-gitlab/src/service/GitlabWebhookEventsModule.ts @@ -0,0 +1,50 @@ +/* + * 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 { + configServiceRef, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { eventsExtensionPoint } from '@backstage/plugin-events-node'; +import { createGitlabTokenValidator } from '../http/createGitlabTokenValidator'; + +/** + * Module for the events-backend plugin, + * registering an HTTP POST ingress with request validator + * which verifies the webhook token based on a secret. + * + * Registers the {@link GitlabEventRouter}. + * + * @alpha + */ +export const gitlabWebhookEventsModule = createBackendModule({ + pluginId: 'events', + moduleId: 'gitlabWebhook', + register(env) { + env.registerInit({ + deps: { + config: configServiceRef, + events: eventsExtensionPoint, + }, + async init({ config, events }) { + events.addHttpPostIngress({ + topic: 'gitlab', + validator: createGitlabTokenValidator(config), + }); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index 28bb925fe4..2da96e27bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5264,6 +5264,7 @@ __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:^" supertest: ^6.1.3