Merge pull request #17024 from Rbillon59/feat/badges/enable-safe-public-access

Enable safe public access for Badges
This commit is contained in:
Johan Haals
2023-05-04 10:08:02 +02:00
committed by GitHub
18 changed files with 1196 additions and 149 deletions
+36
View File
@@ -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)
+32
View File
@@ -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;
}
```
@@ -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');
};
+12 -2
View File
@@ -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"
@@ -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' });
});
});
});
@@ -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<DatabaseBadgesStore> {
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 };
}
}
+1
View File
@@ -24,3 +24,4 @@ export * from './badges';
export * from './lib';
export * from './service/router';
export * from './types';
export * from './database/badgesStore';
@@ -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<BadgeBuilder> = {
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<BadgesStore> = {
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<CatalogApi> 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<CatalogApi> 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 = '<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/: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 = '<svg>...</svg>';
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',
);
});
});
});
});
+129 -59
View File
@@ -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<BadgeBuilder>;
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<BadgesStore> = {
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<CatalogApi> 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<CatalogApi> 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 = '<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 +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 = '<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 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');
});
});
});
+227 -33
View File
@@ -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<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];
}
@@ -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)