From a0108c497749566fe27dfe04d9a91e6a1c139ea4 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Wed, 22 Mar 2023 15:49:20 +0100 Subject: [PATCH 01/16] feat(plugins): Update frontend and backend badges plugin to implement an obfuscation feature to limit security risks when opening badges to public Signed-off-by: Rbillon59 --- .changeset/extragavent-fast-fly.md | 8 + plugins/badges-backend/README.md | 49 +++ plugins/badges-backend/package.json | 18 +- plugins/badges-backend/src/service/router.ts | 412 +++++++++++++++--- .../src/service/standaloneServer.ts | 24 +- .../src/tests/router-obfuscated.test.ts | 348 +++++++++++++++ .../src/{service => tests}/router.test.ts | 177 +++++--- plugins/badges/README.md | 33 ++ plugins/badges/src/api/BadgesClient.ts | 92 +++- plugins/badges/src/plugin.ts | 15 +- 10 files changed, 1045 insertions(+), 131 deletions(-) create mode 100644 .changeset/extragavent-fast-fly.md create mode 100644 plugins/badges-backend/src/tests/router-obfuscated.test.ts rename plugins/badges-backend/src/{service => tests}/router.test.ts (51%) diff --git a/.changeset/extragavent-fast-fly.md b/.changeset/extragavent-fast-fly.md new file mode 100644 index 0000000000..ffac2303a1 --- /dev/null +++ b/.changeset/extragavent-fast-fly.md @@ -0,0 +1,8 @@ +--- +'@backstage/badges': patch +'@backstage/badges-backend': patch +--- + +Fixing badges-backend plugin to get a token from the TokenManager instead of parsing the request header. Hence, it's now possible to disable the authMiddleware for the badges-backend plugin to expose publicly the badges. + +Implementing an obfuscation feature to protect an open badges endpoint from being enumerated. The feature is disabled by default and the change is compatible with the previous version. diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 63e43c6692..6444a5f828 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -36,6 +36,8 @@ export default async function createPlugin( config: env.config, discovery: env.discovery, badgeFactories: createDefaultBadgeFactories(), + tokenManager: env.tokenManager, + logger: env.logger, }); } ``` @@ -112,11 +114,42 @@ export const createMyCustomBadgeFactories = (): BadgeFactories => ({ }); ``` +### Badge obfuscation + +When you enable the obfuscation feature, the badges backend will obfuscate the entity names in the badge link. It's useful when you want your badges to be visible to the public, but you don't want to expose the entity names and also to protect your entity names from being enumerated. + +To enable the obfuscation you need to activate the `obfuscation` feature in the `app-config.yaml`: + +```yaml +app: + badges: + obfuscate: 'true' +``` + +Also you need to provide the [salt]() in the `app-config.yaml`: + +```yaml +custom: + badges-backend: + salt: # required + cacheTimeToLive: 10 # minutes (optional) +``` + +Any string can be used as a salt, but it's recommended to use a long random string to increase entropy. You can generate a random string using the following command: + +```bash +openssl rand -hex 32 +``` + +> Note: The salt is used to obfuscate the entity names, so if you change the salt, the entity names will be obfuscated differently and the already established badges (in Github repositories for examples) will need to be updated. + ## API The badges backend api exposes two main endpoints for entity badges. The `/badges` prefix is arbitrary, and the default for the example backend. +### If obfuscation is disabled (default or apps.badges.obfuscate: "false") + - `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges for a particular entity, in json format. See [BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts) @@ -126,6 +159,22 @@ The badges backend api exposes two main endpoints for entity badges. The an SVG image. If the `accept` request header prefers `application/json` the badge spec as JSON will be returned instead of the image. +### If obfuscation is enabled (apps.badges.obfuscate: "true") + +- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity + hash from name, namespace, kind. + +> Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. It meant to be called from the frontend plugin. + +- `/badges/entity/:entityHash/:badgeId` Get the entity badge as an SVG image. If + the `accept` request header prefers `application/json` the badge spec as JSON + will be returned instead of the image. + +- `/badge/entity/:entityHash/badge-specs` List all defined badges for a + particular entity, in json format. See + [BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts) + from the frontend plugin for a type declaration. + ## Links - [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index b3fd8ba49c..922f34373d 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -38,18 +38,32 @@ "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", "@types/express": "^4.17.6", "badge-maker": "^3.3.0", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "lodash": "^4.17.21", + "supertest": "^6.3.3", "winston": "^3.2.1", "yn": "^4.0.0" }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, "devDependencies": { + "@backstage/catalog-client": "^1.3.1", "@backstage/cli": "workspace:^", - "@types/supertest": "^2.0.8", - "supertest": "^6.1.3" + "@backstage/core-app-api": "^1.5.0", + "@backstage/dev-utils": "^1.0.12", + "@backstage/test-utils": "^1.2.5", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "@types/node": "*", + "cross-fetch": "^3.1.5", + "msw": "^0.49.0" }, "files": [ "dist" diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index e5caa5cd2a..5944975a9b 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -19,13 +19,18 @@ import Router from 'express-promise-router'; import { errorHandler, PluginEndpointDiscovery, + TokenManager, } from '@backstage/backend-common'; import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; import { BadgeContext, BadgeFactories } from '../types'; - +import { isEmpty, isNil } from 'lodash'; +import crypto from 'crypto'; +import { Logger } from 'winston'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; /** @public */ export interface RouterOptions { badgeBuilder?: BadgeBuilder; @@ -33,6 +38,9 @@ export interface RouterOptions { catalog?: CatalogApi; config: Config; discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + logger: Logger; + identity: IdentityApi; } /** @public */ @@ -46,67 +54,121 @@ export async function createRouter( new DefaultBadgeBuilder(options.badgeFactories || {}); const router = Router(); - router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { - const { namespace, kind, name } = req.params; - const entity = await catalog.getEntityByRef( - { namespace, kind, name }, - { - token: getBearerToken(req.headers.authorization), - }, - ); - if (!entity) { - throw new NotFoundError( - `No ${kind} entity in ${namespace} named "${name}"`, - ); + const tokenManager = options.tokenManager; + let lookupTable: Map< + string, + { + name: string; + namespace: string; + kind: string; + creationDate: number; } + > = new Map(); - const specs = []; - for (const badgeInfo of await badgeBuilder.getBadges()) { - const context: BadgeContext = { - badgeUrl: await getBadgeUrl( + // Check if the users have enabled the obfuscation of the entity name + const obfuscate: boolean = + options.config.getOptionalBoolean('app.badges.obfuscate') ?? false; + + if (obfuscate) { + options.logger.info('Badges obfuscation is enabled'); + + // Use the generated hash instead of the triplet namespace/kind/name + router.get('/entity/:entityHash/badge-specs', async (req, res) => { + const { entityHash } = req.params; + + if (isLookupTableToRefresh(lookupTable)) { + lookupTable = await generateHashLookupTable(); + } + // Try to match the queried hash with a key in the lookup table + const entityInfo = await getEntityInfoFromLookupTable(entityHash); + + // If a mapping is found, map name, namespace and kind + const name = entityInfo.name; + const namespace = entityInfo.namespace; + const kind = entityInfo.kind; + const token = await tokenManager.getToken(); + + // Query the catalog with the name, namespace, kind to get the entity informations + const entity = await catalog.getEntityByRef( + { namespace, kind, name, - badgeInfo.id, - options, - ), - config: options.config, - entity, - }; - - const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context }); - specs.push(badge); - } - - res.status(200).json(specs); - }); - - router.get( - '/entity/:namespace/:kind/:name/badge/:badgeId', - async (req, res) => { - const { namespace, kind, name, badgeId } = req.params; - const entity = await catalog.getEntityByRef( - { namespace, kind, name }, - { - token: getBearerToken(req.headers.authorization), }, + token, ); - if (!entity) { + if (isNil(entity)) { throw new NotFoundError( `No ${kind} entity in ${namespace} named "${name}"`, ); } + // Create the badge specs + const specs = []; + for (const badgeInfo of await badgeBuilder.getBadges()) { + const context: BadgeContext = { + badgeUrl: await getBadgeObfuscatedUrl( + namespace, + kind, + name, + badgeInfo.id, + ), + config: options.config, + entity, + }; + + const badge = await badgeBuilder.createBadgeJson({ + badgeInfo, + context, + }); + specs.push(badge); + } + + res.status(200).json(specs); + }); + + // Use the generated hash instead of the triplet namespace/kind/name + router.get('/entity/:entityHash/:badgeId', async (req, res) => { + const { entityHash, badgeId } = req.params; + + if (isLookupTableToRefresh(lookupTable)) { + lookupTable = await generateHashLookupTable(); + } + + // Try to match the queried hash with a key in the lookup table + const entityInfo = await getEntityInfoFromLookupTable(entityHash); + + // If a mapping is found, map name, namespace and kind + const name = entityInfo.name; + const namespace = entityInfo.namespace; + const kind = entityInfo.kind; + const token = await tokenManager.getToken(); + const entity = await catalog.getEntityByRef( + { + namespace, + kind, + name, + }, + token, + ); + if (isNil(entity)) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + res.sendStatus(404), + ); + } + let format = req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; if (req.query.format === 'json') { format = 'application/json'; } + // Generate the badge URL for the different types of badgeId const badgeOptions = { badgeInfo: { id: badgeId }, context: { - badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options), + badgeUrl: await getBadgeObfuscatedUrl(namespace, kind, name, badgeId), config: options.config, entity, }, @@ -125,25 +187,255 @@ export async function createRouter( res.setHeader('Content-Type', format); res.status(200).send(data); - }, - ); + }); - router.use(errorHandler()); + // Generate the hash for queried the namespace/kind/name triplet + router.get( + '/entity/:namespace/:kind/:name/obfuscated', + function authenticate(req, res, next) { + const token = + getBearerTokenFromAuthorizationHeader(req.headers.authorization) || + (req.cookies?.token as string | undefined); - return router; -} - -async function getBadgeUrl( - namespace: string, - kind: string, - name: string, - badgeId: string, - options: RouterOptions, -): Promise { - const baseUrl = await options.discovery.getExternalBaseUrl('badges'); - return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; -} - -function getBearerToken(header?: string): string | undefined { - return header?.match(/Bearer\s+(\S+)/i)?.[1]; + if (!token) { + res.status(401).send('Unauthorized'); + return; + } + + try { + req.user = options.identity.getIdentity({ request: req }); + next(); + } catch (error) { + options.tokenManager.authenticate(token.toString()); + next(error); + } + }, + async (req, res) => { + const { namespace, kind, name } = req.params; + const salt = options.config.getString('custom.badges-backend.salt'); + const hash = crypto + .createHash('sha256') + .update(`${kind}:${namespace}:${name}:${salt}`) + .digest('hex'); + + return res.status(200).json({ hash }); + }, + ); + + router.use(errorHandler()); + + return router; + + // If the obfuscation is disabled, use the previously implemented routes + // eslint-disable-next-line no-else-return + } else { + router.get( + '/entity/:namespace/:kind/:name/badge-specs', + async (req, res) => { + const token = await tokenManager.getToken(); + const { namespace, kind, name } = req.params; + const entity = await catalog.getEntityByRef( + { namespace, kind, name }, + token, + ); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } + + const specs = []; + for (const badgeInfo of await badgeBuilder.getBadges()) { + const context: BadgeContext = { + badgeUrl: await getBadgeUrl(namespace, kind, name, badgeInfo.id), + config: options.config, + entity, + }; + + const badge = await badgeBuilder.createBadgeJson({ + badgeInfo, + context, + }); + specs.push(badge); + } + + res.status(200).json(specs); + }, + ); + + router.get( + '/entity/:namespace/:kind/:name/badge/:badgeId', + async (req, res) => { + const { namespace, kind, name, badgeId } = req.params; + const token = await tokenManager.getToken(); + const entity = await catalog.getEntityByRef( + { namespace, kind, name }, + token, + ); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } + + let format = + req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; + if (req.query.format === 'json') { + format = 'application/json'; + } + + const badgeOptions = { + badgeInfo: { id: badgeId }, + context: { + badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId), + config: options.config, + entity, + }, + }; + + let data: string; + if (format === 'application/json') { + data = JSON.stringify( + await badgeBuilder.createBadgeJson(badgeOptions), + null, + 2, + ); + } else { + data = await badgeBuilder.createBadgeSvg(badgeOptions); + } + + res.setHeader('Content-Type', format); + res.status(200).send(data); + }, + ); + + router.use(errorHandler()); + + return router; + } + + // This function generate a table that maps the hash to the namespace/kind/name triplet + async function generateHashLookupTable() { + const logger = options.logger.child({ service: 'badges-backend' }); + const generationStartTimestamp = Date.now(); + logger.info('Start Generating lookup table'); + const token = await options.tokenManager.getToken(); + + // The salt is used to increase the hash entropy + const salt = options.config.getString('custom.badges-backend.salt'); + + // Get all the entities in the catalog of kind "Component" + const entitiesList = await catalog.getEntities( + { + filter: { + kind: 'Component', + }, + }, + token, + ); + if (isEmpty(entitiesList)) { + throw new NotFoundError(`No entities found`); + } + + // For each entity, generate the hash with the triplet kind:namespace:name then add the salt. Finally add it to the lookup table + entitiesList.items.map(async entity => { + const name = entity.metadata.name.toLocaleLowerCase(); + const creationDate: number = Date.now(); + const namespace = + entity.metadata.namespace?.toLocaleLowerCase() ?? 'default'; + const kind = entity.kind.toLocaleLowerCase(); + const hash = crypto + .createHash('sha256') + .update(`${kind}:${namespace}:${name}:${salt}`) + .digest('hex'); + + lookupTable.set(hash, { name, namespace, kind, creationDate }); + + return lookupTable; + }); + + // Monitor the lookup generation time and log it + logger.info( + `Finished generating lookup table in ${ + Date.now() - generationStartTimestamp + } ms`, + ); + // Monitor the lookup table size in byte + logger.info(`Lookup table size: ${lookupTable.size} entries`); + + return lookupTable; + } + + // This function check if the lookup table is empty or if it is expired (based on the cacheTimeToLive config) and need to be refreshed + function isLookupTableToRefresh( + table: Map< + string, + { + name: string; + namespace: string; + kind: string; + creationDate: number; + } + >, + ): boolean { + const lookupTableLength = table.size; + + if (lookupTableLength === 0) { + return true; + } + + const now = Date.now(); + const cacheTimeToLive = + options.config.getOptionalNumber( + 'custom.badges-backend.cacheTimeToLive', + ) ?? 10 * 60 * 1000; + + const valuesArray = Array.from(table.values()); + const lastEntry = valuesArray[valuesArray.length - 1]; + const lastCreationDate = lastEntry.creationDate; + + return lastCreationDate + cacheTimeToLive < now; + } + + // This function return the namespace/kind/name triplet from the lookup table based on the hash + async function getEntityInfoFromLookupTable( + entityHash: string, + ): Promise<{ name: string; namespace: string; kind: string }> { + if (lookupTable.get(entityHash) === undefined) { + throw new NotFoundError(`No entity with hash "${entityHash}"`); + } + + const name = lookupTable.get(entityHash)!.name; + const namespace = lookupTable.get(entityHash)!.namespace; + const kind = lookupTable.get(entityHash)!.kind; + + return { name, namespace, kind }; + } + + // This function return the obfuscated badge url based on the namespace/kind/name triplet + async function getBadgeObfuscatedUrl( + namespace: string, + kind: string, + name: string, + badgeId: string, + ): Promise { + const baseUrl = await options.discovery.getExternalBaseUrl('badges'); + const salt = options.config.getString('custom.badges-backend.salt'); + const hash = crypto + .createHash('sha256') + .update(`${kind}:${namespace}:${name}:${salt}`) + .digest('hex'); + return `${baseUrl}/entity/${hash}/${badgeId}`; + } + + // This function return the badge url based on the namespace/kind/name triplet + async function getBadgeUrl( + namespace: string, + kind: string, + name: string, + badgeId: string, + ): Promise { + const baseUrl = await options.discovery.getExternalBaseUrl('badges'); + return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; + } } diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index 0800585634..9e29d5dead 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -19,9 +19,11 @@ import { Logger } from 'winston'; import { createServiceBuilder, loadBackendConfig, + ServerTokenManager, SingleHostDiscovery, } from '@backstage/backend-common'; import { createRouter } from './router'; +import { IdentityApi } from '@backstage/plugin-auth-node'; export interface ServerOptions { port: number; @@ -38,7 +40,27 @@ export async function startStandaloneServer( logger.debug('Creating application...'); - const router = await createRouter({ config, discovery }); + const tokenManager = ServerTokenManager.noop(); + const identity: IdentityApi = { + async getIdentity({ request }) { + const token = request.headers.authorization?.split(' ')[1]; + return { + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: token || 'user:default/john_doe', + }, + token: token || 'no-token', + }; + }, + }; + const router = await createRouter({ + config, + discovery, + tokenManager, + logger, + identity, + }); let service = createServiceBuilder(module) .setPort(options.port) diff --git a/plugins/badges-backend/src/tests/router-obfuscated.test.ts b/plugins/badges-backend/src/tests/router-obfuscated.test.ts new file mode 100644 index 0000000000..d03cbe6d24 --- /dev/null +++ b/plugins/badges-backend/src/tests/router-obfuscated.test.ts @@ -0,0 +1,348 @@ +/* + * Copyright 2021 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 express from 'express'; +import request from 'supertest'; +import { + getVoidLogger, + PluginEndpointDiscovery, + ServerTokenManager, + SingleHostDiscovery, +} from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import type { Entity } from '@backstage/catalog-model'; +import { Config, ConfigReader } from '@backstage/config'; +import { createRouter } from '../service/router'; +import { BadgeBuilder } from '../lib'; +import { + BackstageIdentityResponse, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; + +describe('createRouter', () => { + let app: express.Express; + let badgeBuilder: jest.Mocked; + + const catalog = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByRef: jest.fn(), + getLocationByRef: jest.fn(), + getLocationById: jest.fn(), + removeLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + refreshEntity: jest.fn(), + getEntityAncestors: jest.fn(), + getEntityFacets: jest.fn(), + validateEntity: jest.fn(), + }; + let config: Config; + let discovery: PluginEndpointDiscovery; + + const getIdentity = jest.fn(); + + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + }, + }; + + const entities: Entity[] = [ + entity, + { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test-2', + }, + }, + ]; + + const badge = { + id: 'test-badge', + badge: { + label: 'test', + message: 'badge', + }, + url: '/...', + markdown: '[![...](...)]', + }; + + beforeAll(async () => { + getIdentity.mockImplementation( + async ({ + request: _request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + > => { + return { + identity: { + userEntityRef: 'user:default/guest', + ownershipEntityRefs: [], + type: 'user', + }, + token: 'token', + }; + }, + ); + + badgeBuilder = { + getBadges: jest.fn(), + createBadgeJson: jest.fn(), + createBadgeSvg: jest.fn(), + }; + config = new ConfigReader({ + backend: { + baseUrl: 'http://127.0.0.1', + listen: { + port: 7007, + }, + }, + app: { + badges: { + obfuscate: true, + }, + }, + custom: { + 'badges-backend': { + salt: 'random-string', + cacheTimeToLive: '60', + }, + }, + }); + + discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.noop(); + const router = await createRouter({ + badgeBuilder, + catalog: catalog as Partial as CatalogApi, + config, + discovery, + tokenManager, + logger: getVoidLogger(), + identity: { getIdentity }, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('works', async () => { + const tokenManager = ServerTokenManager.noop(); + const router = await createRouter({ + badgeBuilder, + catalog: catalog as Partial as CatalogApi, + config, + discovery, + tokenManager, + logger: getVoidLogger(), + identity: { getIdentity }, + }); + expect(router).toBeDefined(); + }); + + describe('GET /entity/:namespace/:kind/:name/badge-specs', () => { + it('does not returns all badge specs for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const response = await request(app).get( + '/entity/default/component/test/badge-specs', + ); + + expect(response.status).toEqual(404); + }); + }); + + describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => { + it('does not returns badge for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + + const image = '...'; + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); + + const response = await request(app).get( + '/entity/default/component/test/badge/test-badge', + ); + + expect(response.status).toEqual(404); + }); + + it('does not returns badge spec for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const url = '/entity/default/component/test/badge/test-badge?format=json'; + const response = await request(app).get(url); + + expect(response.status).toEqual(404); + }); + }); + + describe('GET /entity/:entityHash/badge-specs', () => { + it('returns all badge specs for entity', async () => { + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + catalog.getEntityByRef.mockResolvedValueOnce(entity); + + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const response = await request(app).get( + '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/badge-specs', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([badge]); + + expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByRef).toHaveBeenCalledWith( + { + namespace: 'default', + kind: 'component', + name: 'test', + }, + { token: '' }, + ); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed\/test-badge/, + ), + config, + entity, + }, + }); + }); + }); + + describe('GET /entity/:entityHash/test-badge', () => { + it('returns badge for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + + const image = '...'; + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); + + const response = await request(app).get( + '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/test-badge', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(Buffer.from(image)); + + expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByRef).toHaveBeenCalledWith( + { + namespace: 'default', + kind: 'component', + name: 'test', + }, + { token: '' }, + ); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed\/test-badge/, + ), + config, + entity, + }, + }); + }); + + it('returns badge spec for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const url = + '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/test-badge?format=json'; + const response = await request(app).get(url); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(badge); + }); + }); + + describe('GET /entity/:namespace/:kind/:name/obfuscated', () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + + it('returns obfuscated entity', async () => { + const obfuscatedEntity = await request(app) + .get('/entity/default/component/test/obfuscated') + .set('Authorization', 'Bearer fakeToken'); + expect(obfuscatedEntity.status).toEqual(200); + // echo -n "component:default:test:random-string" | openssl dgst -sha256 + expect(obfuscatedEntity.body).toEqual({ + hash: '3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed', + }); + }); + + it('returns obfuscated 401 if no auth', async () => { + const obfuscatedEntity = await request(app).get( + '/entity/default/component/test/obfuscated', + ); + expect(obfuscatedEntity.status).toEqual(401); + }); + }); + + describe('Errors', () => { + it('returns 404 for unknown entity hash', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + async function testUrl(url: string) { + const response = await request(app).get(url); + expect(response.status).toEqual(404); + expect(response.body).toEqual({ + error: { + message: expect.any(String), + name: 'NotFoundError', + }, + request: { + method: 'GET', + url, + }, + response: { + statusCode: 404, + }, + }); + } + await testUrl( + '/entity/3a5f91c1e66519be5394c37a8ba69cfsf3087b7c322c600e7497dc9d517353e5bed/badge-specs', + ); + await testUrl( + '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7csfsf322c600e7497dc9d517353e5bed/test-badge', + ); + }); + }); +}); diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/tests/router.test.ts similarity index 51% rename from plugins/badges-backend/src/service/router.test.ts rename to plugins/badges-backend/src/tests/router.test.ts index 109b64231d..1388b043d0 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/tests/router.test.ts @@ -17,18 +17,25 @@ import express from 'express'; import request from 'supertest'; import { + getVoidLogger, PluginEndpointDiscovery, + ServerTokenManager, SingleHostDiscovery, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import type { Entity } from '@backstage/catalog-model'; import { Config, ConfigReader } from '@backstage/config'; -import { createRouter } from './router'; +import { createRouter } from '../service/router'; import { BadgeBuilder } from '../lib'; +import { + BackstageIdentityResponse, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; describe('createRouter', () => { let app: express.Express; let badgeBuilder: jest.Mocked; + const catalog = { addLocation: jest.fn(), getEntities: jest.fn(), @@ -42,12 +49,15 @@ describe('createRouter', () => { getEntityFacets: jest.fn(), validateEntity: jest.fn(), }; + + const getIdentity = jest.fn(); + let config: Config; let discovery: PluginEndpointDiscovery; const entity: Entity = { apiVersion: 'v1', - kind: 'service', + kind: 'component', metadata: { name: 'test', }, @@ -75,13 +85,34 @@ describe('createRouter', () => { listen: { port: 7007 }, }, }); - discovery = SingleHostDiscovery.fromConfig(config); + getIdentity.mockImplementation( + async ({ + request: _request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + > => { + return { + identity: { + userEntityRef: 'user:default/guest', + ownershipEntityRefs: [], + type: 'user', + }, + token: 'token', + }; + }, + ); + + discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.noop(); const router = await createRouter({ badgeBuilder, catalog: catalog as Partial as CatalogApi, config, discovery, + tokenManager, + logger: getVoidLogger(), + identity: { getIdentity }, }); app = express().use(router); }); @@ -91,11 +122,15 @@ describe('createRouter', () => { }); it('works', async () => { + const tokenManager = ServerTokenManager.noop(); const router = await createRouter({ badgeBuilder, catalog: catalog as Partial as CatalogApi, config, discovery, + tokenManager, + logger: getVoidLogger(), + identity: { getIdentity }, }); expect(router).toBeDefined(); }); @@ -108,7 +143,7 @@ describe('createRouter', () => { badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); const response = await request(app).get( - '/entity/default/service/test/badge-specs', + '/entity/default/component/test/badge-specs', ); expect(response.status).toEqual(200); @@ -118,10 +153,10 @@ describe('createRouter', () => { expect(catalog.getEntityByRef).toHaveBeenCalledWith( { namespace: 'default', - kind: 'service', + kind: 'component', name: 'test', }, - { token: undefined }, + { token: '' }, ); expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); @@ -130,46 +165,7 @@ describe('createRouter', () => { badgeInfo: { id: badge.id }, context: { badgeUrl: expect.stringMatching( - /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, - ), - config, - entity, - }, - }); - }); - }); - - describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => { - it('returns badge for entity', async () => { - catalog.getEntityByRef.mockResolvedValueOnce(entity); - - const image = '...'; - badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); - - const response = await request(app).get( - '/entity/default/service/test/badge/test-badge', - ); - - expect(response.status).toEqual(200); - expect(response.body).toEqual(Buffer.from(image)); - - expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); - expect(catalog.getEntityByRef).toHaveBeenCalledWith( - { - namespace: 'default', - kind: 'service', - name: 'test', - }, - { token: undefined }, - ); - - expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); - expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({ - badgeInfo: { id: badge.id }, - context: { - badgeUrl: expect.stringMatching( - /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/component\/test\/badge\/test-badge/, ), config, entity, @@ -177,27 +173,90 @@ describe('createRouter', () => { }); }); - it('returns badge spec for entity', async () => { - catalog.getEntityByRef.mockResolvedValueOnce(entity); - badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => { + it('returns badge for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); - const url = '/entity/default/service/test/badge/test-badge?format=json'; - const response = await request(app).get(url); + const image = '...'; + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); - expect(response.status).toEqual(200); - expect(response.body).toEqual(badge); + const response = await request(app).get( + '/entity/default/component/test/badge/test-badge', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(Buffer.from(image)); + + expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByRef).toHaveBeenCalledWith( + { + namespace: 'default', + kind: 'component', + name: 'test', + }, + { token: '' }, + ); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/component\/test\/badge\/test-badge/, + ), + config, + entity, + }, + }); + }); + + it('returns badge spec for entity', async () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const url = + '/entity/default/component/test/badge/test-badge?format=json'; + const response = await request(app).get(url); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(badge); + }); }); - }); - describe('Errors', () => { - it('returns 404 for unknown entities', async () => { + describe('Errors', () => { + it('returns 404 for unknown entities', async () => { + catalog.getEntityByRef.mockResolvedValue(undefined); + async function testUrl(url: string) { + const response = await request(app).get(url); + expect(response.status).toEqual(404); + expect(response.body).toEqual({ + error: { + message: 'No component entity in default named "missing"', + name: 'NotFoundError', + }, + request: { + method: 'GET', + url, + }, + response: { + statusCode: 404, + }, + }); + } + await testUrl('/entity/default/component/missing/badge-specs'); + await testUrl('/entity/default/component/missing/badge/test-badge'); + }); + }); + + it('returns 404 for hashed entities', async () => { catalog.getEntityByRef.mockResolvedValue(undefined); async function testUrl(url: string) { const response = await request(app).get(url); expect(response.status).toEqual(404); expect(response.body).toEqual({ error: { - message: 'No service entity in default named "missing"', + message: 'No component entity in default named "missing"', name: 'NotFoundError', }, request: { @@ -209,8 +268,8 @@ describe('createRouter', () => { }, }); } - await testUrl('/entity/default/service/missing/badge-specs'); - await testUrl('/entity/default/service/missing/badge/test-badge'); + await testUrl('/entity/default/component/missing/badge-specs'); + await testUrl('/entity/default/component/missing/badge/test-badge'); }); }); }); diff --git a/plugins/badges/README.md b/plugins/badges/README.md index 65ace6226e..a33ddec063 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -18,6 +18,39 @@ This will popup a badges dialog showing all available badges for that entity lik ![Badges Dialog](./doc/badges-dialog.png) +##  Badge obfuscation + +The badges plugin supports obfuscating the badge URL to prevent it from being enumerated if the badges are used in a public context (like in Github repositories). + +To enable obfuscation, set the `obfuscate` option to `true` in the `app.badges` section of your `app-config.yaml`: + +```yaml +app: + badges: + obfuscate: true +``` + +Please note that if you have already set badges in your repositories and you activate the obfuscation you will need to update the badges in your repositories to use the new obfuscated URLs. + +Please note that the backend part needs to be configured to support obfuscation. See the [backend plugin documentation](../badges-backend/README.md) for more details. + +Also, you need to allow your frontend to access the configuration : + +```typescript +export interface Config { + app: { + ... some code + badges: { + /** + * badges obfuscate + * @visibility frontend + */ + obfuscate?: string; + }; + }; +} +``` + ## Sample Badges Here are some samples of badges for the `artists-lookup` service in the Demo Backstage site: diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 9a89806d56..5071b30437 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -18,23 +18,67 @@ import { generatePath } from 'react-router-dom'; import { ResponseError } from '@backstage/errors'; import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { BadgesApi, BadgeSpec } from './types'; -import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + ConfigApi, + DiscoveryApi, + IdentityApi, +} from '@backstage/core-plugin-api'; export class BadgesClient implements BadgesApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; + private readonly configApi: ConfigApi; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + configApi: ConfigApi; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; + this.configApi = options.configApi; + } + + static fromConfig(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + configApi: ConfigApi; + }) { + return new BadgesClient(options); } public async getEntityBadgeSpecs(entity: Entity): Promise { - const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); + // Check if obfuscation is enabled in the configuration + const obfuscate = + this.configApi.getOptionalBoolean('app.badges.obfuscate') ?? false; const { token } = await this.identityApi.getCredentials(); + + // If obfuscation is enabled, get the hash of the entity and use that to get the badge specs + if (obfuscate) { + const entityHashUrl = await this.getEntityHashUrl(entity); + const entityHash = await this.getEntityHash(entityHashUrl).then(data => { + return data.hash; + }); + const entityHashedBadgeSpecsUrl = await this.getEntityHashedBadgeSpecsUrl( + entityHash, + ); + + const response = await fetch(entityHashedBadgeSpecsUrl, { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + + // If obfuscation is disabled, get the badge specs directly as the previous implementation + const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); const response = await fetch(entityBadgeSpecsUrl, { headers: token ? { @@ -42,7 +86,6 @@ export class BadgesClient implements BadgesApi { } : undefined, }); - if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -50,14 +93,51 @@ export class BadgesClient implements BadgesApi { return await response.json(); } + // This function is used to generate the URL to get the badge specs for an entity when obfuscation is enabled. Using the hash + private async getEntityHashUrl(entity: Entity): Promise { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(`:namespace/:kind/:name`, routeParams); + const baseUrl = await this.discoveryApi.getBaseUrl('badges'); + const obfuscatedEntityUrl = `${baseUrl}/entity/${path}/obfuscated`; + + return obfuscatedEntityUrl; + } + + // This function is used to get the hash of an entity when obfuscation is enabled. It's calling the badges backend to get the hash + private async getEntityHash(entityHashUrl: string): Promise { + const { token: idToken } = await this.identityApi.getCredentials(); + + const responseEntityHash = await fetch(entityHashUrl, { + headers: idToken + ? { + Authorization: `Bearer ${idToken}`, + } + : undefined, + }); + + if (!responseEntityHash.ok) { + throw await ResponseError.fromResponse(responseEntityHash); + } + return await responseEntityHash.json(); + } + + // This function is used to generate the URLs to use in the frontend when obfuscation is enabled. It's using the hash of the entity to get the badge specs + private async getEntityHashedBadgeSpecsUrl(entityHash: { + hash: string; + }): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('badges'); + return `${baseUrl}/entity/${entityHash}/badge-specs`; + } + + // This function is used to generate the URLs to use in the frontend when obfuscation is disabled. It's using the entity name to get the badge specs private async getEntityBadgeSpecsUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(`:namespace/:kind/:name`, routeParams); - return `${await this.discoveryApi.getBaseUrl( - 'badges', - )}/entity/${path}/badge-specs`; + const baseUrl = await this.discoveryApi.getBaseUrl('badges'); + return `${baseUrl}/entity/${path}/badge-specs`; } + // This function is used to generate the route parameters using the entity kind, namespace and name private getEntityRouteParams(entity: Entity) { return { kind: entity.kind.toLocaleLowerCase('en-US'), diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 7ef3025458..eac5ee11ad 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -15,6 +15,7 @@ */ import { badgesApiRef, BadgesClient } from './api'; import { + configApiRef, createApiFactory, createComponentExtension, createPlugin, @@ -28,9 +29,17 @@ export const badgesPlugin = createPlugin({ apis: [ createApiFactory({ api: badgesApiRef, - deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, - factory: ({ discoveryApi, identityApi }) => - new BadgesClient({ discoveryApi, identityApi }), + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, identityApi, configApi }) => + new BadgesClient({ + discoveryApi, + identityApi, + configApi, + }), }), ], }); From 63a1e7f714d24b773c0228bb596b253a076e0bf7 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Wed, 22 Mar 2023 16:55:38 +0100 Subject: [PATCH 02/16] chore(plugins): Update changeset to fit plugins names Signed-off-by: Rbillon59 --- .changeset/extragavent-fast-fly.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/extragavent-fast-fly.md b/.changeset/extragavent-fast-fly.md index ffac2303a1..cb6348c0e7 100644 --- a/.changeset/extragavent-fast-fly.md +++ b/.changeset/extragavent-fast-fly.md @@ -1,6 +1,6 @@ --- -'@backstage/badges': patch -'@backstage/badges-backend': patch +'@backstage/plugin-badges': patch +'@backstage/plugin-badges-backend': patch --- Fixing badges-backend plugin to get a token from the TokenManager instead of parsing the request header. Hence, it's now possible to disable the authMiddleware for the badges-backend plugin to expose publicly the badges. From 8c43cf673e02a4f35bed718f63590d55eac757f8 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Wed, 22 Mar 2023 17:03:25 +0100 Subject: [PATCH 03/16] chore(ci): Remove peerDependencies Signed-off-by: Rbillon59 --- plugins/badges-backend/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 922f34373d..2983b17eb4 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -49,9 +49,6 @@ "winston": "^3.2.1", "yn": "^4.0.0" }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0" - }, "devDependencies": { "@backstage/catalog-client": "^1.3.1", "@backstage/cli": "workspace:^", From 9f2279ce06d6f854b3986d147c3a15faffdbba29 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Wed, 22 Mar 2023 17:09:06 +0100 Subject: [PATCH 04/16] chore(ts): Update lock file Signed-off-by: Rbillon59 --- yarn.lock | 145 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 125 insertions(+), 20 deletions(-) diff --git a/yarn.lock b/yarn.lock index c0b5189db6..c2576e0795 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3433,6 +3433,25 @@ __metadata: languageName: node linkType: hard +"@backstage/app-defaults@npm:^1.2.1": + version: 1.2.1 + resolution: "@backstage/app-defaults@npm:1.2.1" + dependencies: + "@backstage/core-app-api": ^1.6.0 + "@backstage/core-components": ^0.12.5 + "@backstage/core-plugin-api": ^1.5.0 + "@backstage/plugin-permission-react": ^0.4.11 + "@backstage/theme": ^0.2.18 + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + checksum: 436a3708452aca4f9cb4c4cc6e8a9a41a8e2fe3831c01fe139bd3eb5b2eee9ee1a67f13244c5bee281927fe9ad12ea87ffb9d0c07587287b7a56ccce0f9043ec + languageName: node + linkType: hard + "@backstage/app-defaults@workspace:^, @backstage/app-defaults@workspace:packages/app-defaults": version: 0.0.0-use.local resolution: "@backstage/app-defaults@workspace:packages/app-defaults" @@ -3963,6 +3982,27 @@ __metadata: languageName: unknown linkType: soft +"@backstage/core-app-api@npm:^1.5.0, @backstage/core-app-api@npm:^1.6.0": + version: 1.6.0 + resolution: "@backstage/core-app-api@npm:1.6.0" + dependencies: + "@backstage/config": ^1.0.7 + "@backstage/core-plugin-api": ^1.5.0 + "@backstage/types": ^1.0.2 + "@backstage/version-bridge": ^1.0.3 + "@types/prop-types": ^15.7.3 + prop-types: ^15.7.2 + react-use: ^17.2.4 + zen-observable: ^0.10.0 + zod: ~3.18.0 + peerDependencies: + "@types/react": ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + checksum: 301a765f877a5573861faccd4256033457ab40cb46b96ecbeaad749684d98ab4a0502919df22b8ef582e7575fb9224c9d3f5b19ed61209f98cd06f22a87c0930 + languageName: node + linkType: hard + "@backstage/core-app-api@workspace:^, @backstage/core-app-api@workspace:packages/core-app-api": version: 0.0.0-use.local resolution: "@backstage/core-app-api@workspace:packages/core-app-api" @@ -4182,6 +4222,35 @@ __metadata: languageName: unknown linkType: soft +"@backstage/dev-utils@npm:^1.0.12": + version: 1.0.13 + resolution: "@backstage/dev-utils@npm:1.0.13" + dependencies: + "@backstage/app-defaults": ^1.2.1 + "@backstage/catalog-model": ^1.2.1 + "@backstage/core-app-api": ^1.6.0 + "@backstage/core-components": ^0.12.5 + "@backstage/core-plugin-api": ^1.5.0 + "@backstage/integration-react": ^1.1.11 + "@backstage/plugin-catalog-react": ^1.4.0 + "@backstage/test-utils": ^1.2.6 + "@backstage/theme": ^0.2.18 + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + react-use: ^17.2.4 + zen-observable: ^0.10.0 + peerDependencies: + "@types/react": ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + checksum: 37de3090b644b7c0c040cebb99fbd6b4e7249c4ca85bcfea658d202fe94dae6b70460ecb69e201ffee5f877a43bddef315b88e3716f0087d42bedf16fe741e91 + languageName: node + linkType: hard + "@backstage/dev-utils@workspace:^, @backstage/dev-utils@workspace:packages/dev-utils": version: 0.0.0-use.local resolution: "@backstage/dev-utils@workspace:packages/dev-utils" @@ -4933,18 +5002,28 @@ __metadata: resolution: "@backstage/plugin-badges-backend@workspace:plugins/badges-backend" dependencies: "@backstage/backend-common": "workspace:^" - "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-client": ^1.3.1 "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/core-app-api": ^1.5.0 + "@backstage/dev-utils": ^1.0.12 "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/test-utils": ^1.2.5 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 "@types/express": ^4.17.6 - "@types/supertest": ^2.0.8 + "@types/node": "*" badge-maker: ^3.3.0 cors: ^2.8.5 + cross-fetch: ^3.1.5 express: ^4.17.1 express-promise-router: ^4.1.0 - supertest: ^6.1.3 + lodash: ^4.17.21 + msw: ^0.49.0 + supertest: ^6.3.3 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -9460,6 +9539,33 @@ __metadata: languageName: unknown linkType: soft +"@backstage/test-utils@npm:^1.2.5, @backstage/test-utils@npm:^1.2.6": + version: 1.2.6 + resolution: "@backstage/test-utils@npm:1.2.6" + dependencies: + "@backstage/config": ^1.0.7 + "@backstage/core-app-api": ^1.6.0 + "@backstage/core-plugin-api": ^1.5.0 + "@backstage/plugin-permission-common": ^0.7.4 + "@backstage/plugin-permission-react": ^0.4.11 + "@backstage/theme": ^0.2.18 + "@backstage/types": ^1.0.2 + "@material-ui/core": ^4.12.2 + "@material-ui/icons": ^4.9.1 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + cross-fetch: ^3.1.5 + zen-observable: ^0.10.0 + peerDependencies: + "@types/react": ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + checksum: 0ae8e0f9f2779cae0d0ba30622b5c4fc2f150685da94e63019cd3314ac5b3033c49c4ecf0bcf9815eeb4d74cfc573cea8f756626ed4aa933f8104e57e1c7e4a5 + languageName: node + linkType: hard + "@backstage/test-utils@workspace:^, @backstage/test-utils@workspace:packages/test-utils": version: 0.0.0-use.local resolution: "@backstage/test-utils@workspace:packages/test-utils" @@ -20665,7 +20771,7 @@ __metadata: languageName: node linkType: hard -"cookiejar@npm:^2.1.3": +"cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" checksum: c4442111963077dc0e5672359956d6556a195d31cbb35b528356ce5f184922b99ac48245ac05ed86cf993f7df157c56da10ab3efdadfed79778a0d9b1b092d5b @@ -24669,7 +24775,7 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^2.0.1": +"formidable@npm:^2.1.2": version: 2.1.2 resolution: "formidable@npm:2.1.2" dependencies: @@ -34262,7 +34368,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.11.0, qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.9.1, qs@npm:^6.9.4, qs@npm:^6.9.6": +"qs@npm:6.11.0, qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.11.0, qs@npm:^6.9.1, qs@npm:^6.9.4, qs@npm:^6.9.6": version: 6.11.0 resolution: "qs@npm:6.11.0" dependencies: @@ -37867,32 +37973,31 @@ __metadata: languageName: node linkType: hard -"superagent@npm:^8.0.0": - version: 8.0.0 - resolution: "superagent@npm:8.0.0" +"superagent@npm:^8.0.5": + version: 8.0.9 + resolution: "superagent@npm:8.0.9" dependencies: component-emitter: ^1.3.0 - cookiejar: ^2.1.3 + cookiejar: ^2.1.4 debug: ^4.3.4 fast-safe-stringify: ^2.1.1 form-data: ^4.0.0 - formidable: ^2.0.1 + formidable: ^2.1.2 methods: ^1.1.2 mime: 2.6.0 - qs: ^6.10.3 - readable-stream: ^3.6.0 - semver: ^7.3.7 - checksum: 14343e59327eafd85fa230acb876017079d5efcecc72a56566abc0f965220bb460af2e070dddecd9e2856410b2d2b318d81d9cc1d342aa5922da93c29a295dd7 + qs: ^6.11.0 + semver: ^7.3.8 + checksum: 5d00cdc7ceb5570663da80604965750e6b1b8d7d7442b7791e285c62bcd8d578a8ead0242a2426432b59a255fb42eb3a196d636157538a1392e7b6c5f1624810 languageName: node linkType: hard -"supertest@npm:^6.1.3, supertest@npm:^6.1.6, supertest@npm:^6.2.4": - version: 6.2.4 - resolution: "supertest@npm:6.2.4" +"supertest@npm:^6.1.3, supertest@npm:^6.1.6, supertest@npm:^6.2.4, supertest@npm:^6.3.3": + version: 6.3.3 + resolution: "supertest@npm:6.3.3" dependencies: methods: ^1.1.2 - superagent: ^8.0.0 - checksum: f2ddc4f3ba467a5c4036dd4aad41351e4b60eb13c39ecf5233ccd2ebb425504073b2b7036c973a70c7047f5c6bc1b9fef096b7bbff114d357cbe80654441db23 + superagent: ^8.0.5 + checksum: 38239e517f7ba62b7a139a79c5c48d55f8d67b5ff4b6e51d5b07732ca8bbc4a28ffa1b10916fbb403dd013a054dbf028edc5850057d9a43aecbff439d494673e languageName: node linkType: hard From 997e2ec732540882147a9846d310233dbaeef172 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Wed, 22 Mar 2023 17:21:35 +0100 Subject: [PATCH 05/16] chore(ci): Update package json with workspace range for dev dependencies and fix badge.ts to add new options to router Signed-off-by: Rbillon59 --- packages/backend/src/plugins/badges.ts | 3 + plugins/badges-backend/api-report.md | 9 +++ plugins/badges-backend/package.json | 8 +- yarn.lock | 104 +------------------------ 4 files changed, 20 insertions(+), 104 deletions(-) diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 37529689f3..fa50731809 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -28,5 +28,8 @@ export default async function createPlugin( config: env.config, discovery: env.discovery, badgeFactories: createDefaultBadgeFactories(), + tokenManager: env.tokenManager, + logger: env.logger, + identity: env.identity, }); } diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md index 8ca4b561a2..f43acf395f 100644 --- a/plugins/badges-backend/api-report.md +++ b/plugins/badges-backend/api-report.md @@ -7,7 +7,10 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { Logger } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { TokenManager } from '@backstage/backend-common'; // @public (undocumented) export interface Badge { @@ -112,5 +115,11 @@ export interface RouterOptions { config: Config; // (undocumented) discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityApi; + // (undocumented) + logger: Logger; + // (undocumented) + tokenManager: TokenManager; } ``` diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 2983b17eb4..29c824fd70 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -50,11 +50,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/catalog-client": "^1.3.1", + "@backstage/catalog-client": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "^1.5.0", - "@backstage/dev-utils": "^1.0.12", - "@backstage/test-utils": "^1.2.5", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", diff --git a/yarn.lock b/yarn.lock index c2576e0795..213f03d6b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3433,25 +3433,6 @@ __metadata: languageName: node linkType: hard -"@backstage/app-defaults@npm:^1.2.1": - version: 1.2.1 - resolution: "@backstage/app-defaults@npm:1.2.1" - dependencies: - "@backstage/core-app-api": ^1.6.0 - "@backstage/core-components": ^0.12.5 - "@backstage/core-plugin-api": ^1.5.0 - "@backstage/plugin-permission-react": ^0.4.11 - "@backstage/theme": ^0.2.18 - "@material-ui/core": ^4.12.2 - "@material-ui/icons": ^4.9.1 - peerDependencies: - react: ^16.13.1 || ^17.0.0 - react-dom: ^16.13.1 || ^17.0.0 - react-router-dom: 6.0.0-beta.0 || ^6.3.0 - checksum: 436a3708452aca4f9cb4c4cc6e8a9a41a8e2fe3831c01fe139bd3eb5b2eee9ee1a67f13244c5bee281927fe9ad12ea87ffb9d0c07587287b7a56ccce0f9043ec - languageName: node - linkType: hard - "@backstage/app-defaults@workspace:^, @backstage/app-defaults@workspace:packages/app-defaults": version: 0.0.0-use.local resolution: "@backstage/app-defaults@workspace:packages/app-defaults" @@ -3982,27 +3963,6 @@ __metadata: languageName: unknown linkType: soft -"@backstage/core-app-api@npm:^1.5.0, @backstage/core-app-api@npm:^1.6.0": - version: 1.6.0 - resolution: "@backstage/core-app-api@npm:1.6.0" - dependencies: - "@backstage/config": ^1.0.7 - "@backstage/core-plugin-api": ^1.5.0 - "@backstage/types": ^1.0.2 - "@backstage/version-bridge": ^1.0.3 - "@types/prop-types": ^15.7.3 - prop-types: ^15.7.2 - react-use: ^17.2.4 - zen-observable: ^0.10.0 - zod: ~3.18.0 - peerDependencies: - "@types/react": ^16.13.1 || ^17.0.0 - react: ^16.13.1 || ^17.0.0 - react-router-dom: 6.0.0-beta.0 || ^6.3.0 - checksum: 301a765f877a5573861faccd4256033457ab40cb46b96ecbeaad749684d98ab4a0502919df22b8ef582e7575fb9224c9d3f5b19ed61209f98cd06f22a87c0930 - languageName: node - linkType: hard - "@backstage/core-app-api@workspace:^, @backstage/core-app-api@workspace:packages/core-app-api": version: 0.0.0-use.local resolution: "@backstage/core-app-api@workspace:packages/core-app-api" @@ -4222,35 +4182,6 @@ __metadata: languageName: unknown linkType: soft -"@backstage/dev-utils@npm:^1.0.12": - version: 1.0.13 - resolution: "@backstage/dev-utils@npm:1.0.13" - dependencies: - "@backstage/app-defaults": ^1.2.1 - "@backstage/catalog-model": ^1.2.1 - "@backstage/core-app-api": ^1.6.0 - "@backstage/core-components": ^0.12.5 - "@backstage/core-plugin-api": ^1.5.0 - "@backstage/integration-react": ^1.1.11 - "@backstage/plugin-catalog-react": ^1.4.0 - "@backstage/test-utils": ^1.2.6 - "@backstage/theme": ^0.2.18 - "@material-ui/core": ^4.12.2 - "@material-ui/icons": ^4.9.1 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 - "@testing-library/user-event": ^14.0.0 - react-use: ^17.2.4 - zen-observable: ^0.10.0 - peerDependencies: - "@types/react": ^16.13.1 || ^17.0.0 - react: ^16.13.1 || ^17.0.0 - react-dom: ^16.13.1 || ^17.0.0 - react-router-dom: 6.0.0-beta.0 || ^6.3.0 - checksum: 37de3090b644b7c0c040cebb99fbd6b4e7249c4ca85bcfea658d202fe94dae6b70460ecb69e201ffee5f877a43bddef315b88e3716f0087d42bedf16fe741e91 - languageName: node - linkType: hard - "@backstage/dev-utils@workspace:^, @backstage/dev-utils@workspace:packages/dev-utils": version: 0.0.0-use.local resolution: "@backstage/dev-utils@workspace:packages/dev-utils" @@ -5002,15 +4933,15 @@ __metadata: resolution: "@backstage/plugin-badges-backend@workspace:plugins/badges-backend" dependencies: "@backstage/backend-common": "workspace:^" - "@backstage/catalog-client": ^1.3.1 + "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" - "@backstage/core-app-api": ^1.5.0 - "@backstage/dev-utils": ^1.0.12 + "@backstage/core-app-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" - "@backstage/test-utils": ^1.2.5 + "@backstage/test-utils": "workspace:^" "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 @@ -9539,33 +9470,6 @@ __metadata: languageName: unknown linkType: soft -"@backstage/test-utils@npm:^1.2.5, @backstage/test-utils@npm:^1.2.6": - version: 1.2.6 - resolution: "@backstage/test-utils@npm:1.2.6" - dependencies: - "@backstage/config": ^1.0.7 - "@backstage/core-app-api": ^1.6.0 - "@backstage/core-plugin-api": ^1.5.0 - "@backstage/plugin-permission-common": ^0.7.4 - "@backstage/plugin-permission-react": ^0.4.11 - "@backstage/theme": ^0.2.18 - "@backstage/types": ^1.0.2 - "@material-ui/core": ^4.12.2 - "@material-ui/icons": ^4.9.1 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 - "@testing-library/user-event": ^14.0.0 - cross-fetch: ^3.1.5 - zen-observable: ^0.10.0 - peerDependencies: - "@types/react": ^16.13.1 || ^17.0.0 - react: ^16.13.1 || ^17.0.0 - react-dom: ^16.13.1 || ^17.0.0 - react-router-dom: 6.0.0-beta.0 || ^6.3.0 - checksum: 0ae8e0f9f2779cae0d0ba30622b5c4fc2f150685da94e63019cd3314ac5b3033c49c4ecf0bcf9815eeb4d74cfc573cea8f756626ed4aa933f8104e57e1c7e4a5 - languageName: node - linkType: hard - "@backstage/test-utils@workspace:^, @backstage/test-utils@workspace:packages/test-utils": version: 0.0.0-use.local resolution: "@backstage/test-utils@workspace:packages/test-utils" From b34749a91e97d6e66c2a554ce0fa705262251201 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Tue, 4 Apr 2023 11:30:16 +0200 Subject: [PATCH 06/16] chore(changeset): update changeset Signed-off-by: Rbillon59 --- .changeset/extragavent-fast-fly.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/extragavent-fast-fly.md b/.changeset/extragavent-fast-fly.md index cb6348c0e7..68f5d7a848 100644 --- a/.changeset/extragavent-fast-fly.md +++ b/.changeset/extragavent-fast-fly.md @@ -1,8 +1,10 @@ --- '@backstage/plugin-badges': patch -'@backstage/plugin-badges-backend': patch +'@backstage/plugin-badges-backend': minor --- Fixing badges-backend plugin to get a token from the TokenManager instead of parsing the request header. Hence, it's now possible to disable the authMiddleware for the badges-backend plugin to expose publicly the badges. Implementing an obfuscation feature to protect an open badges endpoint from being enumerated. The feature is disabled by default and the change is compatible with the previous version. + +**BREAKING**: createRouter now require that a tokenManager, logger and identityApi are passed in as options. From 1aabf9e03fbf1bd29ab3c804ddcd52f9651a3a98 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Fri, 7 Apr 2023 16:06:09 +0200 Subject: [PATCH 07/16] chore(plugins): Refactor the badge backend to store data in database instead of memory. Fix conversation as well Signed-off-by: Rbillon59 --- .changeset/extragavent-fast-fly.md | 2 +- packages/backend/src/plugins/badges.ts | 6 + plugins/badges-backend/README.md | 16 +- plugins/badges-backend/api-report.md | 48 +++ .../migrations/20230404_init.js | 36 +++ plugins/badges-backend/package.json | 8 +- .../src/database/badgesStore.ts | 182 +++++++++++ plugins/badges-backend/src/index.ts | 1 + plugins/badges-backend/src/service/router.ts | 276 +++++++--------- .../src/service/standaloneServer.ts | 13 + .../src/tests/badgeStore.test.ts | 294 ++++++++++++++++++ .../src/tests/router-obfuscated.test.ts | 148 +++++---- .../badges-backend/src/tests/router.test.ts | 12 + plugins/badges/package.json | 9 +- plugins/badges/src/api/BadgesClient.ts | 44 +-- plugins/badges/src/plugin.ts | 8 +- yarn.lock | 11 +- 17 files changed, 844 insertions(+), 270 deletions(-) create mode 100644 plugins/badges-backend/migrations/20230404_init.js create mode 100644 plugins/badges-backend/src/database/badgesStore.ts create mode 100644 plugins/badges-backend/src/tests/badgeStore.test.ts diff --git a/.changeset/extragavent-fast-fly.md b/.changeset/extragavent-fast-fly.md index 68f5d7a848..ffa2e6e859 100644 --- a/.changeset/extragavent-fast-fly.md +++ b/.changeset/extragavent-fast-fly.md @@ -7,4 +7,4 @@ Fixing badges-backend plugin to get a token from the TokenManager instead of par Implementing an obfuscation feature to protect an open badges endpoint from being enumerated. The feature is disabled by default and the change is compatible with the previous version. -**BREAKING**: createRouter now require that a tokenManager, logger and identityApi are passed in as options. +**BREAKING**: createRouter now require that a tokenManager, logger, identityApi and database, are passed in as options. diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index fa50731809..6b40d1e5fb 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -20,10 +20,15 @@ import { } from '@backstage/plugin-badges-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; +import { DatabaseBadgesStore } from '@backstage/plugin-badges-backend'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + const db = await DatabaseBadgesStore.create({ + database: env.database, + }); + return await createRouter({ config: env.config, discovery: env.discovery, @@ -31,5 +36,6 @@ export default async function createPlugin( tokenManager: env.tokenManager, logger: env.logger, identity: env.identity, + db: db, }); } diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 6444a5f828..4fcef981f1 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -32,12 +32,18 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + const db = await DatabaseBadgesStore.create({ + database: env.database, + }); + return await createRouter({ config: env.config, discovery: env.discovery, badgeFactories: createDefaultBadgeFactories(), tokenManager: env.tokenManager, logger: env.logger, + identity: env.identity, + db: db, }); } ``` @@ -123,16 +129,18 @@ To enable the obfuscation you need to activate the `obfuscation` feature in the ```yaml app: badges: - obfuscate: 'true' + obfuscate: true ``` +> Note that you cannot use env vars to set the `obfuscate` value. It must be a boolean value and env vars are always strings. + Also you need to provide the [salt]() in the `app-config.yaml`: ```yaml custom: badges-backend: salt: # required - cacheTimeToLive: 10 # minutes (optional) + cacheTimeToLive: 60 # minutes (optional) ``` Any string can be used as a salt, but it's recommended to use a long random string to increase entropy. You can generate a random string using the following command: @@ -148,7 +156,7 @@ openssl rand -hex 32 The badges backend api exposes two main endpoints for entity badges. The `/badges` prefix is arbitrary, and the default for the example backend. -### If obfuscation is disabled (default or apps.badges.obfuscate: "false") +### If obfuscation is disabled (default or apps.badges.obfuscate: false) - `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges for a particular entity, in json format. See @@ -159,7 +167,7 @@ The badges backend api exposes two main endpoints for entity badges. The an SVG image. If the `accept` request header prefers `application/json` the badge spec as JSON will be returned instead of the image. -### If obfuscation is enabled (apps.badges.obfuscate: "true") +### If obfuscation is enabled (apps.badges.obfuscate: true) - `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity hash from name, namespace, kind. diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md index f43acf395f..6f252cf27d 100644 --- a/plugins/badges-backend/api-report.md +++ b/plugins/badges-backend/api-report.md @@ -7,6 +7,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; +import { GetEntitiesResponse } from '@backstage/catalog-client'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -81,6 +82,51 @@ export type BadgeSpec = { markdown: string; }; +// @public +export interface BadgesStore { + // (undocumented) + countAllBadges(): Promise; + // (undocumented) + createAllBadges( + entities: GetEntitiesResponse, + salt: string | undefined, + ): Promise; + // (undocumented) + deleteObsoleteHashes( + entities: GetEntitiesResponse, + salt: string | undefined, + ): Promise; + // (undocumented) + getAllBadges(): Promise< + { + name: string; + namespace: string; + kind: string; + hash: string; + }[] + >; + // (undocumented) + getBadgeFromHash(hash: string): Promise< + | { + name: string; + namespace: string; + kind: string; + } + | undefined + >; + // (undocumented) + getHashFromEntityMetadata( + name: string, + namespace: string, + kind: string, + ): Promise< + | { + hash: string; + } + | undefined + >; +} + // @public (undocumented) export type BadgeStyle = (typeof BADGE_STYLES)[number]; @@ -114,6 +160,8 @@ export interface RouterOptions { // (undocumented) config: Config; // (undocumented) + db: BadgesStore; + // (undocumented) discovery: PluginEndpointDiscovery; // (undocumented) identity: IdentityApi; diff --git a/plugins/badges-backend/migrations/20230404_init.js b/plugins/badges-backend/migrations/20230404_init.js new file mode 100644 index 0000000000..d9a395d09a --- /dev/null +++ b/plugins/badges-backend/migrations/20230404_init.js @@ -0,0 +1,36 @@ +/* + * Copyright 2021 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. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('badges', table => { + table.string('kind').notNullable(); + table.string('namespace').notNullable(); + table.string('name').notNullable(); + table + .string('hash') + .unique() + .comment( + 'Hash is calculated from the SHA256 of badge kind, namespace, name, and applications salt', + ) + .notNullable(); + table.index(['hash'], 'badges_hash_index'); + table.primary(['hash']); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.dropTable('badges'); +}; diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 29c824fd70..4b05ffb2e4 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -44,23 +44,21 @@ "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "knex": "^2.4.2", "lodash": "^4.17.21", "supertest": "^6.3.3", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3", - "@testing-library/user-event": "^14.0.0", "@types/node": "*", - "cross-fetch": "^3.1.5", - "msw": "^0.49.0" + "cross-fetch": "^3.1.5" }, "files": [ "dist" diff --git a/plugins/badges-backend/src/database/badgesStore.ts b/plugins/badges-backend/src/database/badgesStore.ts new file mode 100644 index 0000000000..fa7bfa3f40 --- /dev/null +++ b/plugins/badges-backend/src/database/badgesStore.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2021 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 { + PluginDatabaseManager, + resolvePackagePath, +} from '@backstage/backend-common'; +import { Knex } from 'knex'; +import crypto from 'crypto'; +import { GetEntitiesResponse } from '@backstage/catalog-client'; + +/** + * internal + * @public + */ +export interface BadgesStore { + createAllBadges( + entities: GetEntitiesResponse, + salt: string | undefined, + ): Promise; + + getBadgeFromHash( + hash: string, + ): Promise<{ name: string; namespace: string; kind: string } | undefined>; + + getHashFromEntityMetadata( + name: string, + namespace: string, + kind: string, + ): Promise<{ hash: string } | undefined>; + + deleteObsoleteHashes( + entities: GetEntitiesResponse, + salt: string | undefined, + ): Promise; + + countAllBadges(): Promise; + + getAllBadges(): Promise< + { name: string; namespace: string; kind: string; hash: string }[] + >; +} + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-badges-backend', // Package name + 'migrations', // Migrations directory +); + +/** + * DatabaseBadgesStore + * @internal + */ +export class DatabaseBadgesStore implements BadgesStore { + private constructor(private readonly db: Knex) {} + + static async create({ + database, + skipMigrations, + }: { + database: PluginDatabaseManager; + skipMigrations?: boolean; + }): Promise { + const client = await database.getClient(); + + if (!database.migrations?.skip && !skipMigrations) { + await client.migrate.latest({ + directory: migrationsDir, + }); + } + + return new DatabaseBadgesStore(client); + } + + async createAllBadges( + entities: GetEntitiesResponse, + salt: string, + ): Promise { + for (const entity of entities.items) { + const name = entity.metadata.name.toLocaleLowerCase(); + const namespace = + entity.metadata.namespace?.toLocaleLowerCase() ?? 'default'; + const kind = entity.kind.toLocaleLowerCase(); + const entityHash = crypto + .createHash('sha256') + .update(`${kind}:${namespace}:${name}:${salt}`) + .digest('hex'); + + await this.db('badges') + .insert({ + hash: entityHash, + namespace: namespace, + name: name, + kind: kind, + }) + .onConflict() + .ignore(); + } + } + + async getAllBadges(): Promise< + { name: string; namespace: string; kind: string; hash: string }[] + > { + const result = await this.db('badges').select('*').orderBy('name', 'asc'); + return result; + } + + async getBadgeFromHash( + hash: string, + ): Promise<{ name: string; namespace: string; kind: string } | undefined> { + const result = await this.db('badges') + .select('namespace', 'name', 'kind') + .where({ hash: hash }) + .first(); + + return result; + } + + async getHashFromEntityMetadata( + name: string, + namespace: string, + kind: string, + ): Promise<{ hash: string } | undefined> { + const result = await this.db('badges') + .select('hash') + .where({ name: name, namespace: namespace, kind: kind }) + .first(); + + return result; + } + + async deleteObsoleteHashes( + entities: GetEntitiesResponse, + salt: string, + ): Promise { + const entityInCatalog: string[] = []; + const entityInBadgeDatabase: string[] = []; + let entityToDelete: string[] = []; + + for (const entity of entities.items) { + const name = entity.metadata.name.toLowerCase(); + const namespace = entity.metadata.namespace?.toLowerCase() ?? 'default'; + const kind = entity.kind.toLowerCase(); + const entityHash = crypto + .createHash('sha256') + .update(`${kind}:${namespace}:${name}:${salt}`) + .digest('hex'); + + entityInCatalog.push(entityHash); + } + + for (const entity of await this.getAllBadges()) { + entityInBadgeDatabase.push(entity.hash); + } + + entityToDelete = entityInBadgeDatabase.filter( + entity => !entityInCatalog.includes(entity), + ); + + for (const entity of entityToDelete) { + await this.db('badges').where({ hash: entity }).del(); + } + } + + async countAllBadges(): Promise { + const result = await this.db('badges').countDistinct('hash as count'); + const count: number = +result[0].count; + return count; + } +} diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index a45714cb81..35a061c516 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -24,3 +24,4 @@ export * from './badges'; export * from './lib'; export * from './service/router'; export * from './types'; +export * from './database/badgesStore'; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 5944975a9b..db568eb0d0 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -26,11 +26,13 @@ import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; import { BadgeContext, BadgeFactories } from '../types'; -import { isEmpty, isNil } from 'lodash'; +import { isNil } from 'lodash'; import crypto from 'crypto'; import { Logger } from 'winston'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import type { BadgesStore } from '../database/badgesStore'; + /** @public */ export interface RouterOptions { badgeBuilder?: BadgeBuilder; @@ -41,6 +43,7 @@ export interface RouterOptions { tokenManager: TokenManager; logger: Logger; identity: IdentityApi; + db: BadgesStore; } /** @public */ @@ -54,38 +57,46 @@ export async function createRouter( new DefaultBadgeBuilder(options.badgeFactories || {}); const router = Router(); - const tokenManager = options.tokenManager; - let lookupTable: Map< - string, - { - name: string; - namespace: string; - kind: string; - creationDate: number; - } - > = new Map(); + const { db, config, logger, tokenManager, discovery, identity } = options; + const baseUrl = await discovery.getExternalBaseUrl('badges'); + const salt = config.getOptionalString('custom.badges-backend.salt'); + const cacheTimeToLive = + config.getOptionalNumber('badgeDatabaseRefreshCacheTimeToLive') ?? 3600; + + let lastDatabaseRefresh = 0; // Check if the users have enabled the obfuscation of the entity name - const obfuscate: boolean = - options.config.getOptionalBoolean('app.badges.obfuscate') ?? false; + if (config.getOptionalBoolean('app.badges.obfuscate')) { + logger.info('Badges obfuscation is enabled'); - if (obfuscate) { - options.logger.info('Badges obfuscation is enabled'); + if (isNil(salt)) { + throw new Error( + 'Badges obfuscation is enabled but no salt has been provided', + ); + } // Use the generated hash instead of the triplet namespace/kind/name router.get('/entity/:entityHash/badge-specs', async (req, res) => { const { entityHash } = req.params; - if (isLookupTableToRefresh(lookupTable)) { - lookupTable = await generateHashLookupTable(); + // Chech if the database needs to be refreshed + if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) { + lastDatabaseRefresh = await refreshBadgeDatabase(); + } + + // Retrieve the badge info from the database + const badgeInfos = await db.getBadgeFromHash(entityHash); + + if (isNil(badgeInfos)) { + throw new NotFoundError( + `No badge found for entity hash "${entityHash}"`, + ); } - // Try to match the queried hash with a key in the lookup table - const entityInfo = await getEntityInfoFromLookupTable(entityHash); // If a mapping is found, map name, namespace and kind - const name = entityInfo.name; - const namespace = entityInfo.namespace; - const kind = entityInfo.kind; + const name = badgeInfos.name; + const namespace = badgeInfos.namespace; + const kind = badgeInfos.kind; const token = await tokenManager.getToken(); // Query the catalog with the name, namespace, kind to get the entity informations @@ -107,13 +118,8 @@ export async function createRouter( const specs = []; for (const badgeInfo of await badgeBuilder.getBadges()) { const context: BadgeContext = { - badgeUrl: await getBadgeObfuscatedUrl( - namespace, - kind, - name, - badgeInfo.id, - ), - config: options.config, + badgeUrl: await getBadgeObfuscatedUrl(entityHash, badgeInfo.id), + config: config, entity, }; @@ -129,19 +135,25 @@ export async function createRouter( // Use the generated hash instead of the triplet namespace/kind/name router.get('/entity/:entityHash/:badgeId', async (req, res) => { - const { entityHash, badgeId } = req.params; - - if (isLookupTableToRefresh(lookupTable)) { - lookupTable = await generateHashLookupTable(); + if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) { + lastDatabaseRefresh = await refreshBadgeDatabase(); } - // Try to match the queried hash with a key in the lookup table - const entityInfo = await getEntityInfoFromLookupTable(entityHash); + const { entityHash, badgeId } = req.params; + + // Retrieve the badge info from the database + const badgeInfo = await db.getBadgeFromHash(entityHash); + + if (isNil(badgeInfo)) { + throw new NotFoundError( + `No badge found for entity hash "${entityHash}"`, + ); + } // If a mapping is found, map name, namespace and kind - const name = entityInfo.name; - const namespace = entityInfo.namespace; - const kind = entityInfo.kind; + const name = badgeInfo.name; + const namespace = badgeInfo.namespace; + const kind = badgeInfo.kind; const token = await tokenManager.getToken(); const entity = await catalog.getEntityByRef( { @@ -168,8 +180,8 @@ export async function createRouter( const badgeOptions = { badgeInfo: { id: badgeId }, context: { - badgeUrl: await getBadgeObfuscatedUrl(namespace, kind, name, badgeId), - config: options.config, + badgeUrl: await getBadgeObfuscatedUrl(entityHash, badgeId), + config: config, entity, }, }; @@ -203,22 +215,42 @@ export async function createRouter( } try { - req.user = options.identity.getIdentity({ request: req }); + req.user = identity.getIdentity({ request: req }); next(); } catch (error) { - options.tokenManager.authenticate(token.toString()); + tokenManager.authenticate(token.toString()); next(error); } }, async (req, res) => { + if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) { + lastDatabaseRefresh = await refreshBadgeDatabase(); + } + const { namespace, kind, name } = req.params; - const salt = options.config.getString('custom.badges-backend.salt'); - const hash = crypto + const storedEntityHash: { hash: string } | undefined = + await db.getHashFromEntityMetadata(name, namespace, kind); + + if (isNil(storedEntityHash)) { + throw new NotFoundError( + `No hash found for entity "${namespace}/${kind}/${name}"`, + ); + } + + // Compare a live calculated hash with the stored one to avoid returning an obsolete entityHash (in case of a salt change) + + const liveHash = crypto .createHash('sha256') .update(`${kind}:${namespace}:${name}:${salt}`) .digest('hex'); - return res.status(200).json({ hash }); + if (storedEntityHash.hash !== liveHash) { + throw new NotFoundError( + "Stored Hash and live Hash don't match, did you change the salt? Try to refresh the badge database table and try again", + ); + } + + return res.status(200).json(storedEntityHash); }, ); @@ -248,7 +280,7 @@ export async function createRouter( for (const badgeInfo of await badgeBuilder.getBadges()) { const context: BadgeContext = { badgeUrl: await getBadgeUrl(namespace, kind, name, badgeInfo.id), - config: options.config, + config: config, entity, }; @@ -288,7 +320,7 @@ export async function createRouter( badgeInfo: { id: badgeId }, context: { badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId), - config: options.config, + config: config, entity, }, }; @@ -314,117 +346,11 @@ export async function createRouter( return router; } - // This function generate a table that maps the hash to the namespace/kind/name triplet - async function generateHashLookupTable() { - const logger = options.logger.child({ service: 'badges-backend' }); - const generationStartTimestamp = Date.now(); - logger.info('Start Generating lookup table'); - const token = await options.tokenManager.getToken(); - - // The salt is used to increase the hash entropy - const salt = options.config.getString('custom.badges-backend.salt'); - - // Get all the entities in the catalog of kind "Component" - const entitiesList = await catalog.getEntities( - { - filter: { - kind: 'Component', - }, - }, - token, - ); - if (isEmpty(entitiesList)) { - throw new NotFoundError(`No entities found`); - } - - // For each entity, generate the hash with the triplet kind:namespace:name then add the salt. Finally add it to the lookup table - entitiesList.items.map(async entity => { - const name = entity.metadata.name.toLocaleLowerCase(); - const creationDate: number = Date.now(); - const namespace = - entity.metadata.namespace?.toLocaleLowerCase() ?? 'default'; - const kind = entity.kind.toLocaleLowerCase(); - const hash = crypto - .createHash('sha256') - .update(`${kind}:${namespace}:${name}:${salt}`) - .digest('hex'); - - lookupTable.set(hash, { name, namespace, kind, creationDate }); - - return lookupTable; - }); - - // Monitor the lookup generation time and log it - logger.info( - `Finished generating lookup table in ${ - Date.now() - generationStartTimestamp - } ms`, - ); - // Monitor the lookup table size in byte - logger.info(`Lookup table size: ${lookupTable.size} entries`); - - return lookupTable; - } - - // This function check if the lookup table is empty or if it is expired (based on the cacheTimeToLive config) and need to be refreshed - function isLookupTableToRefresh( - table: Map< - string, - { - name: string; - namespace: string; - kind: string; - creationDate: number; - } - >, - ): boolean { - const lookupTableLength = table.size; - - if (lookupTableLength === 0) { - return true; - } - - const now = Date.now(); - const cacheTimeToLive = - options.config.getOptionalNumber( - 'custom.badges-backend.cacheTimeToLive', - ) ?? 10 * 60 * 1000; - - const valuesArray = Array.from(table.values()); - const lastEntry = valuesArray[valuesArray.length - 1]; - const lastCreationDate = lastEntry.creationDate; - - return lastCreationDate + cacheTimeToLive < now; - } - - // This function return the namespace/kind/name triplet from the lookup table based on the hash - async function getEntityInfoFromLookupTable( - entityHash: string, - ): Promise<{ name: string; namespace: string; kind: string }> { - if (lookupTable.get(entityHash) === undefined) { - throw new NotFoundError(`No entity with hash "${entityHash}"`); - } - - const name = lookupTable.get(entityHash)!.name; - const namespace = lookupTable.get(entityHash)!.namespace; - const kind = lookupTable.get(entityHash)!.kind; - - return { name, namespace, kind }; - } - // This function return the obfuscated badge url based on the namespace/kind/name triplet async function getBadgeObfuscatedUrl( - namespace: string, - kind: string, - name: string, + hash: string, badgeId: string, ): Promise { - const baseUrl = await options.discovery.getExternalBaseUrl('badges'); - const salt = options.config.getString('custom.badges-backend.salt'); - const hash = crypto - .createHash('sha256') - .update(`${kind}:${namespace}:${name}:${salt}`) - .digest('hex'); return `${baseUrl}/entity/${hash}/${badgeId}`; } @@ -435,7 +361,47 @@ export async function createRouter( name: string, badgeId: string, ): Promise { - const baseUrl = await options.discovery.getExternalBaseUrl('badges'); return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; } + + async function isBadgeDatabaseRefreshNeeded( + lastDatabaseRefreshTimestamp: number, + ): Promise { + if ((await db.countAllBadges()) === 0) { + logger.info('Badge database is empty, refreshing it'); + return true; + } + + if ( + lastDatabaseRefreshTimestamp === 0 || + Date.now() - lastDatabaseRefreshTimestamp > cacheTimeToLive * 1000 + ) { + logger.info( + 'Badge database refresh cache to live exceeded, refreshing it', + ); + return true; + } + + return false; + } + + async function refreshBadgeDatabase(): Promise { + const token = await tokenManager.getToken(); + const entities = await catalog.getEntities( + { + filter: { + kind: ['Component'], + }, + }, + token, + ); + logger.info( + `Refreshing badge database with ${entities.items.length} entities`, + ); + await db.createAllBadges(entities, salt); + logger.info('Badge database refreshed, deleting obsolete hashes'); + await db.deleteObsoleteHashes(entities, salt); + + return Date.now(); + } } diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index 9e29d5dead..5def5d6499 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -21,9 +21,12 @@ import { loadBackendConfig, ServerTokenManager, SingleHostDiscovery, + useHotMemoize, } from '@backstage/backend-common'; import { createRouter } from './router'; import { IdentityApi } from '@backstage/plugin-auth-node'; +import { DatabaseBadgesStore } from '../database/badgesStore'; +import Knex from 'knex'; export interface ServerOptions { port: number; @@ -37,6 +40,13 @@ export async function startStandaloneServer( const logger = options.logger.child({ service: 'badges-backend' }); const config = await loadBackendConfig({ logger, argv: process.argv }); const discovery = SingleHostDiscovery.fromConfig(config); + const database = useHotMemoize(module, () => { + return Knex({ + client: 'better-sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + }); logger.debug('Creating application...'); @@ -60,6 +70,9 @@ export async function startStandaloneServer( tokenManager, logger, identity, + db: await DatabaseBadgesStore.create({ + database: { getClient: async () => database }, + }), }); let service = createServiceBuilder(module) diff --git a/plugins/badges-backend/src/tests/badgeStore.test.ts b/plugins/badges-backend/src/tests/badgeStore.test.ts new file mode 100644 index 0000000000..1c6a76cb38 --- /dev/null +++ b/plugins/badges-backend/src/tests/badgeStore.test.ts @@ -0,0 +1,294 @@ +/* + * Copyright 2021 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 { DatabaseBadgesStore } from '../database/badgesStore'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Knex } from 'knex'; +import { Entity } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { GetEntitiesResponse } from '@backstage/catalog-client'; +import crypto from 'crypto'; + +describe('DatabaseBadgesStore', () => { + const config = new ConfigReader({ + backend: { + baseUrl: 'http://127.0.0.1', + listen: { + port: 7007, + }, + }, + app: { + badges: { + obfuscate: true, + }, + }, + custom: { + 'badges-backend': { + salt: 'random-string', + cacheTimeToLive: '60', + }, + }, + }); + + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + }, + }; + + const entities: Entity[] = [ + entity, + { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test-2', + }, + }, + { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test-3', + }, + }, + ]; + + const defaultEntityListResponse: GetEntitiesResponse = { + items: entities, + }; + + const databases = TestDatabases.create(); + + async function createDatabaseBadgesStore(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + return { + knex, + badgeStore: await DatabaseBadgesStore.create({ + database: { getClient: async () => knex }, + }), + }; + } + + describe.each(databases.eachSupportedId())('%p', databaseId => { + let knex: Knex; + let badgeStore: DatabaseBadgesStore; + + beforeEach(async () => { + ({ knex, badgeStore } = await createDatabaseBadgesStore(databaseId)); + }); + + it('createAllBadges', async () => { + await badgeStore.createAllBadges( + defaultEntityListResponse, + config.getString('custom.badges-backend.salt'), + ); + + const storedBadges = await badgeStore.getAllBadges(); + const testedEntities: { + name: string; + namespace: string; + kind: string; + hash: string; + }[] = []; + + entities.forEach(entityInLoop => { + const kind = entityInLoop.kind.toLowerCase(); + const namespace = entityInLoop.metadata.namespace || 'default'; + const name = entityInLoop.metadata.name.toLocaleLowerCase(); + const salt = config.getString('custom.badges-backend.salt'); + const entityHash = crypto + .createHash('sha256') + .update(`${kind}:${namespace}:${name}:${salt}`) + .digest('hex'); + + testedEntities.push({ + hash: entityHash, + name: name, + namespace: namespace, + kind: kind, + }); + expect(storedBadges).toEqual(expect.arrayContaining(testedEntities)); + expect(storedBadges.length).toEqual(3); + }); + }); + + it('getBadgeFromHash', async () => { + await knex('badges').truncate(); + await knex('badges').insert([ + { + hash: 'hash1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + const storedEntity = await badgeStore.getBadgeFromHash('hash1'); + + expect(storedEntity).toEqual({ + name: 'test', + namespace: 'default', + kind: 'component', + }); + }); + + it('getHashFromEntityMetadata', async () => { + await knex('badges').truncate(); + await knex('badges').insert([ + { + hash: 'hash1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + const storedHash = await badgeStore.getHashFromEntityMetadata( + 'test', + 'default', + 'component', + ); + + expect(storedHash).toEqual({ + hash: 'hash1', + }); + }); + + it('deleteObsoleteHashes', async () => { + const entityHash = crypto + .createHash('sha256') + .update( + `component:default:test:${config.getString( + 'custom.badges-backend.salt', + )}`, + ) + .digest('hex'); + + await knex('badges').insert([ + { + hash: 'obsoleteHash', + name: 'obsoleteEntity', + namespace: 'default', + kind: 'component', + }, + { + hash: entityHash, + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + await badgeStore.deleteObsoleteHashes( + defaultEntityListResponse, + config.getString('custom.badges-backend.salt'), + ); + + const storedBadges = await badgeStore.getAllBadges(); + + expect(storedBadges).toEqual( + expect.not.arrayContaining([ + { + hash: 'obsoleteHash', + name: 'obsoleteEntity', + namespace: 'default', + kind: 'component', + }, + ]), + ); + expect(storedBadges.length).toEqual(1); + expect(storedBadges).toEqual( + expect.arrayContaining([ + { + hash: entityHash, + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]), + ); + }); + + it('countAllBadges', async () => { + await knex('badges').truncate(); + await knex('badges').insert([ + { + hash: 'hash1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash2', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash3', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash4', + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + const storedBadgesCount = await badgeStore.countAllBadges(); + expect(storedBadgesCount).toEqual(4); + }); + + it('getAllBadges', async () => { + await knex('badges').truncate(); + await knex('badges').insert([ + { + hash: 'hash1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash2', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash3', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash4', + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + const storedBadgesCount = await badgeStore.countAllBadges(); + expect(storedBadgesCount).toEqual(4); + }); + }); +}); diff --git a/plugins/badges-backend/src/tests/router-obfuscated.test.ts b/plugins/badges-backend/src/tests/router-obfuscated.test.ts index d03cbe6d24..edf19d9f52 100644 --- a/plugins/badges-backend/src/tests/router-obfuscated.test.ts +++ b/plugins/badges-backend/src/tests/router-obfuscated.test.ts @@ -31,10 +31,16 @@ import { BackstageIdentityResponse, IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; +import { BadgesStore } from '../database/badgesStore'; +import crypto from 'crypto'; describe('createRouter', () => { let app: express.Express; - let badgeBuilder: jest.Mocked; + const badgeBuilder: jest.Mocked = { + getBadges: jest.fn(), + createBadgeJson: jest.fn(), + createBadgeSvg: jest.fn(), + }; const catalog = { addLocation: jest.fn(), @@ -49,10 +55,46 @@ describe('createRouter', () => { getEntityFacets: jest.fn(), validateEntity: jest.fn(), }; - let config: Config; - let discovery: PluginEndpointDiscovery; + const getIdentity = jest + .fn() + .mockImplementation( + async ({ + request: _request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + > => { + return { + identity: { + userEntityRef: 'user:default/guest', + ownershipEntityRefs: [], + type: 'user', + }, + token: 'token', + }; + }, + ); - const getIdentity = jest.fn(); + const config: Config = new ConfigReader({ + backend: { + baseUrl: 'http://127.0.0.1', + listen: { + port: 7007, + }, + }, + app: { + badges: { + obfuscate: true, + }, + }, + custom: { + 'badges-backend': { + salt: 'random-string', + cacheTimeToLive: '60', + }, + }, + }); + + let discovery: PluginEndpointDiscovery; const entity: Entity = { apiVersion: 'v1', @@ -83,49 +125,47 @@ describe('createRouter', () => { markdown: '[![...](...)]', }; + const badgeEntity = { + name: 'test', + namespace: 'default', + kind: 'component', + }; + + const badgeEntities = [ + { + hash: 'hash1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + { + hash: 'hash2', + name: 'test2', + namespace: 'default', + kind: 'component', + }, + ]; + + const badgeStore: jest.Mocked = { + createAllBadges: jest.fn(), + getBadgeFromHash: jest.fn().mockImplementation(async () => { + return badgeEntity; + }), + getHashFromEntityMetadata: jest + .fn() + .mockImplementation(async () => 'niceHash'), + deleteObsoleteHashes: jest.fn(), + countAllBadges: jest.fn().mockImplementation(async () => 4), + getAllBadges: jest.fn().mockImplementation(async () => badgeEntities), + }; + + const salt = config.getString('custom.badges-backend.salt'); + const entityHash = crypto + .createHash('sha256') + .update(`component:default:test:${salt}`) + .digest('hex'); + beforeAll(async () => { - getIdentity.mockImplementation( - async ({ - request: _request, - }: IdentityApiGetIdentityRequest): Promise< - BackstageIdentityResponse | undefined - > => { - return { - identity: { - userEntityRef: 'user:default/guest', - ownershipEntityRefs: [], - type: 'user', - }, - token: 'token', - }; - }, - ); - - badgeBuilder = { - getBadges: jest.fn(), - createBadgeJson: jest.fn(), - createBadgeSvg: jest.fn(), - }; - config = new ConfigReader({ - backend: { - baseUrl: 'http://127.0.0.1', - listen: { - port: 7007, - }, - }, - app: { - badges: { - obfuscate: true, - }, - }, - custom: { - 'badges-backend': { - salt: 'random-string', - cacheTimeToLive: '60', - }, - }, - }); - discovery = SingleHostDiscovery.fromConfig(config); const tokenManager = ServerTokenManager.noop(); const router = await createRouter({ @@ -136,12 +176,13 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, + db: badgeStore, }); app = express().use(router); }); beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('works', async () => { @@ -154,6 +195,7 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, + db: badgeStore, }); expect(router).toBeDefined(); }); @@ -206,9 +248,7 @@ describe('createRouter', () => { badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); - const response = await request(app).get( - '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/badge-specs', - ); + const response = await request(app).get(`/entity/hash1/badge-specs`); expect(response.status).toEqual(200); expect(response.body).toEqual([badge]); @@ -228,7 +268,7 @@ describe('createRouter', () => { badgeInfo: { id: badge.id }, context: { badgeUrl: expect.stringMatching( - /http:\/\/127.0.0.1\/api\/badges\/entity\/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed\/test-badge/, + /http:\/\/127.0.0.1\/api\/badges\/entity\/hash1\/test-badge/, ), config, entity, @@ -293,6 +333,9 @@ describe('createRouter', () => { describe('GET /entity/:namespace/:kind/:name/obfuscated', () => { catalog.getEntityByRef.mockResolvedValueOnce(entity); catalog.getEntities.mockResolvedValueOnce({ items: entities }); + badgeStore.getHashFromEntityMetadata.mockResolvedValue({ + hash: entityHash, + }); it('returns obfuscated entity', async () => { const obfuscatedEntity = await request(app) @@ -301,7 +344,7 @@ describe('createRouter', () => { expect(obfuscatedEntity.status).toEqual(200); // echo -n "component:default:test:random-string" | openssl dgst -sha256 expect(obfuscatedEntity.body).toEqual({ - hash: '3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed', + hash: entityHash, }); }); @@ -315,6 +358,7 @@ describe('createRouter', () => { describe('Errors', () => { it('returns 404 for unknown entity hash', async () => { + badgeStore.getBadgeFromHash.mockResolvedValue(undefined); catalog.getEntityByRef.mockResolvedValueOnce(entity); catalog.getEntities.mockResolvedValueOnce({ items: entities }); badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); diff --git a/plugins/badges-backend/src/tests/router.test.ts b/plugins/badges-backend/src/tests/router.test.ts index 1388b043d0..1d5fb3e9a1 100644 --- a/plugins/badges-backend/src/tests/router.test.ts +++ b/plugins/badges-backend/src/tests/router.test.ts @@ -31,6 +31,7 @@ import { BackstageIdentityResponse, IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; +import { BadgesStore } from '../database/badgesStore'; describe('createRouter', () => { let app: express.Express; @@ -73,6 +74,15 @@ describe('createRouter', () => { markdown: '[![...](...)]', }; + const badgeStore: jest.Mocked = { + createAllBadges: jest.fn(), + getBadgeFromHash: jest.fn(), + getHashFromEntityMetadata: jest.fn(), + deleteObsoleteHashes: jest.fn(), + countAllBadges: jest.fn(), + getAllBadges: jest.fn(), + }; + beforeAll(async () => { badgeBuilder = { getBadges: jest.fn(), @@ -113,6 +123,7 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, + db: badgeStore, }); app = express().use(router); }); @@ -131,6 +142,7 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, + db: badgeStore, }); expect(router).toBeDefined(); }); diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 0bed554beb..cd04e4db97 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -50,14 +50,9 @@ "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", - "@testing-library/dom": "^8.0.0", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3", - "@testing-library/user-event": "^14.0.0", + "@testing-library/jest-dom": "^5.16.5", "@types/node": "^16.11.26", - "@types/react": "^16.13.1 || ^17.0.0", - "cross-fetch": "^3.1.5", - "msw": "^1.0.0" + "cross-fetch": "^3.1.5" }, "files": [ "dist" diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index 5071b30437..db5019d893 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -18,30 +18,26 @@ import { generatePath } from 'react-router-dom'; import { ResponseError } from '@backstage/errors'; import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { BadgesApi, BadgeSpec } from './types'; -import { - ConfigApi, - DiscoveryApi, - IdentityApi, -} from '@backstage/core-plugin-api'; +import { ConfigApi, DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; export class BadgesClient implements BadgesApi { private readonly discoveryApi: DiscoveryApi; - private readonly identityApi: IdentityApi; + private readonly fetchApi: FetchApi; private readonly configApi: ConfigApi; constructor(options: { discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + fetchApi: FetchApi; configApi: ConfigApi; }) { this.discoveryApi = options.discoveryApi; - this.identityApi = options.identityApi; + this.fetchApi = options.fetchApi; this.configApi = options.configApi; } static fromConfig(options: { + fetchApi: FetchApi; discoveryApi: DiscoveryApi; - identityApi: IdentityApi; configApi: ConfigApi; }) { return new BadgesClient(options); @@ -49,9 +45,7 @@ export class BadgesClient implements BadgesApi { public async getEntityBadgeSpecs(entity: Entity): Promise { // Check if obfuscation is enabled in the configuration - const obfuscate = - this.configApi.getOptionalBoolean('app.badges.obfuscate') ?? false; - const { token } = await this.identityApi.getCredentials(); + const obfuscate = this.configApi.getOptionalBoolean('app.badges.obfuscate'); // If obfuscation is enabled, get the hash of the entity and use that to get the badge specs if (obfuscate) { @@ -63,13 +57,7 @@ export class BadgesClient implements BadgesApi { entityHash, ); - const response = await fetch(entityHashedBadgeSpecsUrl, { - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : undefined, - }); + const response = await this.fetchApi.fetch(entityHashedBadgeSpecsUrl); if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -79,13 +67,7 @@ export class BadgesClient implements BadgesApi { // If obfuscation is disabled, get the badge specs directly as the previous implementation const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); - const response = await fetch(entityBadgeSpecsUrl, { - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : undefined, - }); + const response = await this.fetchApi.fetch(entityBadgeSpecsUrl); if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -105,15 +87,7 @@ export class BadgesClient implements BadgesApi { // This function is used to get the hash of an entity when obfuscation is enabled. It's calling the badges backend to get the hash private async getEntityHash(entityHashUrl: string): Promise { - const { token: idToken } = await this.identityApi.getCredentials(); - - const responseEntityHash = await fetch(entityHashUrl, { - headers: idToken - ? { - Authorization: `Bearer ${idToken}`, - } - : undefined, - }); + const responseEntityHash = await this.fetchApi.fetch(entityHashUrl); if (!responseEntityHash.ok) { throw await ResponseError.fromResponse(responseEntityHash); diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index eac5ee11ad..771e3ed893 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -19,8 +19,8 @@ import { createApiFactory, createComponentExtension, createPlugin, + fetchApiRef, discoveryApiRef, - identityApiRef, } from '@backstage/core-plugin-api'; /** @public */ @@ -30,14 +30,14 @@ export const badgesPlugin = createPlugin({ createApiFactory({ api: badgesApiRef, deps: { + fetchApi: fetchApiRef, discoveryApi: discoveryApiRef, - identityApi: identityApiRef, configApi: configApiRef, }, - factory: ({ discoveryApi, identityApi, configApi }) => + factory: ({ fetchApi, discoveryApi, configApi }) => new BadgesClient({ + fetchApi, discoveryApi, - identityApi, configApi, }), }), diff --git a/yarn.lock b/yarn.lock index 213f03d6b1..899440e09e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4933,6 +4933,7 @@ __metadata: resolution: "@backstage/plugin-badges-backend@workspace:plugins/badges-backend" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" @@ -4942,9 +4943,6 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/test-utils": "workspace:^" - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 - "@testing-library/user-event": ^14.0.0 "@types/express": ^4.17.6 "@types/node": "*" badge-maker: ^3.3.0 @@ -4952,8 +4950,8 @@ __metadata: cross-fetch: ^3.1.5 express: ^4.17.1 express-promise-router: ^4.1.0 + knex: ^2.4.2 lodash: ^4.17.21 - msw: ^0.49.0 supertest: ^6.3.3 winston: ^3.2.1 yn: ^4.0.0 @@ -4984,7 +4982,6 @@ __metadata: "@types/node": ^16.11.26 "@types/react": ^16.13.1 || ^17.0.0 cross-fetch: ^3.1.5 - msw: ^1.0.0 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 @@ -15026,7 +15023,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4": +"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4, @testing-library/jest-dom@npm:^5.16.5": version: 5.16.5 resolution: "@testing-library/jest-dom@npm:5.16.5" dependencies: @@ -28987,7 +28984,7 @@ __metadata: languageName: node linkType: hard -"knex@npm:^2.0.0": +"knex@npm:^2.0.0, knex@npm:^2.4.2": version: 2.4.2 resolution: "knex@npm:2.4.2" dependencies: From 6263ef7ce9b2826ac7c7869ae20ac2e3481acd7f Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Fri, 21 Apr 2023 08:53:28 +0200 Subject: [PATCH 08/16] feat(badges): Remove hash implementation and replace it with uuid one. Signed-off-by: Rbillon59 --- packages/backend/src/plugins/badges.ts | 6 - plugins/badges-backend/README.md | 31 +-- plugins/badges-backend/api-report.md | 38 +-- .../migrations/20230404_init.js | 12 +- plugins/badges-backend/package.json | 1 + .../src/database/badgesStore.ts | 129 +++------ plugins/badges-backend/src/service/router.ts | 138 +++------- .../src/service/standaloneServer.ts | 2 +- .../src/tests/badgeStore.test.ts | 224 ++-------------- .../src/tests/router-obfuscated.test.ts | 251 ++++++------------ .../badges-backend/src/tests/router.test.ts | 20 +- plugins/badges/src/api/BadgesClient.ts | 35 ++- yarn.lock | 1 + 13 files changed, 215 insertions(+), 673 deletions(-) diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts index 6b40d1e5fb..fa50731809 100644 --- a/packages/backend/src/plugins/badges.ts +++ b/packages/backend/src/plugins/badges.ts @@ -20,15 +20,10 @@ import { } from '@backstage/plugin-badges-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -import { DatabaseBadgesStore } from '@backstage/plugin-badges-backend'; export default async function createPlugin( env: PluginEnvironment, ): Promise { - const db = await DatabaseBadgesStore.create({ - database: env.database, - }); - return await createRouter({ config: env.config, discovery: env.discovery, @@ -36,6 +31,5 @@ export default async function createPlugin( tokenManager: env.tokenManager, logger: env.logger, identity: env.identity, - db: db, }); } diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 4fcef981f1..5e9b8dd3cc 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -32,10 +32,6 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { - const db = await DatabaseBadgesStore.create({ - database: env.database, - }); - return await createRouter({ config: env.config, discovery: env.discovery, @@ -43,7 +39,6 @@ export default async function createPlugin( tokenManager: env.tokenManager, logger: env.logger, identity: env.identity, - db: db, }); } ``` @@ -134,23 +129,6 @@ app: > Note that you cannot use env vars to set the `obfuscate` value. It must be a boolean value and env vars are always strings. -Also you need to provide the [salt]() in the `app-config.yaml`: - -```yaml -custom: - badges-backend: - salt: # required - cacheTimeToLive: 60 # minutes (optional) -``` - -Any string can be used as a salt, but it's recommended to use a long random string to increase entropy. You can generate a random string using the following command: - -```bash -openssl rand -hex 32 -``` - -> Note: The salt is used to obfuscate the entity names, so if you change the salt, the entity names will be obfuscated differently and the already established badges (in Github repositories for examples) will need to be updated. - ## API The badges backend api exposes two main endpoints for entity badges. The @@ -169,16 +147,15 @@ The badges backend api exposes two main endpoints for entity badges. The ### If obfuscation is enabled (apps.badges.obfuscate: true) -- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity - hash from name, namespace, kind. +- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity url. -> Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. It meant to be called from the frontend plugin. +> Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. _It meant to be called from the frontend plugin._ -- `/badges/entity/:entityHash/:badgeId` Get the entity badge as an SVG image. If +- `/badges/entity/:entityUuid/:badgeId` Get the entity badge as an SVG image. If the `accept` request header prefers `application/json` the badge spec as JSON will be returned instead of the image. -- `/badge/entity/:entityHash/badge-specs` List all defined badges for a +- `/badge/entity/:entityUuid/badge-specs` List all defined badges for a particular entity, in json format. See [BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts) from the frontend plugin for a type declaration. diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md index 6f252cf27d..013c2c5ac7 100644 --- a/plugins/badges-backend/api-report.md +++ b/plugins/badges-backend/api-report.md @@ -7,7 +7,6 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; -import { GetEntitiesResponse } from '@backstage/catalog-client'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -85,28 +84,15 @@ export type BadgeSpec = { // @public export interface BadgesStore { // (undocumented) - countAllBadges(): Promise; + addBadge( + name: string, + namespace: string, + kind: string, + ): Promise<{ + uuid: string; + }>; // (undocumented) - createAllBadges( - entities: GetEntitiesResponse, - salt: string | undefined, - ): Promise; - // (undocumented) - deleteObsoleteHashes( - entities: GetEntitiesResponse, - salt: string | undefined, - ): Promise; - // (undocumented) - getAllBadges(): Promise< - { - name: string; - namespace: string; - kind: string; - hash: string; - }[] - >; - // (undocumented) - getBadgeFromHash(hash: string): Promise< + getBadgeFromUuid(uuid: string): Promise< | { name: string; namespace: string; @@ -115,13 +101,13 @@ export interface BadgesStore { | undefined >; // (undocumented) - getHashFromEntityMetadata( + getUuidFromEntityMetadata( name: string, namespace: string, kind: string, ): Promise< | { - hash: string; + uuid: string; } | undefined >; @@ -156,12 +142,12 @@ export interface RouterOptions { // (undocumented) badgeFactories?: BadgeFactories; // (undocumented) + badgeStore?: BadgesStore; + // (undocumented) catalog?: CatalogApi; // (undocumented) config: Config; // (undocumented) - db: BadgesStore; - // (undocumented) discovery: PluginEndpointDiscovery; // (undocumented) identity: IdentityApi; diff --git a/plugins/badges-backend/migrations/20230404_init.js b/plugins/badges-backend/migrations/20230404_init.js index d9a395d09a..f44b211038 100644 --- a/plugins/badges-backend/migrations/20230404_init.js +++ b/plugins/badges-backend/migrations/20230404_init.js @@ -19,15 +19,9 @@ exports.up = async function up(knex) { table.string('kind').notNullable(); table.string('namespace').notNullable(); table.string('name').notNullable(); - table - .string('hash') - .unique() - .comment( - 'Hash is calculated from the SHA256 of badge kind, namespace, name, and applications salt', - ) - .notNullable(); - table.index(['hash'], 'badges_hash_index'); - table.primary(['hash']); + table.string('uuid').unique().notNullable(); + table.index(['uuid'], 'badges_uuid_index'); + table.primary(['kind', 'namespace', 'name']); }); }; diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 4b05ffb2e4..b7bdd7c44a 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -47,6 +47,7 @@ "knex": "^2.4.2", "lodash": "^4.17.21", "supertest": "^6.3.3", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/badges-backend/src/database/badgesStore.ts b/plugins/badges-backend/src/database/badgesStore.ts index fa7bfa3f40..62d546f025 100644 --- a/plugins/badges-backend/src/database/badgesStore.ts +++ b/plugins/badges-backend/src/database/badgesStore.ts @@ -19,39 +19,28 @@ import { resolvePackagePath, } from '@backstage/backend-common'; import { Knex } from 'knex'; -import crypto from 'crypto'; -import { GetEntitiesResponse } from '@backstage/catalog-client'; +import { v4 as uuidv4 } from 'uuid'; /** * internal * @public */ export interface BadgesStore { - createAllBadges( - entities: GetEntitiesResponse, - salt: string | undefined, - ): Promise; - - getBadgeFromHash( - hash: string, - ): Promise<{ name: string; namespace: string; kind: string } | undefined>; - - getHashFromEntityMetadata( + addBadge( name: string, namespace: string, kind: string, - ): Promise<{ hash: string } | undefined>; + ): Promise<{ uuid: string }>; - deleteObsoleteHashes( - entities: GetEntitiesResponse, - salt: string | undefined, - ): Promise; + getBadgeFromUuid( + uuid: string, + ): Promise<{ name: string; namespace: string; kind: string } | undefined>; - countAllBadges(): Promise; - - getAllBadges(): Promise< - { name: string; namespace: string; kind: string; hash: string }[] - >; + getUuidFromEntityMetadata( + name: string, + namespace: string, + kind: string, + ): Promise<{ uuid: string } | undefined>; } const migrationsDir = resolvePackagePath( @@ -84,99 +73,47 @@ export class DatabaseBadgesStore implements BadgesStore { return new DatabaseBadgesStore(client); } - async createAllBadges( - entities: GetEntitiesResponse, - salt: string, - ): Promise { - for (const entity of entities.items) { - const name = entity.metadata.name.toLocaleLowerCase(); - const namespace = - entity.metadata.namespace?.toLocaleLowerCase() ?? 'default'; - const kind = entity.kind.toLocaleLowerCase(); - const entityHash = crypto - .createHash('sha256') - .update(`${kind}:${namespace}:${name}:${salt}`) - .digest('hex'); - - await this.db('badges') - .insert({ - hash: entityHash, - namespace: namespace, - name: name, - kind: kind, - }) - .onConflict() - .ignore(); - } - } - - async getAllBadges(): Promise< - { name: string; namespace: string; kind: string; hash: string }[] - > { - const result = await this.db('badges').select('*').orderBy('name', 'asc'); - return result; - } - - async getBadgeFromHash( - hash: string, + async getBadgeFromUuid( + uuid: string, ): Promise<{ name: string; namespace: string; kind: string } | undefined> { const result = await this.db('badges') .select('namespace', 'name', 'kind') - .where({ hash: hash }) + .where({ uuid: uuid }) .first(); return result; } - async getHashFromEntityMetadata( + async getUuidFromEntityMetadata( name: string, namespace: string, kind: string, - ): Promise<{ hash: string } | undefined> { + ): Promise<{ uuid: string } | undefined> { const result = await this.db('badges') - .select('hash') + .select('uuid') .where({ name: name, namespace: namespace, kind: kind }) .first(); return result; } - async deleteObsoleteHashes( - entities: GetEntitiesResponse, - salt: string, - ): Promise { - const entityInCatalog: string[] = []; - const entityInBadgeDatabase: string[] = []; - let entityToDelete: string[] = []; + async addBadge( + name: string, + namespace: string, + kind: string, + ): Promise<{ uuid: string }> { + const uuid = uuidv4(); - for (const entity of entities.items) { - const name = entity.metadata.name.toLowerCase(); - const namespace = entity.metadata.namespace?.toLowerCase() ?? 'default'; - const kind = entity.kind.toLowerCase(); - const entityHash = crypto - .createHash('sha256') - .update(`${kind}:${namespace}:${name}:${salt}`) - .digest('hex'); + await this.db('badges') + .insert({ + uuid: uuid, + name: name, + namespace: namespace, + kind: kind, + }) + .onConflict(['name', 'namespace', 'kind']) + .ignore(); - entityInCatalog.push(entityHash); - } - - for (const entity of await this.getAllBadges()) { - entityInBadgeDatabase.push(entity.hash); - } - - entityToDelete = entityInBadgeDatabase.filter( - entity => !entityInCatalog.includes(entity), - ); - - for (const entity of entityToDelete) { - await this.db('badges').where({ hash: entity }).del(); - } - } - - async countAllBadges(): Promise { - const result = await this.db('badges').countDistinct('hash as count'); - const count: number = +result[0].count; - return count; + return { uuid }; } } diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index db568eb0d0..d1cb0cff36 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -17,6 +17,7 @@ import express from 'express'; import Router from 'express-promise-router'; import { + DatabaseManager, errorHandler, PluginEndpointDiscovery, TokenManager, @@ -27,11 +28,10 @@ import { NotFoundError } from '@backstage/errors'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; import { BadgeContext, BadgeFactories } from '../types'; import { isNil } from 'lodash'; -import crypto from 'crypto'; import { Logger } from 'winston'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; -import type { BadgesStore } from '../database/badgesStore'; +import { BadgesStore, DatabaseBadgesStore } from '../database/badgesStore'; /** @public */ export interface RouterOptions { @@ -43,7 +43,7 @@ export interface RouterOptions { tokenManager: TokenManager; logger: Logger; identity: IdentityApi; - db: BadgesStore; + badgeStore?: BadgesStore; } /** @public */ @@ -57,39 +57,29 @@ export async function createRouter( new DefaultBadgeBuilder(options.badgeFactories || {}); const router = Router(); - const { db, config, logger, tokenManager, discovery, identity } = options; + const { config, logger, tokenManager, discovery, identity } = options; const baseUrl = await discovery.getExternalBaseUrl('badges'); - const salt = config.getOptionalString('custom.badges-backend.salt'); - const cacheTimeToLive = - config.getOptionalNumber('badgeDatabaseRefreshCacheTimeToLive') ?? 3600; - let lastDatabaseRefresh = 0; - - // Check if the users have enabled the obfuscation of the entity name if (config.getOptionalBoolean('app.badges.obfuscate')) { logger.info('Badges obfuscation is enabled'); - if (isNil(salt)) { - throw new Error( - 'Badges obfuscation is enabled but no salt has been provided', - ); - } + const store = options.badgeStore + ? options.badgeStore + : await DatabaseBadgesStore.create({ + database: await DatabaseManager.fromConfig(config).forPlugin( + 'badges', + ), + }); - // Use the generated hash instead of the triplet namespace/kind/name - router.get('/entity/:entityHash/badge-specs', async (req, res) => { - const { entityHash } = req.params; - - // Chech if the database needs to be refreshed - if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) { - lastDatabaseRefresh = await refreshBadgeDatabase(); - } + router.get('/entity/:entityUuid/badge-specs', async (req, res) => { + const { entityUuid } = req.params; // Retrieve the badge info from the database - const badgeInfos = await db.getBadgeFromHash(entityHash); + const badgeInfos = await store.getBadgeFromUuid(entityUuid); if (isNil(badgeInfos)) { throw new NotFoundError( - `No badge found for entity hash "${entityHash}"`, + `No badge found for entity uuid "${entityUuid}"`, ); } @@ -118,7 +108,7 @@ export async function createRouter( const specs = []; for (const badgeInfo of await badgeBuilder.getBadges()) { const context: BadgeContext = { - badgeUrl: await getBadgeObfuscatedUrl(entityHash, badgeInfo.id), + badgeUrl: await getBadgeObfuscatedUrl(entityUuid, badgeInfo.id), config: config, entity, }; @@ -133,20 +123,15 @@ export async function createRouter( res.status(200).json(specs); }); - // Use the generated hash instead of the triplet namespace/kind/name - router.get('/entity/:entityHash/:badgeId', async (req, res) => { - if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) { - lastDatabaseRefresh = await refreshBadgeDatabase(); - } - - const { entityHash, badgeId } = req.params; + router.get('/entity/:entityUuid/:badgeId', async (req, res) => { + const { entityUuid, badgeId } = req.params; // Retrieve the badge info from the database - const badgeInfo = await db.getBadgeFromHash(entityHash); + const badgeInfo = await store.getBadgeFromUuid(entityUuid); if (isNil(badgeInfo)) { throw new NotFoundError( - `No badge found for entity hash "${entityHash}"`, + `No badge found for entity uuid "${entityUuid}"`, ); } @@ -180,7 +165,7 @@ export async function createRouter( const badgeOptions = { badgeInfo: { id: badgeId }, context: { - badgeUrl: await getBadgeObfuscatedUrl(entityHash, badgeId), + badgeUrl: await getBadgeObfuscatedUrl(entityUuid, badgeId), config: config, entity, }, @@ -201,7 +186,6 @@ export async function createRouter( res.status(200).send(data); }); - // Generate the hash for queried the namespace/kind/name triplet router.get( '/entity/:namespace/:kind/:name/obfuscated', function authenticate(req, res, next) { @@ -223,34 +207,21 @@ export async function createRouter( } }, async (req, res) => { - if (await isBadgeDatabaseRefreshNeeded(lastDatabaseRefresh)) { - lastDatabaseRefresh = await refreshBadgeDatabase(); - } - const { namespace, kind, name } = req.params; - const storedEntityHash: { hash: string } | undefined = - await db.getHashFromEntityMetadata(name, namespace, kind); + let storedEntityUuid: { uuid: string } | undefined = + await store.getUuidFromEntityMetadata(name, namespace, kind); - if (isNil(storedEntityHash)) { - throw new NotFoundError( - `No hash found for entity "${namespace}/${kind}/${name}"`, - ); + if (isNil(storedEntityUuid)) { + storedEntityUuid = await store.addBadge(name, namespace, kind); + + if (isNil(storedEntityUuid)) { + throw new NotFoundError( + `No uuid found for entity "${namespace}/${kind}/${name}"`, + ); + } } - // Compare a live calculated hash with the stored one to avoid returning an obsolete entityHash (in case of a salt change) - - const liveHash = crypto - .createHash('sha256') - .update(`${kind}:${namespace}:${name}:${salt}`) - .digest('hex'); - - if (storedEntityHash.hash !== liveHash) { - throw new NotFoundError( - "Stored Hash and live Hash don't match, did you change the salt? Try to refresh the badge database table and try again", - ); - } - - return res.status(200).json(storedEntityHash); + return res.status(200).json(storedEntityUuid); }, ); @@ -348,10 +319,10 @@ export async function createRouter( // This function return the obfuscated badge url based on the namespace/kind/name triplet async function getBadgeObfuscatedUrl( - hash: string, + uuid: string, badgeId: string, ): Promise { - return `${baseUrl}/entity/${hash}/${badgeId}`; + return `${baseUrl}/entity/${uuid}/${badgeId}`; } // This function return the badge url based on the namespace/kind/name triplet @@ -363,45 +334,4 @@ export async function createRouter( ): Promise { return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; } - - async function isBadgeDatabaseRefreshNeeded( - lastDatabaseRefreshTimestamp: number, - ): Promise { - if ((await db.countAllBadges()) === 0) { - logger.info('Badge database is empty, refreshing it'); - return true; - } - - if ( - lastDatabaseRefreshTimestamp === 0 || - Date.now() - lastDatabaseRefreshTimestamp > cacheTimeToLive * 1000 - ) { - logger.info( - 'Badge database refresh cache to live exceeded, refreshing it', - ); - return true; - } - - return false; - } - - async function refreshBadgeDatabase(): Promise { - const token = await tokenManager.getToken(); - const entities = await catalog.getEntities( - { - filter: { - kind: ['Component'], - }, - }, - token, - ); - logger.info( - `Refreshing badge database with ${entities.items.length} entities`, - ); - await db.createAllBadges(entities, salt); - logger.info('Badge database refreshed, deleting obsolete hashes'); - await db.deleteObsoleteHashes(entities, salt); - - return Date.now(); - } } diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index 5def5d6499..b07e1fa3b1 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -70,7 +70,7 @@ export async function startStandaloneServer( tokenManager, logger, identity, - db: await DatabaseBadgesStore.create({ + badgeStore: await DatabaseBadgesStore.create({ database: { getClient: async () => database }, }), }); diff --git a/plugins/badges-backend/src/tests/badgeStore.test.ts b/plugins/badges-backend/src/tests/badgeStore.test.ts index 1c6a76cb38..2fd18e4233 100644 --- a/plugins/badges-backend/src/tests/badgeStore.test.ts +++ b/plugins/badges-backend/src/tests/badgeStore.test.ts @@ -18,31 +18,8 @@ import { DatabaseBadgesStore } from '../database/badgesStore'; import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; import { Knex } from 'knex'; import { Entity } from '@backstage/catalog-model'; -import { ConfigReader } from '@backstage/config'; -import { GetEntitiesResponse } from '@backstage/catalog-client'; -import crypto from 'crypto'; describe('DatabaseBadgesStore', () => { - const config = new ConfigReader({ - backend: { - baseUrl: 'http://127.0.0.1', - listen: { - port: 7007, - }, - }, - app: { - badges: { - obfuscate: true, - }, - }, - custom: { - 'badges-backend': { - salt: 'random-string', - cacheTimeToLive: '60', - }, - }, - }); - const entity: Entity = { apiVersion: 'v1', kind: 'Component', @@ -51,28 +28,6 @@ describe('DatabaseBadgesStore', () => { }, }; - const entities: Entity[] = [ - entity, - { - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'test-2', - }, - }, - { - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'test-3', - }, - }, - ]; - - const defaultEntityListResponse: GetEntitiesResponse = { - items: entities, - }; - const databases = TestDatabases.create(); async function createDatabaseBadgesStore(databaseId: TestDatabaseId) { @@ -93,53 +48,33 @@ describe('DatabaseBadgesStore', () => { ({ knex, badgeStore } = await createDatabaseBadgesStore(databaseId)); }); - it('createAllBadges', async () => { - await badgeStore.createAllBadges( - defaultEntityListResponse, - config.getString('custom.badges-backend.salt'), + it('createABadge', async () => { + const uuid = await badgeStore.addBadge( + entity.metadata.name, + entity.metadata.namespace || 'default', + entity.kind, ); - const storedBadges = await badgeStore.getAllBadges(); - const testedEntities: { - name: string; - namespace: string; - kind: string; - hash: string; - }[] = []; - - entities.forEach(entityInLoop => { - const kind = entityInLoop.kind.toLowerCase(); - const namespace = entityInLoop.metadata.namespace || 'default'; - const name = entityInLoop.metadata.name.toLocaleLowerCase(); - const salt = config.getString('custom.badges-backend.salt'); - const entityHash = crypto - .createHash('sha256') - .update(`${kind}:${namespace}:${name}:${salt}`) - .digest('hex'); - - testedEntities.push({ - hash: entityHash, - name: name, - namespace: namespace, - kind: kind, - }); - expect(storedBadges).toEqual(expect.arrayContaining(testedEntities)); - expect(storedBadges.length).toEqual(3); - }); + const storedBadge = await badgeStore.getBadgeFromUuid(uuid.uuid); + expect(storedBadge?.kind).toEqual(entity.kind); + expect(storedBadge?.name).toEqual(entity.metadata.name); + expect(storedBadge?.namespace).toEqual( + entity.metadata.namespace || 'default', + ); }); - it('getBadgeFromHash', async () => { + it('getBadgeFromUuid', async () => { await knex('badges').truncate(); await knex('badges').insert([ { - hash: 'hash1', + uuid: 'uuid1', name: 'test', namespace: 'default', kind: 'component', }, ]); - const storedEntity = await badgeStore.getBadgeFromHash('hash1'); + const storedEntity = await badgeStore.getBadgeFromUuid('uuid1'); expect(storedEntity).toEqual({ name: 'test', @@ -148,147 +83,26 @@ describe('DatabaseBadgesStore', () => { }); }); - it('getHashFromEntityMetadata', async () => { + it('getUuidFromEntityMetadata', async () => { await knex('badges').truncate(); await knex('badges').insert([ { - hash: 'hash1', + uuid: 'uuid1', name: 'test', namespace: 'default', kind: 'component', }, ]); - const storedHash = await badgeStore.getHashFromEntityMetadata( + const storedUuid = await badgeStore.getUuidFromEntityMetadata( 'test', 'default', 'component', ); - expect(storedHash).toEqual({ - hash: 'hash1', + expect(storedUuid).toEqual({ + uuid: 'uuid1', }); }); - - it('deleteObsoleteHashes', async () => { - const entityHash = crypto - .createHash('sha256') - .update( - `component:default:test:${config.getString( - 'custom.badges-backend.salt', - )}`, - ) - .digest('hex'); - - await knex('badges').insert([ - { - hash: 'obsoleteHash', - name: 'obsoleteEntity', - namespace: 'default', - kind: 'component', - }, - { - hash: entityHash, - name: 'test', - namespace: 'default', - kind: 'component', - }, - ]); - - await badgeStore.deleteObsoleteHashes( - defaultEntityListResponse, - config.getString('custom.badges-backend.salt'), - ); - - const storedBadges = await badgeStore.getAllBadges(); - - expect(storedBadges).toEqual( - expect.not.arrayContaining([ - { - hash: 'obsoleteHash', - name: 'obsoleteEntity', - namespace: 'default', - kind: 'component', - }, - ]), - ); - expect(storedBadges.length).toEqual(1); - expect(storedBadges).toEqual( - expect.arrayContaining([ - { - hash: entityHash, - name: 'test', - namespace: 'default', - kind: 'component', - }, - ]), - ); - }); - - it('countAllBadges', async () => { - await knex('badges').truncate(); - await knex('badges').insert([ - { - hash: 'hash1', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash2', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash3', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash4', - name: 'test', - namespace: 'default', - kind: 'component', - }, - ]); - - const storedBadgesCount = await badgeStore.countAllBadges(); - expect(storedBadgesCount).toEqual(4); - }); - - it('getAllBadges', async () => { - await knex('badges').truncate(); - await knex('badges').insert([ - { - hash: 'hash1', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash2', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash3', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash4', - name: 'test', - namespace: 'default', - kind: 'component', - }, - ]); - - const storedBadgesCount = await badgeStore.countAllBadges(); - expect(storedBadgesCount).toEqual(4); - }); }); }); diff --git a/plugins/badges-backend/src/tests/router-obfuscated.test.ts b/plugins/badges-backend/src/tests/router-obfuscated.test.ts index edf19d9f52..1253e59157 100644 --- a/plugins/badges-backend/src/tests/router-obfuscated.test.ts +++ b/plugins/badges-backend/src/tests/router-obfuscated.test.ts @@ -32,7 +32,6 @@ import { IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; import { BadgesStore } from '../database/badgesStore'; -import crypto from 'crypto'; describe('createRouter', () => { let app: express.Express; @@ -80,18 +79,16 @@ describe('createRouter', () => { listen: { port: 7007, }, + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, }, app: { badges: { obfuscate: true, }, }, - custom: { - 'badges-backend': { - salt: 'random-string', - cacheTimeToLive: '60', - }, - }, }); let discovery: PluginEndpointDiscovery; @@ -131,40 +128,18 @@ describe('createRouter', () => { kind: 'component', }; - const badgeEntities = [ - { - hash: 'hash1', - name: 'test', - namespace: 'default', - kind: 'component', - }, - { - hash: 'hash2', - name: 'test2', - namespace: 'default', - kind: 'component', - }, - ]; - const badgeStore: jest.Mocked = { - createAllBadges: jest.fn(), - getBadgeFromHash: jest.fn().mockImplementation(async () => { + addBadge: jest.fn().mockImplementation(async () => { + return { uuid: 'uuid1' }; + }), + getBadgeFromUuid: jest.fn().mockImplementation(async () => { return badgeEntity; }), - getHashFromEntityMetadata: jest + getUuidFromEntityMetadata: jest .fn() - .mockImplementation(async () => 'niceHash'), - deleteObsoleteHashes: jest.fn(), - countAllBadges: jest.fn().mockImplementation(async () => 4), - getAllBadges: jest.fn().mockImplementation(async () => badgeEntities), + .mockImplementation(async () => 'uuid1'), }; - const salt = config.getString('custom.badges-backend.salt'); - const entityHash = crypto - .createHash('sha256') - .update(`component:default:test:${salt}`) - .digest('hex'); - beforeAll(async () => { discovery = SingleHostDiscovery.fromConfig(config); const tokenManager = ServerTokenManager.noop(); @@ -176,7 +151,6 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, - db: badgeStore, }); app = express().use(router); }); @@ -185,7 +159,7 @@ describe('createRouter', () => { jest.clearAllMocks(); }); - it('works', async () => { + it('works with provided badgeStore', async () => { const tokenManager = ServerTokenManager.noop(); const router = await createRouter({ badgeBuilder, @@ -195,7 +169,7 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, - db: badgeStore, + badgeStore: badgeStore, }); expect(router).toBeDefined(); }); @@ -240,113 +214,9 @@ describe('createRouter', () => { }); }); - describe('GET /entity/:entityHash/badge-specs', () => { - it('returns all badge specs for entity', async () => { - catalog.getEntities.mockResolvedValueOnce({ items: entities }); - catalog.getEntityByRef.mockResolvedValueOnce(entity); - - badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); - badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); - - const response = await request(app).get(`/entity/hash1/badge-specs`); - expect(response.status).toEqual(200); - expect(response.body).toEqual([badge]); - - expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); - expect(catalog.getEntityByRef).toHaveBeenCalledWith( - { - namespace: 'default', - kind: 'component', - name: 'test', - }, - { token: '' }, - ); - - expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadgeJson).toHaveBeenCalledWith({ - badgeInfo: { id: badge.id }, - context: { - badgeUrl: expect.stringMatching( - /http:\/\/127.0.0.1\/api\/badges\/entity\/hash1\/test-badge/, - ), - config, - entity, - }, - }); - }); - }); - - describe('GET /entity/:entityHash/test-badge', () => { - it('returns badge for entity', async () => { - catalog.getEntityByRef.mockResolvedValueOnce(entity); - catalog.getEntities.mockResolvedValueOnce({ items: entities }); - - const image = '...'; - badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); - - const response = await request(app).get( - '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/test-badge', - ); - - expect(response.status).toEqual(200); - expect(response.body).toEqual(Buffer.from(image)); - - expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1); - expect(catalog.getEntityByRef).toHaveBeenCalledWith( - { - namespace: 'default', - kind: 'component', - name: 'test', - }, - { token: '' }, - ); - - expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); - expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); - expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({ - badgeInfo: { id: badge.id }, - context: { - badgeUrl: expect.stringMatching( - /http:\/\/127.0.0.1\/api\/badges\/entity\/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed\/test-badge/, - ), - config, - entity, - }, - }); - }); - - it('returns badge spec for entity', async () => { - catalog.getEntityByRef.mockResolvedValueOnce(entity); - catalog.getEntities.mockResolvedValueOnce({ items: entities }); - badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); - - const url = - '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7c322c600e7497dc9d517353e5bed/test-badge?format=json'; - const response = await request(app).get(url); - - expect(response.status).toEqual(200); - expect(response.body).toEqual(badge); - }); - }); - describe('GET /entity/:namespace/:kind/:name/obfuscated', () => { catalog.getEntityByRef.mockResolvedValueOnce(entity); catalog.getEntities.mockResolvedValueOnce({ items: entities }); - badgeStore.getHashFromEntityMetadata.mockResolvedValue({ - hash: entityHash, - }); - - it('returns obfuscated entity', async () => { - const obfuscatedEntity = await request(app) - .get('/entity/default/component/test/obfuscated') - .set('Authorization', 'Bearer fakeToken'); - expect(obfuscatedEntity.status).toEqual(200); - // echo -n "component:default:test:random-string" | openssl dgst -sha256 - expect(obfuscatedEntity.body).toEqual({ - hash: entityHash, - }); - }); it('returns obfuscated 401 if no auth', async () => { const obfuscatedEntity = await request(app).get( @@ -354,39 +224,82 @@ describe('createRouter', () => { ); expect(obfuscatedEntity.status).toEqual(401); }); - }); - describe('Errors', () => { - it('returns 404 for unknown entity hash', async () => { - badgeStore.getBadgeFromHash.mockResolvedValue(undefined); + it('returns obfuscated entity and badges', async () => { + const obfuscatedEntity = await request(app) + .get('/entity/default/component/test/obfuscated') + .set('Authorization', 'Bearer fakeToken'); + expect(obfuscatedEntity.status).toEqual(200); + expect(obfuscatedEntity.body.uuid).toMatch( + new RegExp( + '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + ), + ); + + const uuid = obfuscatedEntity.body.uuid; + const url = `/entity/${uuid}/test-badge?format=json`; + let response = await request(app).get(url); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(badge); + catalog.getEntityByRef.mockResolvedValueOnce(entity); catalog.getEntities.mockResolvedValueOnce({ items: entities }); + + const image = '...'; + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); + + response = await request(app).get(`/entity/${uuid}/test-badge`); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(Buffer.from(image)); + + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + catalog.getEntityByRef.mockResolvedValueOnce(entity); + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); - async function testUrl(url: string) { - const response = await request(app).get(url); - expect(response.status).toEqual(404); - expect(response.body).toEqual({ - error: { - message: expect.any(String), - name: 'NotFoundError', - }, - request: { - method: 'GET', - url, - }, - response: { - statusCode: 404, - }, - }); - } - await testUrl( - '/entity/3a5f91c1e66519be5394c37a8ba69cfsf3087b7c322c600e7497dc9d517353e5bed/badge-specs', - ); - await testUrl( - '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7csfsf322c600e7497dc9d517353e5bed/test-badge', - ); + response = await request(app).get(`/entity/${uuid}/badge-specs`); + expect(response.status).toEqual(200); + expect(response.body).toEqual([badge]); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(2); + }); + + describe('Errors', () => { + it('returns 404 for unknown entity uuid', async () => { + badgeStore.getBadgeFromUuid.mockResolvedValue(undefined); + catalog.getEntityByRef.mockResolvedValueOnce(entity); + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + async function testUrl(url: string) { + const response = await request(app).get(url); + expect(response.status).toEqual(404); + expect(response.body).toEqual({ + error: { + message: expect.any(String), + name: 'NotFoundError', + }, + request: { + method: 'GET', + url, + }, + response: { + statusCode: 404, + }, + }); + } + await testUrl( + '/entity/3a5f91c1e66519be5394c37a8ba69cfsf3087b7c322c600e7497dc9d517353e5bed/badge-specs', + ); + await testUrl( + '/entity/3a5f91c1e66519be5394c37a8ba69c3087b7csfsf322c600e7497dc9d517353e5bed/test-badge', + ); + }); }); }); }); diff --git a/plugins/badges-backend/src/tests/router.test.ts b/plugins/badges-backend/src/tests/router.test.ts index 1d5fb3e9a1..a44281e9fa 100644 --- a/plugins/badges-backend/src/tests/router.test.ts +++ b/plugins/badges-backend/src/tests/router.test.ts @@ -75,12 +75,9 @@ describe('createRouter', () => { }; const badgeStore: jest.Mocked = { - createAllBadges: jest.fn(), - getBadgeFromHash: jest.fn(), - getHashFromEntityMetadata: jest.fn(), - deleteObsoleteHashes: jest.fn(), - countAllBadges: jest.fn(), - getAllBadges: jest.fn(), + getBadgeFromUuid: jest.fn(), + getUuidFromEntityMetadata: jest.fn(), + addBadge: jest.fn(), }; beforeAll(async () => { @@ -93,6 +90,10 @@ describe('createRouter', () => { backend: { baseUrl: 'http://127.0.0.1', listen: { port: 7007 }, + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, }, }); @@ -123,7 +124,6 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, - db: badgeStore, }); app = express().use(router); }); @@ -132,7 +132,7 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - it('works', async () => { + it('works with badgeStore', async () => { const tokenManager = ServerTokenManager.noop(); const router = await createRouter({ badgeBuilder, @@ -142,7 +142,7 @@ describe('createRouter', () => { tokenManager, logger: getVoidLogger(), identity: { getIdentity }, - db: badgeStore, + badgeStore: badgeStore, }); expect(router).toBeDefined(); }); @@ -261,7 +261,7 @@ describe('createRouter', () => { }); }); - it('returns 404 for hashed entities', async () => { + it('returns 404 for uuid entities', async () => { catalog.getEntityByRef.mockResolvedValue(undefined); async function testUrl(url: string) { const response = await request(app).get(url); diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts index db5019d893..9a08db882a 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -47,17 +47,16 @@ export class BadgesClient implements BadgesApi { // Check if obfuscation is enabled in the configuration const obfuscate = this.configApi.getOptionalBoolean('app.badges.obfuscate'); - // If obfuscation is enabled, get the hash of the entity and use that to get the badge specs if (obfuscate) { - const entityHashUrl = await this.getEntityHashUrl(entity); - const entityHash = await this.getEntityHash(entityHashUrl).then(data => { - return data.hash; + const entityUuidUrl = await this.getEntityUuidUrl(entity); + const entityUuid = await this.getEntityUuid(entityUuidUrl).then(data => { + return data.uuid; }); - const entityHashedBadgeSpecsUrl = await this.getEntityHashedBadgeSpecsUrl( - entityHash, + const entityUuidBadgeSpecsUrl = await this.getEntityUuidBadgeSpecsUrl( + entityUuid, ); - const response = await this.fetchApi.fetch(entityHashedBadgeSpecsUrl); + const response = await this.fetchApi.fetch(entityUuidBadgeSpecsUrl); if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -75,8 +74,7 @@ export class BadgesClient implements BadgesApi { return await response.json(); } - // This function is used to generate the URL to get the badge specs for an entity when obfuscation is enabled. Using the hash - private async getEntityHashUrl(entity: Entity): Promise { + private async getEntityUuidUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(`:namespace/:kind/:name`, routeParams); const baseUrl = await this.discoveryApi.getBaseUrl('badges'); @@ -85,25 +83,22 @@ export class BadgesClient implements BadgesApi { return obfuscatedEntityUrl; } - // This function is used to get the hash of an entity when obfuscation is enabled. It's calling the badges backend to get the hash - private async getEntityHash(entityHashUrl: string): Promise { - const responseEntityHash = await this.fetchApi.fetch(entityHashUrl); + private async getEntityUuid(entityUuidUrl: string): Promise { + const responseEntityUuid = await this.fetchApi.fetch(entityUuidUrl); - if (!responseEntityHash.ok) { - throw await ResponseError.fromResponse(responseEntityHash); + if (!responseEntityUuid.ok) { + throw await ResponseError.fromResponse(responseEntityUuid); } - return await responseEntityHash.json(); + return await responseEntityUuid.json(); } - // This function is used to generate the URLs to use in the frontend when obfuscation is enabled. It's using the hash of the entity to get the badge specs - private async getEntityHashedBadgeSpecsUrl(entityHash: { - hash: string; + private async getEntityUuidBadgeSpecsUrl(entityUuid: { + uuid: string; }): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('badges'); - return `${baseUrl}/entity/${entityHash}/badge-specs`; + return `${baseUrl}/entity/${entityUuid}/badge-specs`; } - // This function is used to generate the URLs to use in the frontend when obfuscation is disabled. It's using the entity name to get the badge specs private async getEntityBadgeSpecsUrl(entity: Entity): Promise { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(`:namespace/:kind/:name`, routeParams); diff --git a/yarn.lock b/yarn.lock index 899440e09e..442d981e9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4953,6 +4953,7 @@ __metadata: knex: ^2.4.2 lodash: ^4.17.21 supertest: ^6.3.3 + uuid: ^9.0.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown From 2fbf5300c5b925267d2acf212d2d8330ecebcc40 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Thu, 27 Apr 2023 11:45:47 +0200 Subject: [PATCH 09/16] chore(badges): improve error handling, improve visibility splitting if else block to functions, move db test file beside the tested file Signed-off-by: Rbillon59 --- .../{tests => database}/badgeStore.test.ts | 2 +- plugins/badges-backend/src/service/router.ts | 450 +++++++++--------- 2 files changed, 231 insertions(+), 221 deletions(-) rename plugins/badges-backend/src/{tests => database}/badgeStore.test.ts (97%) diff --git a/plugins/badges-backend/src/tests/badgeStore.test.ts b/plugins/badges-backend/src/database/badgeStore.test.ts similarity index 97% rename from plugins/badges-backend/src/tests/badgeStore.test.ts rename to plugins/badges-backend/src/database/badgeStore.test.ts index 2fd18e4233..60a885daa4 100644 --- a/plugins/badges-backend/src/tests/badgeStore.test.ts +++ b/plugins/badges-backend/src/database/badgeStore.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DatabaseBadgesStore } from '../database/badgesStore'; +import { DatabaseBadgesStore } from './badgesStore'; import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; import { Knex } from 'knex'; import { Entity } from '@backstage/catalog-model'; diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index d1cb0cff36..92bbb1aeae 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -24,7 +24,7 @@ import { } from '@backstage/backend-common'; import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; -import { NotFoundError } from '@backstage/errors'; +import { AuthenticationError, NotFoundError } from '@backstage/errors'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; import { BadgeContext, BadgeFactories } from '../types'; import { isNil } from 'lodash'; @@ -61,97 +61,252 @@ export async function createRouter( const baseUrl = await discovery.getExternalBaseUrl('badges'); if (config.getOptionalBoolean('app.badges.obfuscate')) { - logger.info('Badges obfuscation is enabled'); + return obfuscatedRoute( + router, + catalog, + badgeBuilder, + tokenManager, + logger, + options, + config, + identity, + baseUrl, + ); + } + return nonObfuscatedRoute( + router, + catalog, + badgeBuilder, + tokenManager, + config, + baseUrl, + ); +} - const store = options.badgeStore - ? options.badgeStore - : await DatabaseBadgesStore.create({ - database: await DatabaseManager.fromConfig(config).forPlugin( - 'badges', - ), - }); +async function obfuscatedRoute( + router: express.Router, + catalog: CatalogApi, + badgeBuilder: BadgeBuilder, + tokenManager: TokenManager, + logger: Logger, + options: RouterOptions, + config: Config, + identity: IdentityApi, + baseUrl: string, +) { + logger.info('Badges obfuscation is enabled'); - router.get('/entity/:entityUuid/badge-specs', async (req, res) => { - const { entityUuid } = req.params; + const store = options.badgeStore + ? options.badgeStore + : await DatabaseBadgesStore.create({ + database: await DatabaseManager.fromConfig(config).forPlugin('badges'), + }); - // Retrieve the badge info from the database - const badgeInfos = await store.getBadgeFromUuid(entityUuid); + router.get('/entity/:entityUuid/badge-specs', async (req, res) => { + const { entityUuid } = req.params; - if (isNil(badgeInfos)) { - throw new NotFoundError( - `No badge found for entity uuid "${entityUuid}"`, - ); - } + // Retrieve the badge info from the database + const badgeInfos = await store.getBadgeFromUuid(entityUuid); - // If a mapping is found, map name, namespace and kind - const name = badgeInfos.name; - const namespace = badgeInfos.namespace; - const kind = badgeInfos.kind; - const token = await tokenManager.getToken(); + if (isNil(badgeInfos)) { + throw new NotFoundError(`No badge found for entity uuid "${entityUuid}"`); + } - // Query the catalog with the name, namespace, kind to get the entity informations - const entity = await catalog.getEntityByRef( - { - namespace, - kind, - name, - }, - token, + // If a mapping is found, map name, namespace and kind + const name = badgeInfos.name; + const namespace = badgeInfos.namespace; + const kind = badgeInfos.kind; + const token = await tokenManager.getToken(); + + // Query the catalog with the name, namespace, kind to get the entity informations + const entity = await catalog.getEntityByRef( + { + namespace, + kind, + name, + }, + token, + ); + if (isNil(entity)) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, ); - if (isNil(entity)) { - throw new NotFoundError( - `No ${kind} entity in ${namespace} named "${name}"`, - ); + } + + // Create the badge specs + const specs = []; + for (const badgeInfo of await badgeBuilder.getBadges()) { + const context: BadgeContext = { + badgeUrl: `${baseUrl}/entity/${entityUuid}/${badgeInfo.id}`, + config: config, + entity, + }; + + const badge = await badgeBuilder.createBadgeJson({ + badgeInfo, + context, + }); + specs.push(badge); + } + + res.status(200).json(specs); + }); + + router.get('/entity/:entityUuid/:badgeId', async (req, res) => { + const { entityUuid, badgeId } = req.params; + + // Retrieve the badge info from the database + const badgeInfo = await store.getBadgeFromUuid(entityUuid); + + if (isNil(badgeInfo)) { + throw new NotFoundError(`No badge found for entity uuid "${entityUuid}"`); + } + + // If a mapping is found, map name, namespace and kind + const name = badgeInfo.name; + const namespace = badgeInfo.namespace; + const kind = badgeInfo.kind; + const token = await tokenManager.getToken(); + const entity = await catalog.getEntityByRef( + { + namespace, + kind, + name, + }, + token, + ); + if (isNil(entity)) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + res.sendStatus(404), + ); + } + + let format = + req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; + if (req.query.format === 'json') { + format = 'application/json'; + } + + const badgeOptions = { + badgeInfo: { id: badgeId }, + context: { + badgeUrl: `${baseUrl}/entity/${entityUuid}/${badgeId}`, + config: config, + entity, + }, + }; + + let data: string; + if (format === 'application/json') { + data = JSON.stringify( + await badgeBuilder.createBadgeJson(badgeOptions), + null, + 2, + ); + } else { + data = await badgeBuilder.createBadgeSvg(badgeOptions); + } + + res.setHeader('Content-Type', format); + res.status(200).send(data); + }); + + router.get( + '/entity/:namespace/:kind/:name/obfuscated', + function authenticate(req, _res, next) { + const token = + getBearerTokenFromAuthorizationHeader(req.headers.authorization) || + (req.cookies?.token as string | undefined); + + if (!token) { + throw new AuthenticationError('Unauthorized'); } - // Create the badge specs - const specs = []; - for (const badgeInfo of await badgeBuilder.getBadges()) { - const context: BadgeContext = { - badgeUrl: await getBadgeObfuscatedUrl(entityUuid, badgeInfo.id), - config: config, - entity, - }; + try { + req.user = identity.getIdentity({ request: req }); + next(); + } catch (error) { + tokenManager.authenticate(token.toString()); + next(error); + } + }, + async (req, res) => { + const { namespace, kind, name } = req.params; + let storedEntityUuid: { uuid: string } | undefined = + await store.getUuidFromEntityMetadata(name, namespace, kind); - const badge = await badgeBuilder.createBadgeJson({ - badgeInfo, - context, - }); - specs.push(badge); + if (isNil(storedEntityUuid)) { + storedEntityUuid = await store.addBadge(name, namespace, kind); + + if (isNil(storedEntityUuid)) { + throw new NotFoundError( + `No uuid found for entity "${namespace}/${kind}/${name}"`, + ); + } } - res.status(200).json(specs); - }); + return res.status(200).json(storedEntityUuid); + }, + ); - router.get('/entity/:entityUuid/:badgeId', async (req, res) => { - const { entityUuid, badgeId } = req.params; + router.use(errorHandler()); - // Retrieve the badge info from the database - const badgeInfo = await store.getBadgeFromUuid(entityUuid); + return router; +} - if (isNil(badgeInfo)) { - throw new NotFoundError( - `No badge found for entity uuid "${entityUuid}"`, - ); - } +async function nonObfuscatedRoute( + router: express.Router, + catalog: CatalogApi, + badgeBuilder: BadgeBuilder, + tokenManager: TokenManager, + config: Config, + baseUrl: string, +) { + router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { + const token = await tokenManager.getToken(); + const { namespace, kind, name } = req.params; + const entity = await catalog.getEntityByRef( + { namespace, kind, name }, + token, + ); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } - // If a mapping is found, map name, namespace and kind - const name = badgeInfo.name; - const namespace = badgeInfo.namespace; - const kind = badgeInfo.kind; + const specs = []; + for (const badgeInfo of await badgeBuilder.getBadges()) { + const badgeId = badgeInfo.id; + const context: BadgeContext = { + badgeUrl: `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`, + config: config, + entity, + }; + + const badge = await badgeBuilder.createBadgeJson({ + badgeInfo, + context, + }); + specs.push(badge); + } + + res.status(200).json(specs); + }); + + router.get( + '/entity/:namespace/:kind/:name/badge/:badgeId', + async (req, res) => { + const { namespace, kind, name, badgeId } = req.params; const token = await tokenManager.getToken(); const entity = await catalog.getEntityByRef( - { - namespace, - kind, - name, - }, + { namespace, kind, name }, token, ); - if (isNil(entity)) { + if (!entity) { throw new NotFoundError( `No ${kind} entity in ${namespace} named "${name}"`, - res.sendStatus(404), ); } @@ -161,11 +316,10 @@ export async function createRouter( format = 'application/json'; } - // Generate the badge URL for the different types of badgeId const badgeOptions = { badgeInfo: { id: badgeId }, context: { - badgeUrl: await getBadgeObfuscatedUrl(entityUuid, badgeId), + badgeUrl: `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`, config: config, entity, }, @@ -184,154 +338,10 @@ export async function createRouter( res.setHeader('Content-Type', format); res.status(200).send(data); - }); + }, + ); - router.get( - '/entity/:namespace/:kind/:name/obfuscated', - function authenticate(req, res, next) { - const token = - getBearerTokenFromAuthorizationHeader(req.headers.authorization) || - (req.cookies?.token as string | undefined); + router.use(errorHandler()); - if (!token) { - res.status(401).send('Unauthorized'); - return; - } - - try { - req.user = identity.getIdentity({ request: req }); - next(); - } catch (error) { - tokenManager.authenticate(token.toString()); - next(error); - } - }, - async (req, res) => { - const { namespace, kind, name } = req.params; - let storedEntityUuid: { uuid: string } | undefined = - await store.getUuidFromEntityMetadata(name, namespace, kind); - - if (isNil(storedEntityUuid)) { - storedEntityUuid = await store.addBadge(name, namespace, kind); - - if (isNil(storedEntityUuid)) { - throw new NotFoundError( - `No uuid found for entity "${namespace}/${kind}/${name}"`, - ); - } - } - - return res.status(200).json(storedEntityUuid); - }, - ); - - router.use(errorHandler()); - - return router; - - // If the obfuscation is disabled, use the previously implemented routes - // eslint-disable-next-line no-else-return - } else { - router.get( - '/entity/:namespace/:kind/:name/badge-specs', - async (req, res) => { - const token = await tokenManager.getToken(); - const { namespace, kind, name } = req.params; - const entity = await catalog.getEntityByRef( - { namespace, kind, name }, - token, - ); - if (!entity) { - throw new NotFoundError( - `No ${kind} entity in ${namespace} named "${name}"`, - ); - } - - const specs = []; - for (const badgeInfo of await badgeBuilder.getBadges()) { - const context: BadgeContext = { - badgeUrl: await getBadgeUrl(namespace, kind, name, badgeInfo.id), - config: config, - entity, - }; - - const badge = await badgeBuilder.createBadgeJson({ - badgeInfo, - context, - }); - specs.push(badge); - } - - res.status(200).json(specs); - }, - ); - - router.get( - '/entity/:namespace/:kind/:name/badge/:badgeId', - async (req, res) => { - const { namespace, kind, name, badgeId } = req.params; - const token = await tokenManager.getToken(); - const entity = await catalog.getEntityByRef( - { namespace, kind, name }, - token, - ); - if (!entity) { - throw new NotFoundError( - `No ${kind} entity in ${namespace} named "${name}"`, - ); - } - - let format = - req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; - if (req.query.format === 'json') { - format = 'application/json'; - } - - const badgeOptions = { - badgeInfo: { id: badgeId }, - context: { - badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId), - config: config, - entity, - }, - }; - - let data: string; - if (format === 'application/json') { - data = JSON.stringify( - await badgeBuilder.createBadgeJson(badgeOptions), - null, - 2, - ); - } else { - data = await badgeBuilder.createBadgeSvg(badgeOptions); - } - - res.setHeader('Content-Type', format); - res.status(200).send(data); - }, - ); - - router.use(errorHandler()); - - return router; - } - - // This function return the obfuscated badge url based on the namespace/kind/name triplet - async function getBadgeObfuscatedUrl( - uuid: string, - badgeId: string, - ): Promise { - return `${baseUrl}/entity/${uuid}/${badgeId}`; - } - - // This function return the badge url based on the namespace/kind/name triplet - async function getBadgeUrl( - namespace: string, - kind: string, - name: string, - badgeId: string, - ): Promise { - return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; - } + return router; } From 00979497ac6ff17994fc9098d166bdc892c30ca4 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Thu, 27 Apr 2023 11:54:33 +0200 Subject: [PATCH 10/16] chore(badges): Add warning about endpoint exposition Signed-off-by: Rbillon59 --- plugins/badges-backend/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 5e9b8dd3cc..4a5eb8f07e 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -127,6 +127,8 @@ app: obfuscate: true ``` +:warning: **Warning**: The only endpoint to be publicly available is the `/entity/:entityUuid/:badgeId` endpoint. The other endpoints are meant to be called from the frontend plugin. + > Note that you cannot use env vars to set the `obfuscate` value. It must be a boolean value and env vars are always strings. ## API From 29078a6c05eceb1849cc8b003c921bab48d1559c Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Fri, 28 Apr 2023 18:38:07 +0200 Subject: [PATCH 11/16] chore(badges): Enhance warning in readme and describe how to allow configuration reading from frontend. Move remaining tests files beside the tested code Signed-off-by: Rbillon59 --- plugins/badges-backend/README.md | 2 +- plugins/badges-backend/migrations/20230404_init.js | 3 +++ .../{tests => service}/router-obfuscated.test.ts | 2 +- .../src/{tests => service}/router.test.ts | 2 +- plugins/badges/README.md | 14 +++++++++++++- 5 files changed, 19 insertions(+), 4 deletions(-) rename plugins/badges-backend/src/{tests => service}/router-obfuscated.test.ts (99%) rename plugins/badges-backend/src/{tests => service}/router.test.ts (99%) diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 4a5eb8f07e..71ff029f9c 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -127,7 +127,7 @@ app: obfuscate: true ``` -:warning: **Warning**: The only endpoint to be publicly available is the `/entity/:entityUuid/:badgeId` endpoint. The other endpoints are meant to be called from the frontend plugin. +:warning: **Warning**: The only endpoint to be publicly available is the `/entity/:entityUuid/:badgeId` endpoint. The other endpoints are meant for trusted internal users and should not be publicly exposed. > Note that you cannot use env vars to set the `obfuscate` value. It must be a boolean value and env vars are always strings. diff --git a/plugins/badges-backend/migrations/20230404_init.js b/plugins/badges-backend/migrations/20230404_init.js index f44b211038..3de6bfc248 100644 --- a/plugins/badges-backend/migrations/20230404_init.js +++ b/plugins/badges-backend/migrations/20230404_init.js @@ -26,5 +26,8 @@ exports.up = async function up(knex) { }; exports.down = async function down(knex) { + await knex.schema.alterTable('badges', table => { + table.dropIndex('', 'badges_uuid_index'); + }); await knex.schema.dropTable('badges'); }; diff --git a/plugins/badges-backend/src/tests/router-obfuscated.test.ts b/plugins/badges-backend/src/service/router-obfuscated.test.ts similarity index 99% rename from plugins/badges-backend/src/tests/router-obfuscated.test.ts rename to plugins/badges-backend/src/service/router-obfuscated.test.ts index 1253e59157..d5f18ab470 100644 --- a/plugins/badges-backend/src/tests/router-obfuscated.test.ts +++ b/plugins/badges-backend/src/service/router-obfuscated.test.ts @@ -25,7 +25,7 @@ import { import { CatalogApi } from '@backstage/catalog-client'; import type { Entity } from '@backstage/catalog-model'; import { Config, ConfigReader } from '@backstage/config'; -import { createRouter } from '../service/router'; +import { createRouter } from './router'; import { BadgeBuilder } from '../lib'; import { BackstageIdentityResponse, diff --git a/plugins/badges-backend/src/tests/router.test.ts b/plugins/badges-backend/src/service/router.test.ts similarity index 99% rename from plugins/badges-backend/src/tests/router.test.ts rename to plugins/badges-backend/src/service/router.test.ts index a44281e9fa..962d01e4d8 100644 --- a/plugins/badges-backend/src/tests/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -25,7 +25,7 @@ import { import { CatalogApi } from '@backstage/catalog-client'; import type { Entity } from '@backstage/catalog-model'; import { Config, ConfigReader } from '@backstage/config'; -import { createRouter } from '../service/router'; +import { createRouter } from './router'; import { BadgeBuilder } from '../lib'; import { BackstageIdentityResponse, diff --git a/plugins/badges/README.md b/plugins/badges/README.md index a33ddec063..4b8ff38d47 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -34,7 +34,9 @@ Please note that if you have already set badges in your repositories and you act Please note that the backend part needs to be configured to support obfuscation. See the [backend plugin documentation](../badges-backend/README.md) for more details. -Also, you need to allow your frontend to access the configuration : +Also, you need to allow your frontend to access the configuration see : + +Example implementation would be in : `packages/app/src/config.d.ts` ```typescript export interface Config { @@ -51,6 +53,16 @@ export interface Config { } ``` +then include in the `packages/app/package.json` : + +```json +"files": [ + "dist", + "config.d.ts" + ], +"configSchema": "config.d.ts", +``` + ## Sample Badges Here are some samples of badges for the `artists-lookup` service in the Demo Backstage site: From 52ce881a5ffcf6b9c2651cb43a69cfc479c57b54 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Sat, 29 Apr 2023 01:10:35 +0200 Subject: [PATCH 12/16] chore: update yarn lock and update pre-commit command and add backstage node cli for precommit Signed-off-by: Rbillon59 --- .husky/pre-commit | 2 +- package.json | 1 + yarn.lock | 7 ++----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index d2ae35e84b..9dcd433f6e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -yarn lint-staged +yarn lint diff --git a/package.json b/package.json index 91b8d33e09..0bc1720f7e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "version": "1.14.0-next.0", "dependencies": { + "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "@manypkg/get-packages": "^1.1.3" }, diff --git a/yarn.lock b/yarn.lock index 442d981e9c..e4933d95d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4976,12 +4976,8 @@ __metadata: "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 - "@testing-library/dom": ^8.0.0 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 - "@testing-library/user-event": ^14.0.0 + "@testing-library/jest-dom": ^5.16.5 "@types/node": ^16.11.26 - "@types/react": ^16.13.1 || ^17.0.0 cross-fetch: ^3.1.5 react-use: ^17.2.4 peerDependencies: @@ -36139,6 +36135,7 @@ __metadata: resolution: "root@workspace:." dependencies: "@backstage/cli": "workspace:*" + "@backstage/cli-node": "workspace:^" "@backstage/codemods": "workspace:*" "@backstage/create-app": "workspace:*" "@backstage/errors": "workspace:^" From 9e206cff72fffd80be0cc7d07bbb8957d27e184d Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Tue, 2 May 2023 15:06:51 +0200 Subject: [PATCH 13/16] chore: revert 099c8b0200c876421ec97959ddca604f9c917f6e Signed-off-by: Rbillon59 --- .husky/pre-commit | 2 +- package.json | 1 - yarn.lock | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 9dcd433f6e..d2ae35e84b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -yarn lint +yarn lint-staged diff --git a/package.json b/package.json index 0bc1720f7e..91b8d33e09 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ }, "version": "1.14.0-next.0", "dependencies": { - "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "@manypkg/get-packages": "^1.1.3" }, diff --git a/yarn.lock b/yarn.lock index e4933d95d6..bd7f4bf1d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36135,7 +36135,6 @@ __metadata: resolution: "root@workspace:." dependencies: "@backstage/cli": "workspace:*" - "@backstage/cli-node": "workspace:^" "@backstage/codemods": "workspace:*" "@backstage/create-app": "workspace:*" "@backstage/errors": "workspace:^" From 8fc889a4bdd222afe4dcac24469915cb6935f798 Mon Sep 17 00:00:00 2001 From: Romain Billon Date: Tue, 2 May 2023 15:07:36 +0200 Subject: [PATCH 14/16] Update .changeset/extragavent-fast-fly.md Co-authored-by: Johan Haals Signed-off-by: Romain Billon Signed-off-by: Rbillon59 --- .changeset/extragavent-fast-fly.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/extragavent-fast-fly.md b/.changeset/extragavent-fast-fly.md index ffa2e6e859..bba3d8a9c3 100644 --- a/.changeset/extragavent-fast-fly.md +++ b/.changeset/extragavent-fast-fly.md @@ -7,4 +7,4 @@ Fixing badges-backend plugin to get a token from the TokenManager instead of par Implementing an obfuscation feature to protect an open badges endpoint from being enumerated. The feature is disabled by default and the change is compatible with the previous version. -**BREAKING**: createRouter now require that a tokenManager, logger, identityApi and database, are passed in as options. +**BREAKING**: `createRouter` now require that `tokenManager`, `logger`, `identityApi` and `database`, are passed in as options. From b11367dc2c3e6e735208052c7c44fbfa76648b10 Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Tue, 2 May 2023 15:12:06 +0200 Subject: [PATCH 15/16] chore: fix spell check on plugins/badges-backend/README.md Signed-off-by: Rbillon59 --- plugins/badges-backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md index 71ff029f9c..ddbb30bc4e 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -149,7 +149,7 @@ The badges backend api exposes two main endpoints for entity badges. The ### If obfuscation is enabled (apps.badges.obfuscate: true) -- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated entity url. +- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated `entity url`. > Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. _It meant to be called from the frontend plugin._ From e139961b4871c3706b665d4f245506b1029851ad Mon Sep 17 00:00:00 2001 From: Rbillon59 Date: Wed, 3 May 2023 17:44:10 +0200 Subject: [PATCH 16/16] refactor: factorise addBadge and getUuidFromBadgeMetadata to one endpoint handling both cases Signed-off-by: Rbillon59 --- plugins/badges-backend/api-report.md | 19 ++------ .../src/database/badgeStore.test.ts | 18 ++++---- .../src/database/badgesStore.ts | 43 ++++++++----------- .../src/service/router-obfuscated.test.ts | 5 +-- .../badges-backend/src/service/router.test.ts | 3 +- plugins/badges-backend/src/service/router.ts | 14 +++--- 6 files changed, 38 insertions(+), 64 deletions(-) diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md index 013c2c5ac7..09a02ba44d 100644 --- a/plugins/badges-backend/api-report.md +++ b/plugins/badges-backend/api-report.md @@ -83,14 +83,6 @@ export type BadgeSpec = { // @public export interface BadgesStore { - // (undocumented) - addBadge( - name: string, - namespace: string, - kind: string, - ): Promise<{ - uuid: string; - }>; // (undocumented) getBadgeFromUuid(uuid: string): Promise< | { @@ -101,16 +93,13 @@ export interface BadgesStore { | undefined >; // (undocumented) - getUuidFromEntityMetadata( + getBadgeUuid( name: string, namespace: string, kind: string, - ): Promise< - | { - uuid: string; - } - | undefined - >; + ): Promise<{ + uuid: string; + }>; } // @public (undocumented) diff --git a/plugins/badges-backend/src/database/badgeStore.test.ts b/plugins/badges-backend/src/database/badgeStore.test.ts index 60a885daa4..5a7a82d4b7 100644 --- a/plugins/badges-backend/src/database/badgeStore.test.ts +++ b/plugins/badges-backend/src/database/badgeStore.test.ts @@ -48,13 +48,17 @@ describe('DatabaseBadgesStore', () => { ({ knex, badgeStore } = await createDatabaseBadgesStore(databaseId)); }); - it('createABadge', async () => { - const uuid = await badgeStore.addBadge( + it('createABadge if not existing in DB', async () => { + const uuid = await badgeStore.getBadgeUuid( entity.metadata.name, entity.metadata.namespace || 'default', entity.kind, ); + expect(uuid.uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + const storedBadge = await badgeStore.getBadgeFromUuid(uuid.uuid); expect(storedBadge?.kind).toEqual(entity.kind); expect(storedBadge?.name).toEqual(entity.metadata.name); @@ -63,7 +67,7 @@ describe('DatabaseBadgesStore', () => { ); }); - it('getBadgeFromUuid', async () => { + it('getBadge if badge already exist in DB', async () => { await knex('badges').truncate(); await knex('badges').insert([ { @@ -83,7 +87,7 @@ describe('DatabaseBadgesStore', () => { }); }); - it('getUuidFromEntityMetadata', async () => { + it('getBadgeUuid if badge exist in DB', async () => { await knex('badges').truncate(); await knex('badges').insert([ { @@ -94,15 +98,13 @@ describe('DatabaseBadgesStore', () => { }, ]); - const storedUuid = await badgeStore.getUuidFromEntityMetadata( + const storedUuid = await badgeStore.getBadgeUuid( 'test', 'default', 'component', ); - expect(storedUuid).toEqual({ - uuid: 'uuid1', - }); + expect(storedUuid).toEqual({ uuid: 'uuid1' }); }); }); }); diff --git a/plugins/badges-backend/src/database/badgesStore.ts b/plugins/badges-backend/src/database/badgesStore.ts index 62d546f025..e6c6a9e2ad 100644 --- a/plugins/badges-backend/src/database/badgesStore.ts +++ b/plugins/badges-backend/src/database/badgesStore.ts @@ -19,6 +19,7 @@ import { resolvePackagePath, } from '@backstage/backend-common'; import { Knex } from 'knex'; +import { isNil } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; /** @@ -26,7 +27,7 @@ import { v4 as uuidv4 } from 'uuid'; * @public */ export interface BadgesStore { - addBadge( + getBadgeUuid( name: string, namespace: string, kind: string, @@ -35,12 +36,6 @@ export interface BadgesStore { getBadgeFromUuid( uuid: string, ): Promise<{ name: string; namespace: string; kind: string } | undefined>; - - getUuidFromEntityMetadata( - name: string, - namespace: string, - kind: string, - ): Promise<{ uuid: string } | undefined>; } const migrationsDir = resolvePackagePath( @@ -84,35 +79,31 @@ export class DatabaseBadgesStore implements BadgesStore { return result; } - async getUuidFromEntityMetadata( + async getBadgeUuid( name: string, namespace: string, kind: string, - ): Promise<{ uuid: string } | undefined> { + ): Promise<{ uuid: string }> { const result = await this.db('badges') .select('uuid') .where({ name: name, namespace: namespace, kind: kind }) .first(); - return result; - } + let uuid = result?.uuid; - async addBadge( - name: string, - namespace: string, - kind: string, - ): Promise<{ uuid: string }> { - const uuid = uuidv4(); + if (isNil(uuid)) { + uuid = uuidv4(); - await this.db('badges') - .insert({ - uuid: uuid, - name: name, - namespace: namespace, - kind: kind, - }) - .onConflict(['name', 'namespace', 'kind']) - .ignore(); + await this.db('badges') + .insert({ + uuid: uuid, + name: name, + namespace: namespace, + kind: kind, + }) + .onConflict(['name', 'namespace', 'kind']) + .ignore(); + } return { uuid }; } diff --git a/plugins/badges-backend/src/service/router-obfuscated.test.ts b/plugins/badges-backend/src/service/router-obfuscated.test.ts index d5f18ab470..3c9ec0d2b6 100644 --- a/plugins/badges-backend/src/service/router-obfuscated.test.ts +++ b/plugins/badges-backend/src/service/router-obfuscated.test.ts @@ -129,15 +129,12 @@ describe('createRouter', () => { }; const badgeStore: jest.Mocked = { - addBadge: jest.fn().mockImplementation(async () => { + getBadgeUuid: jest.fn().mockImplementation(async () => { return { uuid: 'uuid1' }; }), getBadgeFromUuid: jest.fn().mockImplementation(async () => { return badgeEntity; }), - getUuidFromEntityMetadata: jest - .fn() - .mockImplementation(async () => 'uuid1'), }; beforeAll(async () => { diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 962d01e4d8..2ecae6b452 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -76,8 +76,7 @@ describe('createRouter', () => { const badgeStore: jest.Mocked = { getBadgeFromUuid: jest.fn(), - getUuidFromEntityMetadata: jest.fn(), - addBadge: jest.fn(), + getBadgeUuid: jest.fn(), }; beforeAll(async () => { diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index 92bbb1aeae..308865b73a 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -233,17 +233,13 @@ async function obfuscatedRoute( }, async (req, res) => { const { namespace, kind, name } = req.params; - let storedEntityUuid: { uuid: string } | undefined = - await store.getUuidFromEntityMetadata(name, namespace, kind); + const storedEntityUuid: { uuid: string } | undefined = + await store.getBadgeUuid(name, namespace, kind); if (isNil(storedEntityUuid)) { - storedEntityUuid = await store.addBadge(name, namespace, kind); - - if (isNil(storedEntityUuid)) { - throw new NotFoundError( - `No uuid found for entity "${namespace}/${kind}/${name}"`, - ); - } + throw new NotFoundError( + `No uuid found for entity "${namespace}/${kind}/${name}"`, + ); } return res.status(200).json(storedEntityUuid);