Merge pull request #3914 from erikxiv/feat/api-auth-client

Add identity token to api requests
This commit is contained in:
Patrik Oldsberg
2021-02-10 19:41:49 +01:00
committed by GitHub
28 changed files with 638 additions and 98 deletions
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/catalog-client': patch
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-import': minor
'@backstage/plugin-fossa': patch
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-rollbar': minor
'@backstage/plugin-scaffolder': minor
'@backstage/plugin-scaffolder-backend': patch
---
Include Backstage identity token in requests to backend plugins.
@@ -21,6 +21,7 @@ import { CatalogClient } from './CatalogClient';
import { CatalogListResponse, DiscoveryApi } from './types';
const server = setupServer();
const token = 'fake-token';
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi: DiscoveryApi = {
async getBaseUrl(_pluginId) {
@@ -71,7 +72,7 @@ describe('CatalogClient', () => {
});
it('should entities from correct endpoint', async () => {
const response = await client.getEntities();
const response = await client.getEntities({}, { token });
expect(response).toEqual(defaultResponse);
});
@@ -85,13 +86,16 @@ describe('CatalogClient', () => {
}),
);
const response = await client.getEntities({
filter: {
a: '1',
b: ['2', '3'],
ö: '=',
const response = await client.getEntities(
{
filter: {
a: '1',
b: ['2', '3'],
ö: '=',
},
},
});
{ token },
);
expect(response.items).toEqual([]);
});
@@ -106,11 +110,61 @@ describe('CatalogClient', () => {
}),
);
const response = await client.getEntities({
fields: ['a.b', 'ö'],
});
const response = await client.getEntities(
{
fields: ['a.b', 'ö'],
},
{ token },
);
expect(response.items).toEqual([]);
});
});
describe('getLocationById', () => {
const defaultResponse = {
data: {
id: '42',
},
};
beforeEach(() => {
server.use(
rest.get(`${mockBaseUrl}/locations/42`, (_, res, ctx) => {
return res(ctx.json(defaultResponse));
}),
);
});
it('should locations from correct endpoint', async () => {
const response = await client.getLocationById('42', { token });
expect(response).toEqual(defaultResponse);
});
it('forwards authorization token', async () => {
expect.assertions(1);
server.use(
rest.get(`${mockBaseUrl}/locations/42`, (req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(`Bearer ${token}`);
return res(ctx.json(defaultResponse));
}),
);
await client.getLocationById('42', { token });
});
it('skips authorization header if token is omitted', async () => {
expect.assertions(1);
server.use(
rest.get(`${mockBaseUrl}/locations/42`, (req, res, ctx) => {
expect(req.headers.get('authorization')).toBeNull();
return res(ctx.json(defaultResponse));
}),
);
await client.getLocationById('42');
});
});
});
+57 -18
View File
@@ -24,6 +24,7 @@ import fetch from 'cross-fetch';
import {
AddLocationRequest,
AddLocationResponse,
CatalogRequestOptions,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
@@ -37,12 +38,16 @@ export class CatalogClient implements CatalogApi {
this.discoveryApi = options.discoveryApi;
}
async getLocationById(id: String): Promise<Location | undefined> {
return await this.getOptional(`/locations/${id}`);
async getLocationById(
id: String,
options?: CatalogRequestOptions,
): Promise<Location | undefined> {
return await this.getOptional(`/locations/${id}`, options);
}
async getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<CatalogListResponse<Entity>> {
const { filter = {}, fields = [] } = request ?? {};
const params: string[] = [];
@@ -62,21 +67,28 @@ export class CatalogClient implements CatalogApi {
}
const query = params.length ? `?${params.join('&')}` : '';
const entities: Entity[] = await this.getRequired(`/entities${query}`);
const entities: Entity[] = await this.getRequired(
`/entities${query}`,
options,
);
return { items: entities };
}
async getEntityByName(compoundName: EntityName): Promise<Entity | undefined> {
async getEntityByName(
compoundName: EntityName,
options?: CatalogRequestOptions,
): Promise<Entity | undefined> {
const { kind, namespace = 'default', name } = compoundName;
return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`);
return this.getOptional(
`/entities/by-name/${kind}/${namespace}/${name}`,
options,
);
}
async addLocation({
type = 'url',
target,
dryRun,
presence,
}: AddLocationRequest): Promise<AddLocationResponse> {
async addLocation(
{ type = 'url', target, dryRun, presence }: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise<AddLocationResponse> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/locations${
dryRun ? '?dryRun=true' : ''
@@ -84,6 +96,7 @@ export class CatalogClient implements CatalogApi {
{
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify({ type, target, presence }),
@@ -111,18 +124,30 @@ export class CatalogClient implements CatalogApi {
};
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
async getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise<Location | undefined> {
const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const all: { data: Location }[] = await this.getRequired('/locations');
const all: { data: Location }[] = await this.getRequired(
'/locations',
options,
);
return all
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
async removeEntityByUid(uid: string): Promise<void> {
async removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
): Promise<void> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/entities/by-uid/${uid}`,
{
headers: options?.token
? { Authorization: `Bearer ${options.token}` }
: {},
method: 'DELETE',
},
);
@@ -139,9 +164,16 @@ export class CatalogClient implements CatalogApi {
// Private methods
//
private async getRequired(path: string): Promise<any> {
private async getRequired(
path: string,
options?: CatalogRequestOptions,
): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
const response = await fetch(url, {
headers: options?.token
? { Authorization: `Bearer ${options.token}` }
: {},
});
if (!response.ok) {
const payload = await response.text();
@@ -152,9 +184,16 @@ export class CatalogClient implements CatalogApi {
return await response.json();
}
private async getOptional(path: string): Promise<any | undefined> {
private async getOptional(
path: string,
options?: CatalogRequestOptions,
): Promise<any | undefined> {
const url = `${await this.discoveryApi.getBaseUrl('catalog')}${path}`;
const response = await fetch(url);
const response = await fetch(url, {
headers: options?.token
? { Authorization: `Bearer ${options.token}` }
: {},
});
if (!response.ok) {
if (response.status === 404) {
+7 -1
View File
@@ -15,4 +15,10 @@
*/
export { CatalogClient } from './CatalogClient';
export type { CatalogApi } from './types';
export type {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
} from './types';
+25 -5
View File
@@ -25,15 +25,35 @@ export type CatalogListResponse<T> = {
items: T[];
};
export type CatalogRequestOptions = {
token?: string;
};
export interface CatalogApi {
getLocationById(id: String): Promise<Location | undefined>;
getEntityByName(name: EntityName): Promise<Entity | undefined>;
getLocationById(
id: String,
options?: CatalogRequestOptions,
): Promise<Location | undefined>;
getEntityByName(
name: EntityName,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<CatalogListResponse<Entity>>;
addLocation(location: AddLocationRequest): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
addLocation(
location: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise<AddLocationResponse>;
getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise<Location | undefined>;
removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
): Promise<void>;
}
export type AddLocationRequest = {
@@ -38,11 +38,14 @@ describe('CatalogIdentityClient', () => {
client.findUser({ annotations: { key: 'value' } });
expect(catalogApi.getEntities).toBeCalledWith({
filter: {
kind: 'user',
'metadata.annotations.key': 'value',
expect(catalogApi.getEntities).toBeCalledWith(
{
filter: {
kind: 'user',
'metadata.annotations.key': 'value',
},
},
});
undefined,
);
});
});
@@ -37,7 +37,10 @@ export class CatalogIdentityClient {
*
* Throws a NotFoundError or ConflictError if 0 or multiple users are found.
*/
async findUser(query: UserQuery): Promise<UserEntity> {
async findUser(
query: UserQuery,
options?: { token?: string },
): Promise<UserEntity> {
const filter: Record<string, string> = {
kind: 'user',
};
@@ -45,7 +48,7 @@ export class CatalogIdentityClient {
filter[`metadata.annotations.${key}`] = value;
}
const { items } = await this.catalogApi.getEntities({ filter });
const { items } = await this.catalogApi.getEntities({ filter }, options);
if (items.length !== 1) {
if (items.length > 1) {
@@ -38,6 +38,7 @@ import {
PassportDoneCallback,
} from '../../lib/passport';
import { AuthProviderFactory, RedirectInfo } from '../types';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken: string;
@@ -46,16 +47,19 @@ type PrivateInfo = {
export type GoogleAuthProviderOptions = OAuthProviderOptions & {
logger: Logger;
identityClient: CatalogIdentityClient;
tokenIssuer: TokenIssuer;
};
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
private readonly logger: Logger;
private readonly identityClient: CatalogIdentityClient;
private readonly tokenIssuer: TokenIssuer;
constructor(options: GoogleAuthProviderOptions) {
this.logger = options.logger;
this.identityClient = options.identityClient;
this.tokenIssuer = options.tokenIssuer;
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
{
@@ -150,11 +154,17 @@ export class GoogleAuthProvider implements OAuthHandlers {
}
try {
const user = await this.identityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const user = await this.identityClient.findUser(
{
annotations: {
'google.com/email': profile.email,
},
},
{ token },
);
return {
...response,
@@ -192,6 +202,7 @@ export const createGoogleProvider: AuthProviderFactory = ({
clientSecret,
callbackUrl,
logger,
tokenIssuer,
identityClient: new CatalogIdentityClient({ catalogApi }),
});
@@ -72,6 +72,20 @@ describe('CatalogImportClient', () => {
const githubAuthApi: jest.Mocked<OAuthApi> = {
getAccessToken: jest.fn(),
};
const identityApi = {
getUserId: () => {
return 'user';
},
getProfile: () => {
return {};
},
getIdToken: () => {
return Promise.resolve('token');
},
signOut: () => {
return Promise.resolve();
},
};
const configApi = new ConfigReader({});
@@ -92,6 +106,7 @@ describe('CatalogImportClient', () => {
discoveryApi,
githubAuthApi,
configApi,
identityApi,
catalogApi,
});
@@ -16,7 +16,12 @@
import { CatalogApi } from '@backstage/catalog-client';
import { EntityName } from '@backstage/catalog-model';
import { ConfigApi, DiscoveryApi, OAuthApi } from '@backstage/core';
import {
ConfigApi,
DiscoveryApi,
IdentityApi,
OAuthApi,
} from '@backstage/core';
import { GitHubIntegrationConfig } from '@backstage/integration';
import { Octokit } from '@octokit/rest';
import { PartialEntity } from '../types';
@@ -25,6 +30,7 @@ import { getGithubIntegrationConfig } from './GitHub';
export class CatalogImportClient implements CatalogImportApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
private readonly githubAuthApi: OAuthApi;
private readonly configApi: ConfigApi;
private readonly catalogApi: CatalogApi;
@@ -32,11 +38,13 @@ export class CatalogImportClient implements CatalogImportApi {
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
identityApi: IdentityApi;
configApi: ConfigApi;
catalogApi: CatalogApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
this.identityApi = options.identityApi;
this.configApi = options.configApi;
this.catalogApi = options.catalogApi;
}
@@ -124,11 +132,13 @@ export class CatalogImportClient implements CatalogImportApi {
}: {
repo: string;
}): Promise<PartialEntity[]> {
const idToken = await this.identityApi.getIdToken();
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`,
{
headers: {
'Content-Type': 'application/json',
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
method: 'POST',
body: JSON.stringify({
@@ -29,6 +29,21 @@ import { catalogImportApiRef, CatalogImportClient } from '../api';
import { ImportComponentPage } from './ImportComponentPage';
describe('<ImportComponentPage />', () => {
const identityApi = {
getUserId: () => {
return 'user';
},
getProfile: () => {
return {};
},
getIdToken: () => {
return Promise.resolve('token');
},
signOut: () => {
return Promise.resolve();
},
};
let apis: ApiRegistry;
beforeEach(() => {
@@ -41,7 +56,10 @@ describe('<ImportComponentPage />', () => {
catalogImportApiRef,
new CatalogImportClient({
discoveryApi: {} as any,
githubAuthApi: {} as any,
githubAuthApi: {
getAccessToken: async () => 'token',
},
identityApi,
configApi: {} as any,
catalogApi: {} as any,
}),
+10 -1
View File
@@ -15,6 +15,7 @@
*/
import {
identityApiRef,
configApiRef,
createApiFactory,
createPlugin,
@@ -39,14 +40,22 @@ export const catalogImportPlugin = createPlugin({
deps: {
discoveryApi: discoveryApiRef,
githubAuthApi: githubAuthApiRef,
identityApi: identityApiRef,
configApi: configApiRef,
catalogApi: catalogApiRef,
},
factory: ({ discoveryApi, githubAuthApi, configApi, catalogApi }) =>
factory: ({
discoveryApi,
githubAuthApi,
identityApi,
configApi,
catalogApi,
}) =>
new CatalogImportClient({
discoveryApi,
githubAuthApi,
configApi,
identityApi,
catalogApi,
}),
}),
@@ -0,0 +1,169 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { CatalogClient } from '@backstage/catalog-client';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { CatalogClientWrapper } from './CatalogClientWrapper';
jest.mock('@backstage/catalog-client');
const MockedCatalogClient = CatalogClient as jest.Mock<CatalogClient>;
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi: DiscoveryApi = {
async getBaseUrl(_pluginId) {
return mockBaseUrl;
},
};
const identityApi: IdentityApi = {
getUserId() {
return 'jane-fonda';
},
getProfile() {
return { email: 'jane-fonda@spotify.com' };
},
async getIdToken() {
return Promise.resolve('fake-id-token');
},
async signOut() {
return Promise.resolve();
},
};
const guestIdentityApi: IdentityApi = {
getUserId() {
return 'guest';
},
getProfile() {
return {};
},
async getIdToken() {
return Promise.resolve(undefined);
},
async signOut() {
return Promise.resolve();
},
};
describe('CatalogClientWrapper', () => {
beforeEach(() => {
MockedCatalogClient.mockClear();
});
describe('getEntities', () => {
it('injects authorization token', async () => {
const client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi,
});
await client.getEntities();
const getEntities = MockedCatalogClient.mock.instances[0].getEntities;
expect(getEntities).toHaveBeenCalledWith(undefined, {
token: 'fake-id-token',
});
expect(getEntities).toHaveBeenCalledTimes(1);
});
});
describe('getLocationById', () => {
it('omits authorization token when guest', async () => {
const client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi: guestIdentityApi,
});
await client.getLocationById('42');
const getLocationById =
MockedCatalogClient.mock.instances[0].getLocationById;
expect(getLocationById).toHaveBeenCalledWith('42', {});
expect(getLocationById).toHaveBeenCalledTimes(1);
});
});
describe('getEntityByName', () => {
const name = {
kind: 'kind',
namespace: 'namespace',
name: 'name',
};
it('injects authorization token', async () => {
const client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi,
});
await client.getEntityByName(name);
const getEntityByName =
MockedCatalogClient.mock.instances[0].getEntityByName;
expect(getEntityByName).toHaveBeenCalledWith(name, {
token: 'fake-id-token',
});
expect(getEntityByName).toHaveBeenCalledTimes(1);
});
});
describe('addLocation', () => {
const location = { target: 'target' };
it('injects authorization token', async () => {
const client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi,
});
await client.addLocation(location);
const addLocation = MockedCatalogClient.mock.instances[0].addLocation;
expect(addLocation).toHaveBeenCalledWith(location, {
token: 'fake-id-token',
});
expect(addLocation).toHaveBeenCalledTimes(1);
});
});
describe('getLocationByEntity', () => {
const entity = {
apiVersion: 'apiVersion',
kind: 'kind',
metadata: {
name: 'name',
},
};
it('injects authorization token', async () => {
const client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi,
});
await client.getLocationByEntity(entity);
const getLocationByEntity =
MockedCatalogClient.mock.instances[0].getLocationByEntity;
expect(getLocationByEntity).toHaveBeenCalledWith(entity, {
token: 'fake-id-token',
});
expect(getLocationByEntity).toHaveBeenCalledTimes(1);
});
});
describe('removeEntityByUid', () => {
it('injects authorization token', async () => {
const uid = 'uid';
const client = new CatalogClientWrapper({
client: new MockedCatalogClient({ discoveryApi }),
identityApi,
});
await client.removeEntityByUid(uid);
const removeEntityByUid =
MockedCatalogClient.mock.instances[0].removeEntityByUid;
expect(removeEntityByUid).toHaveBeenCalledWith(uid, {
token: 'fake-id-token',
});
expect(removeEntityByUid).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,97 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Entity, EntityName, Location } from '@backstage/catalog-model';
import {
AddLocationRequest,
AddLocationResponse,
CatalogApi,
CatalogEntitiesRequest,
CatalogListResponse,
CatalogClient,
} from '@backstage/catalog-client';
import { IdentityApi } from '@backstage/core';
type CatalogRequestOptions = {
token?: string;
};
/**
* CatalogClient wrapper that injects identity token for all requests
*/
export class CatalogClientWrapper implements CatalogApi {
private readonly identityApi: IdentityApi;
private readonly client: CatalogClient;
constructor(options: { client: CatalogClient; identityApi: IdentityApi }) {
this.client = options.client;
this.identityApi = options.identityApi;
}
async getLocationById(
id: String,
options?: CatalogRequestOptions,
): Promise<Location | undefined> {
return await this.client.getLocationById(id, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async getEntities(
request?: CatalogEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<CatalogListResponse<Entity>> {
return await this.client.getEntities(request, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async getEntityByName(
compoundName: EntityName,
options?: CatalogRequestOptions,
): Promise<Entity | undefined> {
return await this.client.getEntityByName(compoundName, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async addLocation(
request: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise<AddLocationResponse> {
return await this.client.addLocation(request, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async getLocationByEntity(
entity: Entity,
options?: CatalogRequestOptions,
): Promise<Location | undefined> {
return await this.client.getLocationByEntity(entity, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
async removeEntityByUid(
uid: string,
options?: CatalogRequestOptions,
): Promise<void> {
return await this.client.removeEntityByUid(uid, {
token: options?.token ?? (await this.identityApi.getIdToken()),
});
}
}
+8 -2
View File
@@ -20,20 +20,26 @@ import {
createPlugin,
discoveryApiRef,
createRoutableExtension,
identityApiRef,
} from '@backstage/core';
import {
catalogApiRef,
catalogRouteRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { CatalogClientWrapper } from './CatalogClientWrapper';
export const catalogPlugin = createPlugin({
id: 'catalog',
apis: [
createApiFactory({
api: catalogApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new CatalogClientWrapper({
client: new CatalogClient({ discoveryApi }),
identityApi,
}),
}),
],
routes: {
+22 -3
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { UrlPatternDiscovery } from '@backstage/core';
import { UrlPatternDiscovery, IdentityApi } from '@backstage/core';
import { msw } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
@@ -22,6 +22,21 @@ import { FindingSummary, FossaApi, FossaClient } from './index';
const server = setupServer();
const identityApi: IdentityApi = {
getUserId() {
return 'jane-fonda';
},
getProfile() {
return { email: 'jane-fonda@spotify.com' };
},
async getIdToken() {
return Promise.resolve('fake-id-token');
},
async signOut() {
return Promise.resolve();
},
};
describe('FossaClient', () => {
msw.setupDefaultHandlers(server);
@@ -30,7 +45,11 @@ describe('FossaClient', () => {
let client: FossaApi;
beforeEach(() => {
client = new FossaClient({ discoveryApi, organizationId: '8736' });
client = new FossaClient({
discoveryApi,
identityApi,
organizationId: '8736',
});
});
it('should report finding summary', async () => {
@@ -137,7 +156,7 @@ describe('FossaClient', () => {
});
it('should skip organizationId', async () => {
client = new FossaClient({ discoveryApi });
client = new FossaClient({ discoveryApi, identityApi });
server.use(
rest.get(`${mockBaseUrl}/fossa/projects`, (req, res, ctx) => {
+9 -2
View File
@@ -14,28 +14,35 @@
* limitations under the License.
*/
import { DiscoveryApi } from '@backstage/core';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import fetch from 'cross-fetch';
import { FindingSummary, FossaApi } from './FossaApi';
export class FossaClient implements FossaApi {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
organizationId?: string;
constructor({
discoveryApi,
identityApi,
organizationId,
}: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
organizationId?: string;
}) {
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
this.organizationId = organizationId;
}
private async callApi(path: string): Promise<any> {
const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/fossa`;
const response = await fetch(`${apiUrl}/${path}`);
const idToken = await this.identityApi.getIdToken();
const response = await fetch(`${apiUrl}/${path}`, {
headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
});
if (response.status === 200) {
return await response.json();
}
+8 -2
View File
@@ -19,6 +19,7 @@ import {
createApiFactory,
createPlugin,
discoveryApiRef,
identityApiRef,
} from '@backstage/core';
import { fossaApiRef, FossaClient } from './api';
@@ -27,10 +28,15 @@ export const fossaPlugin = createPlugin({
apis: [
createApiFactory({
api: fossaApiRef,
deps: { configApi: configApiRef, discoveryApi: discoveryApiRef },
factory: ({ configApi, discoveryApi }) =>
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
},
factory: ({ configApi, discoveryApi, identityApi }) =>
new FossaClient({
discoveryApi,
identityApi,
organizationId: configApi.getOptionalString('fossa.organizationId'),
}),
}),
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DiscoveryApi } from '@backstage/core';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { KubernetesApi } from './types';
import {
KubernetesRequestBody,
@@ -23,9 +23,14 @@ import {
export class KubernetesBackendClient implements KubernetesApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
private async getRequired(
@@ -33,10 +38,12 @@ export class KubernetesBackendClient implements KubernetesApi {
requestBody: KubernetesRequestBody,
): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`;
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(idToken && { Authorization: `Bearer ${idToken}` }),
},
body: JSON.stringify(requestBody),
});
+4 -3
View File
@@ -18,6 +18,7 @@ import {
createPlugin,
createRouteRef,
discoveryApiRef,
identityApiRef,
googleAuthApiRef,
createRoutableExtension,
} from '@backstage/core';
@@ -36,9 +37,9 @@ export const kubernetesPlugin = createPlugin({
apis: [
createApiFactory({
api: kubernetesApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) =>
new KubernetesBackendClient({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new KubernetesBackendClient({ discoveryApi, identityApi }),
}),
createApiFactory({
api: kubernetesAuthProvidersApiRef,
+11 -3
View File
@@ -20,13 +20,18 @@ import {
RollbarProject,
RollbarTopActiveItem,
} from './types';
import { DiscoveryApi } from '@backstage/core';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
export class RollbarClient implements RollbarApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
async getAllProjects(): Promise<RollbarProject[]> {
@@ -53,7 +58,10 @@ export class RollbarClient implements RollbarApi {
private async get(path: string): Promise<any> {
const url = `${await this.discoveryApi.getBaseUrl('rollbar')}${path}`;
const response = await fetch(url);
const idToken = await this.identityApi.getIdToken();
const response = await fetch(url, {
headers: idToken ? { Authorization: `Bearer ${idToken}` } : {},
});
if (!response.ok) {
const payload = await response.text();
+4 -2
View File
@@ -20,6 +20,7 @@ import {
createRoutableExtension,
createRouteRef,
discoveryApiRef,
identityApiRef,
} from '@backstage/core';
import { rollbarApiRef } from './api/RollbarApi';
import { RollbarClient } from './api/RollbarClient';
@@ -34,8 +35,9 @@ export const rollbarPlugin = createPlugin({
apis: [
createApiFactory({
api: rollbarApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new RollbarClient({ discoveryApi, identityApi }),
}),
],
routes: {
+1
View File
@@ -30,6 +30,7 @@
},
"dependencies": {
"@backstage/backend-common": "^0.5.2",
"@backstage/catalog-client": "^0.3.5",
"@backstage/catalog-model": "^0.7.1",
"@backstage/config": "^0.1.2",
"@backstage/integration": "^0.3.2",
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import fetch from 'cross-fetch';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { CatalogClient } from '@backstage/catalog-client';
import {
ConflictError,
NotFoundError,
@@ -26,10 +26,12 @@ import {
* A catalog client tailored for reading out entity data from the catalog.
*/
export class CatalogEntityClient {
private readonly discovery: PluginEndpointDiscovery;
private readonly catalogClient: CatalogClient;
constructor(options: { discovery: PluginEndpointDiscovery }) {
this.discovery = options.discovery;
this.catalogClient = new CatalogClient({
discoveryApi: options.discovery,
});
}
/**
@@ -37,25 +39,19 @@ export class CatalogEntityClient {
*
* Throws a NotFoundError or ConflictError if 0 or multiple templates are found.
*/
async findTemplate(templateName: string): Promise<TemplateEntityV1alpha1> {
const conditions = [
'kind=template',
`metadata.name=${encodeURIComponent(templateName)}`,
];
const baseUrl = await this.discovery.getBaseUrl('catalog');
const response = await fetch(
`${baseUrl}/entities?filter=${conditions.join(',')}`,
);
if (!response.ok) {
const text = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${text}`,
);
}
const templates: TemplateEntityV1alpha1[] = await response.json();
async findTemplate(
templateName: string,
options?: { token?: string },
): Promise<TemplateEntityV1alpha1> {
const { items: templates } = (await this.catalogClient.getEntities(
{
filter: {
kind: 'template',
'metadata.name': templateName,
},
},
options,
)) as { items: TemplateEntityV1alpha1[] };
if (templates.length !== 1) {
if (templates.length > 1) {
@@ -133,7 +133,10 @@ export async function createRouter(
},
};
const template = await entityClient.findTemplate(templateName);
// Forward authorization from client
const template = await entityClient.findTemplate(templateName, {
token: getBearerToken(req.headers.authorization),
});
const validationResult: ValidatorResult = validate(
values,
@@ -295,3 +298,7 @@ export async function createRouter(
return app;
}
function getBearerToken(header?: string): string | undefined {
return header?.match(/Bearer\s+(\S+)/i)?.[1];
}
+4 -3
View File
@@ -16,7 +16,7 @@
import React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { discoveryApiRef } from '@backstage/core';
import { discoveryApiRef, identityApiRef } from '@backstage/core';
import { CatalogClient } from '@backstage/catalog-client';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { TemplateIndexPage, TemplatePage } from '../src/plugin';
@@ -30,8 +30,9 @@ createDevApp()
})
.registerApi({
api: scaffolderApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new ScaffolderApi({ discoveryApi, identityApi }),
})
.addPage({
path: '/create',
+13 -3
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createApiRef, DiscoveryApi } from '@backstage/core';
import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
id: 'plugin.scaffolder.service',
@@ -23,9 +23,14 @@ export const scaffolderApiRef = createApiRef<ScaffolderApi>({
export class ScaffolderApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
}
/**
@@ -36,11 +41,13 @@ export class ScaffolderApi {
* @param values Parameters for the template, e.g. name, description
*/
async scaffold(templateName: string, values: Record<string, any>) {
const token = await this.identityApi.getIdToken();
const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v1/jobs`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ templateName, values: { ...values } }),
});
@@ -56,8 +63,11 @@ export class ScaffolderApi {
}
async getJob(jobId: string) {
const token = await this.identityApi.getIdToken();
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`;
return fetch(url).then(x => x.json());
return fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}).then(x => x.json());
}
}
+4 -2
View File
@@ -18,6 +18,7 @@ import {
createPlugin,
createApiFactory,
discoveryApiRef,
identityApiRef,
createRoutableExtension,
} from '@backstage/core';
import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage';
@@ -30,8 +31,9 @@ export const scaffolderPlugin = createPlugin({
apis: [
createApiFactory({
api: scaffolderApiRef,
deps: { discoveryApi: discoveryApiRef },
factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }),
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new ScaffolderApi({ discoveryApi, identityApi }),
}),
],
register({ router }) {