Tech Insights permissions coverage (#3867)

* Tech Insights permissions coverage

Signed-off-by: João Martins <joao@roadie.io>

* chore: trigger CI

Signed-off-by: João Martins <joao@roadie.io>

* missing dependencies in router

Signed-off-by: João Martins <joao@roadie.io>

* add api reports

Signed-off-by: João Martins <joao@roadie.io>

---------

Signed-off-by: João Martins <joao@roadie.io>
This commit is contained in:
João Martins 2025-04-30 11:54:00 +01:00 committed by GitHub
parent d8b448d4d8
commit 79a9ab9ec6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 264 additions and 31 deletions

View File

@ -2,6 +2,19 @@
The backend plugin for Tech Insights.
## Breaking Changes in v3.0.0
This version includes breaking changes related to permissions. Please review the changes carefully before upgrading:
- Added required permissions for accessing Tech Insights features
- Users must now have the appropriate policies added to their roles to access Tech Insights functionality
- This change enforces better security by ensuring explicit permission grants for Tech Insights operations
To upgrade, you'll need to:
1. Update your permission policies to include the necessary Tech Insights permissions
2. Ensure your users' roles have the appropriate policies assigned
It provides the API for the frontend tech insights, scorecards and fact visualization functionality, as well as a framework to run fact retrievers and store fact values in to a data store.
Looking for the old backend installation docs? Visit [here](./docs/old-backend-system.md).

View File

@ -1,6 +1,6 @@
{
"name": "@backstage-community/plugin-tech-insights-backend",
"version": "2.2.1",
"version": "3.0.0",
"backstage": {
"role": "backend-plugin",
"pluginId": "tech-insights",
@ -57,6 +57,8 @@
"@backstage/catalog-model": "^1.7.3",
"@backstage/config": "^1.3.2",
"@backstage/errors": "^1.2.7",
"@backstage/plugin-permission-common": "^0.8.4",
"@backstage/plugin-permission-node": "^0.8.7",
"@backstage/types": "^1.2.1",
"@types/express": "^4.17.6",
"@types/luxon": "^3.0.0",

View File

@ -18,8 +18,10 @@ import { FactLifecycle } from '@backstage-community/plugin-tech-insights-node';
import { FactRetriever } from '@backstage-community/plugin-tech-insights-node';
import { FactRetrieverRegistration } from '@backstage-community/plugin-tech-insights-node';
import { FactRetrieverRegistry as FactRetrieverRegistry_2 } from '@backstage-community/plugin-tech-insights-node';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { HumanDuration } from '@backstage/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { PermissionsService } from '@backstage/backend-plugin-api';
import { PersistenceContext as PersistenceContext_2 } from '@backstage-community/plugin-tech-insights-node';
import { SchedulerService } from '@backstage/backend-plugin-api';
import { UrlReaderService } from '@backstage/backend-plugin-api';
@ -90,7 +92,9 @@ export interface RouterOptions<
> {
config: Config;
factChecker?: FactChecker<CheckType, CheckResultType>;
httpAuth: HttpAuthService;
logger: LoggerService;
permissions: PermissionsService;
persistenceContext: PersistenceContext_2;
}

View File

@ -103,6 +103,8 @@ export const techInsightsPlugin = createBackendPlugin({
scheduler: coreServices.scheduler,
auth: coreServices.auth,
urlReader: coreServices.urlReader,
httpAuth: coreServices.httpAuth,
permissions: coreServices.permissions,
},
async init({
config,
@ -113,6 +115,8 @@ export const techInsightsPlugin = createBackendPlugin({
scheduler,
auth,
urlReader,
httpAuth,
permissions,
}) {
const factRetrievers: FactRetrieverRegistration[] = Object.entries(
addedFactRetrievers,
@ -145,6 +149,8 @@ export const techInsightsPlugin = createBackendPlugin({
...context,
config,
logger,
permissions,
httpAuth,
}),
);
},

View File

@ -23,10 +23,53 @@ import {
PersistenceContext,
TechInsightsStore,
} from '@backstage-community/plugin-tech-insights-node';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { DateTime } from 'luxon';
import { mockServices } from '@backstage/backend-test-utils';
import { DefaultSchedulerService } from '@backstage/backend-defaults/scheduler';
const setupRouter = async (
mockPersistenceContext: PersistenceContext,
allow: boolean,
) => {
const database = mockServices.database.mock({
migrations: { skip: true },
});
const logger = mockServices.logger.mock();
const urlReader = mockServices.urlReader.mock();
const techInsightsContext = await buildTechInsightsContext({
database,
logger,
factRetrievers: [],
scheduler: DefaultSchedulerService.create({ database, logger }),
config: ConfigReader.fromConfigs([]),
discovery: {
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
getExternalBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
},
auth: mockServices.auth(),
urlReader,
});
const router = await createRouter({
logger,
config: ConfigReader.fromConfigs([]),
...techInsightsContext,
persistenceContext: mockPersistenceContext,
permissions: mockServices.permissions.mock({
authorize: async () => [
{ result: allow ? AuthorizeResult.ALLOW : AuthorizeResult.DENY },
],
authorizeConditional: async () => [
{ result: allow ? AuthorizeResult.ALLOW : AuthorizeResult.DENY },
],
}),
httpAuth: mockServices.httpAuth(),
});
return router;
};
describe('Tech Insights router tests', () => {
let app: express.Express;
@ -46,32 +89,8 @@ describe('Tech Insights router tests', () => {
jest.resetAllMocks();
});
beforeAll(async () => {
const database = mockServices.database.mock({
migrations: { skip: true },
});
const logger = mockServices.logger.mock();
const urlReader = mockServices.urlReader.mock();
const techInsightsContext = await buildTechInsightsContext({
database,
logger,
factRetrievers: [],
scheduler: DefaultSchedulerService.create({ database, logger }),
config: ConfigReader.fromConfigs([]),
discovery: {
getBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
getExternalBaseUrl: (_: string) => Promise.resolve('http://mock.url'),
},
auth: mockServices.auth(),
urlReader,
});
const router = await createRouter({
logger,
config: ConfigReader.fromConfigs([]),
...techInsightsContext,
persistenceContext: mockPersistenceContext,
});
beforeEach(async () => {
const router = await setupRouter(mockPersistenceContext, true);
app = express().use(router);
});
@ -80,6 +99,12 @@ describe('Tech Insights router tests', () => {
await request(app).get('/fact-schemas').expect(200);
expect(latestSchemasMock).toHaveBeenCalled();
});
it('should not allow access when not authorized', async () => {
const router = await setupRouter(mockPersistenceContext, false);
app = express().use(router);
await request(app).get('/fact-schemas').expect(403);
});
});
describe('/checks', () => {
@ -113,6 +138,20 @@ describe('Tech Insights router tests', () => {
.expect(200);
expect(latestFactsByIdsMock).toHaveBeenCalledWith(['secondId'], 'a:a/a');
});
it('should not allow access when not authorized', async () => {
const router = await setupRouter(mockPersistenceContext, false);
app = express().use(router);
await request(app)
.get('/facts/latest')
.query({
entity: 'a:a/a',
ids: ['firstId', 'secondId'],
})
.expect(403);
});
});
describe('/facts/range', () => {
@ -164,5 +203,13 @@ describe('Tech Insights router tests', () => {
DateTime.fromISO('2022-11-11T11:11:11.000+00:00'),
);
});
it('should not allow access when not authorized', async () => {
const router = await setupRouter(mockPersistenceContext, false);
app = express().use(router);
await request(app).get('/facts/range').expect(403);
});
});
});

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
import express from 'express';
import express, { Request } from 'express';
import Router from 'express-promise-router';
import { Config } from '@backstage/config';
import {
@ -25,6 +25,10 @@ import {
import {
CheckResult,
Check,
techInsightsCheckReadPermission,
techInsightsCheckUpdatePermission,
techInsightsPermissions,
techInsightsFactRetrieverReadPermission,
} from '@backstage-community/plugin-tech-insights-common';
import { DateTime } from 'luxon';
import {
@ -33,8 +37,18 @@ import {
stringifyEntityRef,
} from '@backstage/catalog-model';
import { serializeError } from '@backstage/errors';
import { LoggerService } from '@backstage/backend-plugin-api';
import {
LoggerService,
PermissionsService,
HttpAuthService,
} from '@backstage/backend-plugin-api';
import { MiddlewareFactory } from '@backstage/backend-defaults/rootHttpRouter';
import {
AuthorizeResult,
BasicPermission,
} from '@backstage/plugin-permission-common';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import { NotAllowedError } from '@backstage/errors';
import pLimit from 'p-limit';
/**
@ -67,6 +81,16 @@ export interface RouterOptions<
* Implementation of Winston logger
*/
logger: LoggerService;
/**
* Implementation of PermissionsService
*/
permissions: PermissionsService;
/**
* Implementation of HttpAuthService
*/
httpAuth: HttpAuthService;
}
/**
@ -84,19 +108,54 @@ export async function createRouter<
CheckResultType extends CheckResult,
>(options: RouterOptions<CheckType, CheckResultType>): Promise<express.Router> {
const router = Router();
const permissionsIntegrationRouter = createPermissionIntegrationRouter({
permissions: techInsightsPermissions,
});
router.use(express.json());
const { persistenceContext, factChecker, logger, config } = options;
router.use(permissionsIntegrationRouter);
const {
persistenceContext,
factChecker,
logger,
config,
permissions,
httpAuth,
} = options;
const { techInsightsStore } = persistenceContext;
const factory = MiddlewareFactory.create({ logger, config });
const authorize = async (request: Request, permission: BasicPermission) => {
const decision = (
await permissions.authorize([{ permission: permission }], {
credentials: await httpAuth.credentials(request),
})
)[0];
return decision;
};
if (factChecker) {
logger.info('Fact checker configured. Enabling fact checking endpoints.');
router.get('/checks', async (_req, res) => {
router.get('/checks', async (req, res) => {
const decision = await authorize(req, techInsightsCheckReadPermission);
if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError('Unauthorized');
}
return res.json(await factChecker.getChecks());
});
router.post('/checks/run/:namespace/:kind/:name', async (req, res) => {
const decision = await authorize(req, techInsightsCheckUpdatePermission);
if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError('Unauthorized');
}
const { namespace, kind, name } = req.params;
const { checks }: { checks: string[] } = req.body;
const entityTriplet = stringifyEntityRef({ namespace, kind, name });
@ -107,6 +166,11 @@ export async function createRouter<
const checksRunConcurrency =
config.getOptionalNumber('techInsights.checksRunConcurrency') || 100;
router.post('/checks/run', async (req, res) => {
const decision = await authorize(req, techInsightsCheckUpdatePermission);
if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError('Unauthorized');
}
const checks: string[] = req.body.checks;
let entities: CompoundEntityRef[] = req.body.entities;
if (entities.length === 0) {
@ -144,6 +208,15 @@ export async function createRouter<
}
router.get('/fact-schemas', async (req, res) => {
const decision = await authorize(
req,
techInsightsFactRetrieverReadPermission,
);
if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError('Unauthorized');
}
const ids = req.query.ids as string[];
return res.json(await techInsightsStore.getLatestSchemas(ids));
});
@ -152,6 +225,14 @@ export async function createRouter<
* /facts/latest?entity=component:default/mycomponent&ids[]=factRetrieverId1&ids[]=factRetrieverId2
*/
router.get('/facts/latest', async (req, res) => {
const decision = await authorize(
req,
techInsightsFactRetrieverReadPermission,
);
if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError('Unauthorized');
}
const { entity } = req.query;
const { namespace, kind, name } = parseEntityRef(entity as string);
@ -173,6 +254,15 @@ export async function createRouter<
* /facts/range?entity=component:default/mycomponent&startDateTime=2021-12-24T01:23:45&endDateTime=2021-12-31T23:59:59&ids[]=factRetrieverId1&ids[]=factRetrieverId2
*/
router.get('/facts/range', async (req, res) => {
const decision = await authorize(
req,
techInsightsFactRetrieverReadPermission,
);
if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError('Unauthorized');
}
const { entity } = req.query;
const { namespace, kind, name } = parseEntityRef(entity as string);

View File

@ -3,6 +3,7 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
import { DateTime } from 'luxon';
import { JsonValue } from '@backstage/types';
@ -93,5 +94,17 @@ export interface InsightFacts {
};
}
// @public (undocumented)
export const techInsightsCheckReadPermission: BasicPermission;
// @public (undocumented)
export const techInsightsCheckUpdatePermission: BasicPermission;
// @public (undocumented)
export const techInsightsFactRetrieverReadPermission: BasicPermission;
// @public (undocumented)
export const techInsightsPermissions: BasicPermission[];
// (No @packageDocumentation comment for this package)
```

View File

@ -15,3 +15,4 @@
*/
export * from './types';
export * from './permissions';

View File

@ -0,0 +1,55 @@
/*
* Copyright 2024 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 { createPermission } from '@backstage/plugin-permission-common';
/**
* @public
*/
export const techInsightsCheckReadPermission = createPermission({
name: 'tech-insights.check.read',
attributes: {
action: 'read',
},
});
/**
* @public
*/
export const techInsightsCheckUpdatePermission = createPermission({
name: 'tech-insights.check.run',
attributes: {
action: 'update',
},
});
/**
* @public
*/
export const techInsightsFactRetrieverReadPermission = createPermission({
name: 'tech-insights.fact-retriever.read',
attributes: {
action: 'read',
},
});
/**
* @public
*/
export const techInsightsPermissions = [
techInsightsCheckReadPermission,
techInsightsCheckUpdatePermission,
techInsightsFactRetrieverReadPermission,
];

View File

@ -2778,6 +2778,8 @@ __metadata:
"@backstage/cli": "npm:^0.29.6"
"@backstage/config": "npm:^1.3.2"
"@backstage/errors": "npm:^1.2.7"
"@backstage/plugin-permission-common": "npm:^0.8.4"
"@backstage/plugin-permission-node": "npm:^0.8.7"
"@backstage/types": "npm:^1.2.1"
"@types/express": "npm:^4.17.6"
"@types/lodash": "npm:^4.14.151"