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 <r.billon@celonis.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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](<https://en.wikipedia.org/wiki/Salt_(cryptography)>) in the `app-config.yaml`:
|
||||
|
||||
```yaml
|
||||
custom:
|
||||
badges-backend:
|
||||
salt: <your-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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
const baseUrl = await options.discovery.getExternalBaseUrl('badges');
|
||||
return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<BadgeBuilder>;
|
||||
|
||||
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<CatalogApi> 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<CatalogApi> 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 = '<svg>...</svg>';
|
||||
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 = '<svg>...</svg>';
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
-59
@@ -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<BadgeBuilder>;
|
||||
|
||||
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<CatalogApi> 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<CatalogApi> 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 = '<svg>...</svg>';
|
||||
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 = '<svg>...</svg>';
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,39 @@ This will popup a badges dialog showing all available badges for that entity lik
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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<BadgeSpec[]> {
|
||||
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<string> {
|
||||
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<any> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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'),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user