diff --git a/.changeset/heavy-coats-sniff.md b/.changeset/heavy-coats-sniff.md new file mode 100644 index 0000000000..c503ec0dec --- /dev/null +++ b/.changeset/heavy-coats-sniff.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-jenkins-backend': minor +--- + +**BREAKING**: Both `createRouter` and `DefaultJenkinsInfoProvider.fromConfig` now require the `discovery` service to be forwarded from the plugin environment. This is part of the migration to support new auth services. + +The `JenkinsInfoProvider` interface has been updated to receive `credentials` of the type `BackstageCredentials` rather than a token. diff --git a/packages/backend/src/plugins/jenkins.ts b/packages/backend/src/plugins/jenkins.ts index d62200b0ac..7d47ee338d 100644 --- a/packages/backend/src/plugins/jenkins.ts +++ b/packages/backend/src/plugins/jenkins.ts @@ -32,6 +32,8 @@ export default async function createPlugin( jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({ catalog, config: env.config, + discovery: env.discovery, }), + discovery: env.discovery, }); } diff --git a/plugins/jenkins-backend/api-report.md b/plugins/jenkins-backend/api-report.md index 28dafa70bf..e1cd04d8ec 100644 --- a/plugins/jenkins-backend/api-report.md +++ b/plugins/jenkins-backend/api-report.md @@ -3,11 +3,15 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { DiscoveryService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { HttpAuthService } from '@backstage/backend-plugin-api'; import { Logger } from 'winston'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; @@ -21,12 +25,14 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { static fromConfig(options: { config: Config; catalog: CatalogApi; + discovery: DiscoveryService; + auth?: AuthService; }): DefaultJenkinsInfoProvider; // (undocumented) getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise; // (undocumented) static readonly NEW_JENKINS_ANNOTATION = 'jenkins.io/job-full-name'; @@ -61,7 +67,7 @@ export interface JenkinsInfoProvider { getInstance(options: { entityRef: CompoundEntityRef; jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise; } @@ -86,6 +92,12 @@ export default jenkinsPlugin; // @public (undocumented) export interface RouterOptions { + // (undocumented) + auth?: AuthService; + // (undocumented) + discovery: DiscoveryService; + // (undocumented) + httpAuth?: HttpAuthService; // (undocumented) jenkinsInfoProvider: JenkinsInfoProvider; // (undocumented) diff --git a/plugins/jenkins-backend/package.json b/plugins/jenkins-backend/package.json index 069e4f7ae8..eaf8218a01 100644 --- a/plugins/jenkins-backend/package.json +++ b/plugins/jenkins-backend/package.json @@ -50,6 +50,7 @@ "yn": "^4.0.0" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/jenkins": "^1.0.0", "@types/supertest": "^2.0.8" diff --git a/plugins/jenkins-backend/src/plugin.ts b/plugins/jenkins-backend/src/plugin.ts index 8da26d62aa..c02fc3e189 100644 --- a/plugins/jenkins-backend/src/plugin.ts +++ b/plugins/jenkins-backend/src/plugin.ts @@ -38,12 +38,24 @@ export const jenkinsPlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, config: coreServices.rootConfig, catalogClient: catalogServiceRef, + discovery: coreServices.discovery, + auth: coreServices.auth, }, - async init({ logger, permissions, httpRouter, config, catalogClient }) { + async init({ + logger, + permissions, + httpRouter, + config, + catalogClient, + discovery, + auth, + }) { const winstonLogger = loggerToWinstonLogger(logger); const jenkinsInfoProvider = DefaultJenkinsInfoProvider.fromConfig({ + auth, config, catalog: catalogClient, + discovery, }); httpRouter.use( await createRouter({ @@ -56,6 +68,8 @@ export const jenkinsPlugin = createBackendPlugin({ * Info provider to be able to get all necessary information for the APIs */ jenkinsInfoProvider, + discovery, + auth, }), ); }, diff --git a/plugins/jenkins-backend/src/run.ts b/plugins/jenkins-backend/src/run.ts index 0a3ed2b7f0..95ff18a510 100644 --- a/plugins/jenkins-backend/src/run.ts +++ b/plugins/jenkins-backend/src/run.ts @@ -17,12 +17,14 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; +import { ConfigReader } from '@backstage/config'; const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); +const config = new ConfigReader({}); -startStandaloneServer({ port, enableCors, logger }).catch(err => { +startStandaloneServer({ config, port, enableCors, logger }).catch(err => { logger.error(err); process.exit(1); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 8e80de34a3..2d4fb1d62e 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -20,6 +20,7 @@ import { JenkinsInfo } from './jenkinsInfoProvider'; import { JenkinsBuild, JenkinsProject } from '../types'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import fetch, { Response } from 'node-fetch'; +import { mockServices } from '@backstage/backend-test-utils'; jest.mock('jenkins'); jest.mock('node-fetch'); @@ -716,6 +717,8 @@ describe('JenkinsApi', () => { ); }); describe('rebuildProject', () => { + const auth = mockServices.auth(); + it('successfully rebuilds', async () => { mockFetch.mockResolvedValueOnce({ status: 200 } as Response); const status = await jenkinsApi.rebuildProject( @@ -723,6 +726,7 @@ describe('JenkinsApi', () => { jobFullName, buildNumber, resourceRef, + { credentials: await auth.getOwnServiceCredentials() }, ); expect(status).toEqual(200); }); @@ -733,6 +737,7 @@ describe('JenkinsApi', () => { jobFullName, buildNumber, resourceRef, + { credentials: await auth.getOwnServiceCredentials() }, ); expect(status).toEqual(401); }); @@ -750,6 +755,7 @@ describe('JenkinsApi', () => { jobFullName, buildNumber, resourceRef, + { credentials: await auth.getOwnServiceCredentials() }, ); expect(status).toEqual(401); }); diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 2cf432343f..9171670743 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -23,12 +23,13 @@ import type { JenkinsProject, ScmDetails, } from '../types'; -import { - AuthorizeResult, - PermissionEvaluator, -} from '@backstage/plugin-permission-common'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; import fetch, { HeaderInit } from 'node-fetch'; +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; export class JenkinsApiImpl { private static readonly lastBuildTreeSpec = `lastBuild[ @@ -75,7 +76,7 @@ export class JenkinsApiImpl { inQueue, builds[*]`; - constructor(private readonly permissionApi?: PermissionEvaluator) {} + constructor(private readonly permissionApi?: PermissionsService) {} /** * Get a list of projects for the given JenkinsInfo. @@ -160,12 +161,12 @@ export class JenkinsApiImpl { jobFullName: string, buildNumber: number, resourceRef: string, - options?: { token?: string }, + options: { credentials: BackstageCredentials }, ): Promise { if (this.permissionApi) { const response = await this.permissionApi.authorize( [{ permission: jenkinsExecutePermission, resourceRef }], - { token: options?.token }, + { credentials: options.credentials }, ); // permission api returns always at least one item, we need to check only one result since we do not expect any additional results const { result } = response[0]; diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts index 626e5b0a5a..6be3ec572c 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.test.ts @@ -22,6 +22,7 @@ import { JenkinsConfig, JenkinsInfo, } from './jenkinsInfoProvider'; +import { mockServices } from '@backstage/backend-test-utils'; describe('JenkinsConfig', () => { it('Reads simple config and annotation', async () => { @@ -184,6 +185,8 @@ describe('DefaultJenkinsInfoProvider', () => { return DefaultJenkinsInfoProvider.fromConfig({ config, catalog: mockCatalog, + discovery: mockServices.discovery(), + auth: mockServices.auth(), }); } @@ -191,9 +194,10 @@ describe('DefaultJenkinsInfoProvider', () => { const provider = configureProvider({ jenkins: {} }, undefined); await expect(provider.getInstance({ entityRef })).rejects.toThrow(); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); }); it('Reads simple config and annotation', async () => { @@ -218,9 +222,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toStrictEqual({ baseUrl: 'https://jenkins.example.com', crumbIssuer: undefined, @@ -257,9 +262,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -296,9 +302,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -335,9 +342,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', @@ -363,9 +371,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -391,9 +400,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins.example.com', jobFullName: 'teamA/artistLookup-build', @@ -424,9 +434,10 @@ describe('DefaultJenkinsInfoProvider', () => { ); const info: JenkinsInfo = await provider.getInstance({ entityRef }); - expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith(entityRef, { - backstageToken: undefined, - }); + expect(mockCatalog.getEntityByRef).toHaveBeenCalledWith( + entityRef, + undefined, + ); expect(info).toMatchObject({ baseUrl: 'https://jenkins-other.example.com', jobFullName: 'teamA/artistLookup-build', diff --git a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index c4b4ef58dd..0feb91d68e 100644 --- a/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +import { createLegacyAuthAdapters } from '@backstage/backend-common'; +import { + AuthService, + BackstageCredentials, + DiscoveryService, +} from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Entity, @@ -34,7 +40,7 @@ export interface JenkinsInfoProvider { */ jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise; } @@ -183,27 +189,37 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { private constructor( private readonly config: JenkinsConfig, private readonly catalog: CatalogApi, + private readonly auth: AuthService, ) {} static fromConfig(options: { config: Config; catalog: CatalogApi; + discovery: DiscoveryService; + auth?: AuthService; }): DefaultJenkinsInfoProvider { + const { auth } = createLegacyAuthAdapters(options); return new DefaultJenkinsInfoProvider( JenkinsConfig.fromConfig(options.config), options.catalog, + auth, ); } async getInstance(opt: { entityRef: CompoundEntityRef; jobFullName?: string; - backstageToken?: string; + credentials?: BackstageCredentials; }): Promise { // load entity - const entity = await this.catalog.getEntityByRef(opt.entityRef, { - token: opt.backstageToken, - }); + const entity = await this.catalog.getEntityByRef( + opt.entityRef, + opt.credentials && + (await this.auth.getPluginRequestToken({ + onBehalfOf: opt.credentials, + targetPluginId: 'catalog', + })), + ); if (!entity) { throw new Error( `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 06a04d8266..bda9686efb 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { + createLegacyAuthAdapters, + errorHandler, +} from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; @@ -25,17 +28,24 @@ import { PermissionEvaluator, toPermissionEvaluator, } from '@backstage/plugin-permission-common'; -import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { stringifyError } from '@backstage/errors'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import { jenkinsPermissions } from '@backstage/plugin-jenkins-common'; +import { + AuthService, + DiscoveryService, + HttpAuthService, +} from '@backstage/backend-plugin-api'; /** @public */ export interface RouterOptions { logger: Logger; jenkinsInfoProvider: JenkinsInfoProvider; permissions?: PermissionEvaluator | PermissionAuthorizer; + discovery: DiscoveryService; + auth?: AuthService; + httpAuth?: HttpAuthService; } /** @public */ @@ -56,6 +66,8 @@ export async function createRouter( : undefined; } + const { httpAuth } = createLegacyAuthAdapters(options); + const jenkinsApi = new JenkinsApiImpl(permissionEvaluator); const router = Router(); @@ -70,9 +82,6 @@ export async function createRouter( '/v1/entity/:namespace/:kind/:name/projects', async (request, response) => { const { namespace, kind, name } = request.params; - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const branch = request.query.branch; let branches: string[] | undefined; @@ -96,7 +105,7 @@ export async function createRouter( namespace, name, }, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); try { @@ -123,9 +132,6 @@ export async function createRouter( router.get( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const { namespace, kind, name, jobFullName, buildNumber } = request.params; @@ -136,7 +142,7 @@ export async function createRouter( name, }, jobFullName, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); const build = await jenkinsApi.getBuild( @@ -154,9 +160,6 @@ export async function createRouter( router.get( '/v1/entity/:namespace/:kind/:name/job/:jobFullName', async (request, response) => { - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const { namespace, kind, name, jobFullName } = request.params; const jenkinsInfo = await jenkinsInfoProvider.getInstance({ @@ -166,7 +169,7 @@ export async function createRouter( name, }, jobFullName, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobFullName); @@ -182,9 +185,6 @@ export async function createRouter( async (request, response) => { const { namespace, kind, name, jobFullName, buildNumber } = request.params; - const token = getBearerTokenFromAuthorizationHeader( - request.header('authorization'), - ); const jenkinsInfo = await jenkinsInfoProvider.getInstance({ entityRef: { kind, @@ -192,7 +192,7 @@ export async function createRouter( name, }, jobFullName, - backstageToken: token, + credentials: await httpAuth.credentials(request), }); const resourceRef = stringifyEntityRef({ kind, namespace, name }); @@ -202,7 +202,7 @@ export async function createRouter( parseInt(buildNumber, 10), resourceRef, { - token, + credentials: await httpAuth.credentials(request), }, ); response.json({}).status(status); diff --git a/plugins/jenkins-backend/src/service/standaloneServer.ts b/plugins/jenkins-backend/src/service/standaloneServer.ts index f89f235667..ab77bb6573 100644 --- a/plugins/jenkins-backend/src/service/standaloneServer.ts +++ b/plugins/jenkins-backend/src/service/standaloneServer.ts @@ -14,17 +14,19 @@ * limitations under the License. */ -import { createServiceBuilder } from '@backstage/backend-common'; +import { HostDiscovery, createServiceBuilder } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { JenkinsInfo } from './jenkinsInfoProvider'; +import { Config } from '@backstage/config'; export interface ServerOptions { port: number; enableCors: boolean; logger: Logger; + config: Config; } export async function startStandaloneServer( @@ -41,6 +43,7 @@ export async function startStandaloneServer( return { baseUrl: 'https://example.com/', jobFullName: 'build-foo' }; }, }, + discovery: HostDiscovery.fromConfig(options.config), }); let service = createServiceBuilder(module) diff --git a/yarn.lock b/yarn.lock index 83f14f8ca5..c2b9797393 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7054,6 +7054,7 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^"