diff --git a/.changeset/extragavent-fast-fly.md b/.changeset/extragavent-fast-fly.md new file mode 100644 index 0000000000..bba3d8a9c3 --- /dev/null +++ b/.changeset/extragavent-fast-fly.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-badges': 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 `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 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/README.md b/plugins/badges-backend/README.md index 63e43c6692..ddbb30bc4e 100644 --- a/plugins/badges-backend/README.md +++ b/plugins/badges-backend/README.md @@ -36,6 +36,9 @@ export default async function createPlugin( config: env.config, discovery: env.discovery, badgeFactories: createDefaultBadgeFactories(), + tokenManager: env.tokenManager, + logger: env.logger, + identity: env.identity, }); } ``` @@ -112,11 +115,29 @@ 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 +``` + +: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. + ## 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 +147,21 @@ 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 url`. + +> 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/: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/: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. + ## Links - [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges) diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md index 8ca4b561a2..09a02ba44d 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 { @@ -78,6 +81,27 @@ export type BadgeSpec = { markdown: string; }; +// @public +export interface BadgesStore { + // (undocumented) + getBadgeFromUuid(uuid: string): Promise< + | { + name: string; + namespace: string; + kind: string; + } + | undefined + >; + // (undocumented) + getBadgeUuid( + name: string, + namespace: string, + kind: string, + ): Promise<{ + uuid: string; + }>; +} + // @public (undocumented) export type BadgeStyle = (typeof BADGE_STYLES)[number]; @@ -107,10 +131,18 @@ export interface RouterOptions { // (undocumented) badgeFactories?: BadgeFactories; // (undocumented) + badgeStore?: BadgesStore; + // (undocumented) catalog?: CatalogApi; // (undocumented) config: Config; // (undocumented) discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityApi; + // (undocumented) + logger: Logger; + // (undocumented) + tokenManager: TokenManager; } ``` diff --git a/plugins/badges-backend/migrations/20230404_init.js b/plugins/badges-backend/migrations/20230404_init.js new file mode 100644 index 0000000000..3de6bfc248 --- /dev/null +++ b/plugins/badges-backend/migrations/20230404_init.js @@ -0,0 +1,33 @@ +/* + * 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('uuid').unique().notNullable(); + table.index(['uuid'], 'badges_uuid_index'); + table.primary(['kind', 'namespace', 'name']); + }); +}; + +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/package.json b/plugins/badges-backend/package.json index 5f9b5b51a3..9c235255e5 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -38,18 +38,28 @@ "@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", + "knex": "^2.4.2", + "lodash": "^4.17.21", + "supertest": "^6.3.3", + "uuid": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/catalog-client": "workspace:^", "@backstage/cli": "workspace:^", - "@types/supertest": "^2.0.8", - "supertest": "^6.1.3" + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@types/node": "*", + "cross-fetch": "^3.1.5" }, "files": [ "dist" diff --git a/plugins/badges-backend/src/database/badgeStore.test.ts b/plugins/badges-backend/src/database/badgeStore.test.ts new file mode 100644 index 0000000000..5a7a82d4b7 --- /dev/null +++ b/plugins/badges-backend/src/database/badgeStore.test.ts @@ -0,0 +1,110 @@ +/* + * 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 './badgesStore'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Knex } from 'knex'; +import { Entity } from '@backstage/catalog-model'; + +describe('DatabaseBadgesStore', () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'test', + }, + }; + + 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('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); + expect(storedBadge?.namespace).toEqual( + entity.metadata.namespace || 'default', + ); + }); + + it('getBadge if badge already exist in DB', async () => { + await knex('badges').truncate(); + await knex('badges').insert([ + { + uuid: 'uuid1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + const storedEntity = await badgeStore.getBadgeFromUuid('uuid1'); + + expect(storedEntity).toEqual({ + name: 'test', + namespace: 'default', + kind: 'component', + }); + }); + + it('getBadgeUuid if badge exist in DB', async () => { + await knex('badges').truncate(); + await knex('badges').insert([ + { + uuid: 'uuid1', + name: 'test', + namespace: 'default', + kind: 'component', + }, + ]); + + const storedUuid = await badgeStore.getBadgeUuid( + 'test', + 'default', + 'component', + ); + + expect(storedUuid).toEqual({ uuid: 'uuid1' }); + }); + }); +}); diff --git a/plugins/badges-backend/src/database/badgesStore.ts b/plugins/badges-backend/src/database/badgesStore.ts new file mode 100644 index 0000000000..e6c6a9e2ad --- /dev/null +++ b/plugins/badges-backend/src/database/badgesStore.ts @@ -0,0 +1,110 @@ +/* + * 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 { isNil } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * internal + * @public + */ +export interface BadgesStore { + getBadgeUuid( + name: string, + namespace: string, + kind: string, + ): Promise<{ uuid: string }>; + + getBadgeFromUuid( + uuid: string, + ): Promise<{ name: string; namespace: string; kind: string } | undefined>; +} + +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 getBadgeFromUuid( + uuid: string, + ): Promise<{ name: string; namespace: string; kind: string } | undefined> { + const result = await this.db('badges') + .select('namespace', 'name', 'kind') + .where({ uuid: uuid }) + .first(); + + return result; + } + + async getBadgeUuid( + name: string, + namespace: string, + kind: string, + ): Promise<{ uuid: string }> { + const result = await this.db('badges') + .select('uuid') + .where({ name: name, namespace: namespace, kind: kind }) + .first(); + + let uuid = result?.uuid; + + if (isNil(uuid)) { + uuid = uuidv4(); + + 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/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-obfuscated.test.ts b/plugins/badges-backend/src/service/router-obfuscated.test.ts new file mode 100644 index 0000000000..3c9ec0d2b6 --- /dev/null +++ b/plugins/badges-backend/src/service/router-obfuscated.test.ts @@ -0,0 +1,302 @@ +/* + * 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 './router'; +import { BadgeBuilder } from '../lib'; +import { + BackstageIdentityResponse, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; +import { BadgesStore } from '../database/badgesStore'; + +describe('createRouter', () => { + let app: express.Express; + const badgeBuilder: jest.Mocked = { + getBadges: jest.fn(), + createBadgeJson: jest.fn(), + createBadgeSvg: jest.fn(), + }; + + 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(), + }; + const getIdentity = jest + .fn() + .mockImplementation( + async ({ + request: _request, + }: IdentityApiGetIdentityRequest): Promise< + BackstageIdentityResponse | undefined + > => { + return { + identity: { + userEntityRef: 'user:default/guest', + ownershipEntityRefs: [], + type: 'user', + }, + token: 'token', + }; + }, + ); + + const config: Config = new ConfigReader({ + backend: { + baseUrl: 'http://127.0.0.1', + listen: { + port: 7007, + }, + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + app: { + badges: { + obfuscate: true, + }, + }, + }); + + let discovery: PluginEndpointDiscovery; + + 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: '[![...](...)]', + }; + + const badgeEntity = { + name: 'test', + namespace: 'default', + kind: 'component', + }; + + const badgeStore: jest.Mocked = { + getBadgeUuid: jest.fn().mockImplementation(async () => { + return { uuid: 'uuid1' }; + }), + getBadgeFromUuid: jest.fn().mockImplementation(async () => { + return badgeEntity; + }), + }; + + beforeAll(async () => { + 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.clearAllMocks(); + }); + + it('works with provided badgeStore', async () => { + const tokenManager = ServerTokenManager.noop(); + const router = await createRouter({ + badgeBuilder, + catalog: catalog as Partial as CatalogApi, + config, + discovery, + tokenManager, + logger: getVoidLogger(), + identity: { getIdentity }, + badgeStore: badgeStore, + }); + 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/:namespace/:kind/:name/obfuscated', () => { + catalog.getEntityByRef.mockResolvedValueOnce(entity); + catalog.getEntities.mockResolvedValueOnce({ items: entities }); + + it('returns obfuscated 401 if no auth', async () => { + const obfuscatedEntity = await request(app).get( + '/entity/default/component/test/obfuscated', + ); + expect(obfuscatedEntity.status).toEqual(401); + }); + + 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); + + 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/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 109b64231d..2ecae6b452 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -17,7 +17,9 @@ import express from 'express'; import request from 'supertest'; import { + getVoidLogger, PluginEndpointDiscovery, + ServerTokenManager, SingleHostDiscovery, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; @@ -25,10 +27,16 @@ import type { Entity } from '@backstage/catalog-model'; import { Config, ConfigReader } from '@backstage/config'; import { createRouter } from './router'; import { BadgeBuilder } from '../lib'; +import { + BackstageIdentityResponse, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; +import { BadgesStore } from '../database/badgesStore'; describe('createRouter', () => { let app: express.Express; let badgeBuilder: jest.Mocked; + const catalog = { addLocation: jest.fn(), getEntities: jest.fn(), @@ -42,12 +50,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', }, @@ -63,6 +74,11 @@ describe('createRouter', () => { markdown: '[![...](...)]', }; + const badgeStore: jest.Mocked = { + getBadgeFromUuid: jest.fn(), + getBadgeUuid: jest.fn(), + }; + beforeAll(async () => { badgeBuilder = { getBadges: jest.fn(), @@ -73,15 +89,40 @@ describe('createRouter', () => { backend: { baseUrl: 'http://127.0.0.1', listen: { port: 7007 }, + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, }, }); - 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); }); @@ -90,12 +131,17 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - it('works', async () => { + it('works with badgeStore', async () => { + const tokenManager = ServerTokenManager.noop(); const router = await createRouter({ badgeBuilder, catalog: catalog as Partial as CatalogApi, config, discovery, + tokenManager, + logger: getVoidLogger(), + identity: { getIdentity }, + badgeStore: badgeStore, }); expect(router).toBeDefined(); }); @@ -108,7 +154,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 +164,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 +176,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 +184,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 uuid 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 +279,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-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts index e5caa5cd2a..308865b73a 100644 --- a/plugins/badges-backend/src/service/router.ts +++ b/plugins/badges-backend/src/service/router.ts @@ -17,14 +17,21 @@ import express from 'express'; import Router from 'express-promise-router'; import { + DatabaseManager, 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 { AuthenticationError, NotFoundError } from '@backstage/errors'; import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; import { BadgeContext, BadgeFactories } from '../types'; +import { isNil } from 'lodash'; +import { Logger } from 'winston'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { BadgesStore, DatabaseBadgesStore } from '../database/badgesStore'; /** @public */ export interface RouterOptions { @@ -33,6 +40,10 @@ export interface RouterOptions { catalog?: CatalogApi; config: Config; discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + logger: Logger; + identity: IdentityApi; + badgeStore?: BadgesStore; } /** @public */ @@ -46,13 +57,214 @@ export async function createRouter( new DefaultBadgeBuilder(options.badgeFactories || {}); const router = Router(); + const { config, logger, tokenManager, discovery, identity } = options; + const baseUrl = await discovery.getExternalBaseUrl('badges'); + + if (config.getOptionalBoolean('app.badges.obfuscate')) { + return obfuscatedRoute( + router, + catalog, + badgeBuilder, + tokenManager, + logger, + options, + config, + identity, + baseUrl, + ); + } + return nonObfuscatedRoute( + router, + catalog, + badgeBuilder, + tokenManager, + config, + baseUrl, + ); +} + +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'); + + const store = options.badgeStore + ? options.badgeStore + : await DatabaseBadgesStore.create({ + database: await DatabaseManager.fromConfig(config).forPlugin('badges'), + }); + + router.get('/entity/:entityUuid/badge-specs', async (req, res) => { + const { entityUuid } = req.params; + + // Retrieve the badge info from the database + const badgeInfos = await store.getBadgeFromUuid(entityUuid); + + if (isNil(badgeInfos)) { + throw new NotFoundError(`No badge found for entity uuid "${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(); + + // 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}"`, + ); + } + + // 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'); + } + + 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; + const storedEntityUuid: { uuid: string } | undefined = + await store.getBadgeUuid(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; +} + +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: getBearerToken(req.headers.authorization), - }, + token, ); if (!entity) { throw new NotFoundError( @@ -62,19 +274,17 @@ export async function createRouter( const specs = []; for (const badgeInfo of await badgeBuilder.getBadges()) { + const badgeId = badgeInfo.id; const context: BadgeContext = { - badgeUrl: await getBadgeUrl( - namespace, - kind, - name, - badgeInfo.id, - options, - ), - config: options.config, + badgeUrl: `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`, + config: config, entity, }; - const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context }); + const badge = await badgeBuilder.createBadgeJson({ + badgeInfo, + context, + }); specs.push(badge); } @@ -85,11 +295,10 @@ export async function createRouter( '/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: getBearerToken(req.headers.authorization), - }, + token, ); if (!entity) { throw new NotFoundError( @@ -106,8 +315,8 @@ export async function createRouter( const badgeOptions = { badgeInfo: { id: badgeId }, context: { - badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options), - config: options.config, + badgeUrl: `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`, + config: config, entity, }, }; @@ -132,18 +341,3 @@ export async function createRouter( 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]; -} diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts index 0800585634..b07e1fa3b1 100644 --- a/plugins/badges-backend/src/service/standaloneServer.ts +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -19,9 +19,14 @@ import { Logger } from 'winston'; import { createServiceBuilder, 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; @@ -35,10 +40,40 @@ 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...'); - 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, + badgeStore: await DatabaseBadgesStore.create({ + database: { getClient: async () => database }, + }), + }); let service = createServiceBuilder(module) .setPort(options.port) diff --git a/plugins/badges/README.md b/plugins/badges/README.md index 65ace6226e..4b8ff38d47 100644 --- a/plugins/badges/README.md +++ b/plugins/badges/README.md @@ -18,6 +18,51 @@ 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 see : + +Example implementation would be in : `packages/app/src/config.d.ts` + +```typescript +export interface Config { + app: { + ... some code + badges: { + /** + * badges obfuscate + * @visibility frontend + */ + obfuscate?: string; + }; + }; +} +``` + +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: diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 0e736d1cbc..714de3e3fb 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 9a89806d56..9a08db882a 100644 --- a/plugins/badges/src/api/BadgesClient.ts +++ b/plugins/badges/src/api/BadgesClient.ts @@ -18,31 +18,55 @@ 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, 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; + configApi: ConfigApi; + }) { + return new BadgesClient(options); } public async getEntityBadgeSpecs(entity: Entity): Promise { - const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); - const { token } = await this.identityApi.getCredentials(); - const response = await fetch(entityBadgeSpecsUrl, { - headers: token - ? { - Authorization: `Bearer ${token}`, - } - : undefined, - }); + // Check if obfuscation is enabled in the configuration + const obfuscate = this.configApi.getOptionalBoolean('app.badges.obfuscate'); + if (obfuscate) { + const entityUuidUrl = await this.getEntityUuidUrl(entity); + const entityUuid = await this.getEntityUuid(entityUuidUrl).then(data => { + return data.uuid; + }); + const entityUuidBadgeSpecsUrl = await this.getEntityUuidBadgeSpecsUrl( + entityUuid, + ); + + const response = await this.fetchApi.fetch(entityUuidBadgeSpecsUrl); + 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 this.fetchApi.fetch(entityBadgeSpecsUrl); if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -50,14 +74,39 @@ export class BadgesClient implements BadgesApi { return await response.json(); } + 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'); + const obfuscatedEntityUrl = `${baseUrl}/entity/${path}/obfuscated`; + + return obfuscatedEntityUrl; + } + + private async getEntityUuid(entityUuidUrl: string): Promise { + const responseEntityUuid = await this.fetchApi.fetch(entityUuidUrl); + + if (!responseEntityUuid.ok) { + throw await ResponseError.fromResponse(responseEntityUuid); + } + return await responseEntityUuid.json(); + } + + private async getEntityUuidBadgeSpecsUrl(entityUuid: { + uuid: string; + }): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('badges'); + return `${baseUrl}/entity/${entityUuid}/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..771e3ed893 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -15,11 +15,12 @@ */ import { badgesApiRef, BadgesClient } from './api'; import { + configApiRef, createApiFactory, createComponentExtension, createPlugin, + fetchApiRef, discoveryApiRef, - identityApiRef, } from '@backstage/core-plugin-api'; /** @public */ @@ -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: { + fetchApi: fetchApiRef, + discoveryApi: discoveryApiRef, + configApi: configApiRef, + }, + factory: ({ fetchApi, discoveryApi, configApi }) => + new BadgesClient({ + fetchApi, + discoveryApi, + configApi, + }), }), ], }); diff --git a/yarn.lock b/yarn.lock index e4ffb758a5..3da8172870 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4986,18 +4986,27 @@ __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:^" "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/test-utils": "workspace:^" "@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 + 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 @@ -5020,14 +5029,9 @@ __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 - msw: ^1.0.0 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 @@ -15002,7 +15006,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: @@ -20662,7 +20666,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 @@ -24667,7 +24671,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: @@ -28975,7 +28979,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: @@ -34267,7 +34271,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: @@ -37879,32 +37883,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