jenkins-backend: migrated to use auth services

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-02-14 13:30:38 +01:00
parent 58b5e450dd
commit 55191cc4f6
13 changed files with 136 additions and 60 deletions
+7
View File
@@ -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.
+2
View File
@@ -32,6 +32,8 @@ export default async function createPlugin(
jenkinsInfoProvider: DefaultJenkinsInfoProvider.fromConfig({
catalog,
config: env.config,
discovery: env.discovery,
}),
discovery: env.discovery,
});
}
+14 -2
View File
@@ -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<JenkinsInfo>;
// (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<JenkinsInfo>;
}
@@ -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)
+1
View File
@@ -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"
+15 -1
View File
@@ -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,
}),
);
},
+3 -1
View File
@@ -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);
});
@@ -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);
});
@@ -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<number> {
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];
@@ -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',
@@ -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<JenkinsInfo>;
}
@@ -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<JenkinsInfo> {
// 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)}`,
+19 -19
View File
@@ -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);
@@ -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)
+1
View File
@@ -7058,6 +7058,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:^"