Merge pull request #25924 from 04kash/add-catalog-permissions

Add Missing Catalog permissions for `/analyze-location` and `/validate-entity` endpoints
This commit is contained in:
Ben Lambert
2024-09-11 11:52:18 +02:00
committed by GitHub
13 changed files with 409 additions and 21 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-catalog-common': minor
'@backstage/plugin-catalog-node': minor
---
The `analyze-location` endpoint is now protected by the `catalog.location.analyze` permission.
The `validate-entity` endpoint is now protected by the `catalog.entity.validate` permission.
@@ -0,0 +1,86 @@
/*
* 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 { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { AuthorizedLocationAnalyzer } from './AuthorizedLocationAnalyzer';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { mockCredentials } from '@backstage/backend-test-utils';
describe('AuthorizedLocationAnalyzer', () => {
const locationAnalyzerService = {
analyzeLocation: jest.fn(),
};
const permissionApi = {
authorize: jest.fn(),
};
afterEach(() => {
jest.clearAllMocks();
});
it('throws Authorization Error on deny', async () => {
permissionApi.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.DENY,
},
]);
const authorizedService = new AuthorizedLocationAnalyzer(
locationAnalyzerService,
permissionApi as unknown as ServerPermissionClient,
);
await expect(() =>
authorizedService.analyzeLocation(
{
location: {
type: 'url',
target: 'https://example.com/path/to/your/catalog-info.yaml',
presence: 'required',
},
catalogFilename: 'catalog-info.yaml',
},
mockCredentials.none(),
),
).rejects.toThrow(NotAllowedError);
});
it('calls analyzeLocation on allow', async () => {
permissionApi.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
const authorizedService = new AuthorizedLocationAnalyzer(
locationAnalyzerService,
permissionApi as unknown as ServerPermissionClient,
);
const analyzeLocationRequest = {
location: {
type: 'url',
target: 'https://example.com/path/to/your/catalog-info.yaml',
},
catalogFilename: 'catalog-info.yaml',
};
await authorizedService.analyzeLocation(
analyzeLocationRequest,
mockCredentials.none(),
);
expect(locationAnalyzerService.analyzeLocation).toHaveBeenCalledWith(
analyzeLocationRequest,
mockCredentials.none(),
);
});
});
@@ -0,0 +1,53 @@
/*
* 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 { NotAllowedError } from '@backstage/errors';
import { catalogLocationAnalyzePermission } from '@backstage/plugin-catalog-common/alpha';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
BackstageCredentials,
PermissionsService,
} from '@backstage/backend-plugin-api';
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
import { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
export class AuthorizedLocationAnalyzer implements LocationAnalyzer {
constructor(
private readonly service: LocationAnalyzer,
private readonly permissionApi: PermissionsService,
) {}
async analyzeLocation(
request: AnalyzeLocationRequest,
credentials: BackstageCredentials,
): Promise<AnalyzeLocationResponse> {
const authorizeDecision = (
await this.permissionApi.authorize(
[
{
permission: catalogLocationAnalyzePermission,
},
],
{ credentials: credentials },
)
)[0];
if (authorizeDecision.result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError();
}
return this.service.analyzeLocation(request, credentials);
}
}
@@ -0,0 +1,101 @@
/*
* 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 { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { mockCredentials } from '@backstage/backend-test-utils';
import { AuthorizedValidationService } from './AuthorizedValidationService';
describe('AuthorizedValidationService', () => {
const orchestratorService = {
process: jest.fn(),
};
const permissionApi = {
authorize: jest.fn(),
};
afterEach(() => {
jest.clearAllMocks();
});
it('throws Authorization Error on deny', async () => {
permissionApi.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.DENY,
},
]);
const authorizedService = new AuthorizedValidationService(
orchestratorService,
permissionApi as unknown as ServerPermissionClient,
);
const entityProcessingRequest = {
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'your-entity-name',
namespace: 'default',
description: 'your-entity-description',
},
spec: {
type: 'service',
owner: 'team-a',
},
},
};
await expect(() =>
authorizedService.process(
entityProcessingRequest,
mockCredentials.none(),
),
).rejects.toThrow(NotAllowedError);
});
it('calls process on allow', async () => {
permissionApi.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
const authorizedService = new AuthorizedValidationService(
orchestratorService,
permissionApi as unknown as ServerPermissionClient,
);
const entityProcessingRequest = {
entity: {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'your-entity-name',
namespace: 'default',
description: 'your-entity-description',
},
spec: {
type: 'service',
owner: 'team-a',
},
},
};
await authorizedService.process(
entityProcessingRequest,
mockCredentials.none(),
);
expect(orchestratorService.process).toHaveBeenCalledWith(
entityProcessingRequest,
);
});
});
@@ -0,0 +1,55 @@
/*
* 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 { NotAllowedError } from '@backstage/errors';
import { catalogEntityValidatePermission } from '@backstage/plugin-catalog-common/alpha';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
CatalogProcessingOrchestrator,
EntityProcessingRequest,
EntityProcessingResult,
} from '../processing/types';
import {
BackstageCredentials,
PermissionsService,
} from '@backstage/backend-plugin-api';
export class AuthorizedValidationService {
constructor(
private readonly service: CatalogProcessingOrchestrator,
private readonly permissionApi: PermissionsService,
) {}
async process(
request: EntityProcessingRequest,
credentials: BackstageCredentials,
): Promise<EntityProcessingResult> {
const authorizeDecision = (
await this.permissionApi.authorize(
[
{
permission: catalogEntityValidatePermission,
},
],
{ credentials },
)
)[0];
if (authorizeDecision.result !== AuthorizeResult.ALLOW) {
throw new NotAllowedError();
}
return this.service.process(request);
}
}
@@ -56,6 +56,7 @@ import {
import { ConfigLocationEntityProvider } from '../modules/core/ConfigLocationEntityProvider';
import { DefaultLocationStore } from '../modules/core/DefaultLocationStore';
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
import { AuthorizedLocationAnalyzer } from './AuthorizedLocationAnalyzer';
import {
jsonPlaceholderResolver,
textPlaceholderResolver,
@@ -515,15 +516,7 @@ export class CatalogBuilder {
});
const integrations = ScmIntegrations.fromConfig(config);
const rulesEnforcer = DefaultCatalogRulesEnforcer.fromConfig(config);
const orchestrator = new DefaultCatalogProcessingOrchestrator({
processors,
integrations,
rulesEnforcer,
logger,
parser,
policy,
legacySingleProcessorValidation: this.legacySingleProcessorValidation,
});
const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog({
database: dbClient,
logger,
@@ -540,6 +533,16 @@ export class CatalogBuilder {
permissionsService = toPermissionEvaluator(permissions);
}
const orchestrator = new DefaultCatalogProcessingOrchestrator({
processors,
integrations,
rulesEnforcer,
logger,
parser,
policy,
legacySingleProcessorValidation: this.legacySingleProcessorValidation,
});
const entitiesCatalog = new AuthorizedEntitiesCatalog(
unauthorizedEntitiesCatalog,
permissionsService,
@@ -599,7 +602,10 @@ export class CatalogBuilder {
const locationAnalyzer =
this.locationAnalyzer ??
new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers);
new AuthorizedLocationAnalyzer(
new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers),
permissionsService,
);
const locationService = new AuthorizedLocationService(
new DefaultLocationService(locationStore, orchestrator, {
allowedLocationTypes: this.allowedLocationType,
@@ -622,6 +628,7 @@ export class CatalogBuilder {
permissionIntegrationRouter,
auth,
httpAuth,
permissionsService,
});
await connectEntityProviders(providerDatabase, entityProviders);
@@ -42,6 +42,7 @@ import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils';
import { Server } from 'http';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
import { PermissionsService } from '@backstage/backend-plugin-api';
describe('createRouter readonly disabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
@@ -50,6 +51,7 @@ describe('createRouter readonly disabled', () => {
let app: express.Express | Server;
let refreshService: RefreshService;
let locationAnalyzer: jest.Mocked<LocationAnalyzer>;
let permissionsService: jest.Mocked<PermissionsService>;
beforeAll(async () => {
entitiesCatalog = {
@@ -71,6 +73,11 @@ describe('createRouter readonly disabled', () => {
locationAnalyzer = {
analyzeLocation: jest.fn(),
};
permissionsService = {
authorize: jest.fn(),
authorizeConditional: jest.fn(),
};
refreshService = { refresh: jest.fn() };
orchestrator = { process: jest.fn() };
const router = await createRouter({
@@ -84,6 +91,7 @@ describe('createRouter readonly disabled', () => {
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
locationAnalyzer,
permissionsService: permissionsService,
});
app = wrapInOpenApiTestServer(express().use(router));
});
@@ -725,6 +733,12 @@ describe('createRouter readonly disabled', () => {
metadata: { name: 'n' },
};
permissionsService.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
orchestrator.process.mockResolvedValueOnce({
ok: true,
state: {},
@@ -765,6 +779,12 @@ describe('createRouter readonly disabled', () => {
metadata: { name: 'invalid*name' },
};
permissionsService.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
orchestrator.process.mockResolvedValueOnce({
ok: false,
errors: [new Error('Invalid entity name')],
@@ -802,6 +822,12 @@ describe('createRouter readonly disabled', () => {
metadata: { name: 'n' },
};
permissionsService.authorize.mockResolvedValueOnce([
{
result: AuthorizeResult.ALLOW,
},
]);
const response = await request(app)
.post('/validate-entity')
.send({ entity, location: null });
@@ -848,6 +874,7 @@ describe('createRouter readonly enabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
let app: express.Express;
let locationService: jest.Mocked<LocationService>;
let permissionsService: jest.Mocked<PermissionsService>;
beforeAll(async () => {
entitiesCatalog = {
@@ -877,6 +904,7 @@ describe('createRouter readonly enabled', () => {
permissionIntegrationRouter: express.Router(),
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
permissionsService: permissionsService,
});
app = express().use(router);
});
@@ -1046,6 +1074,7 @@ describe('NextRouter permissioning', () => {
let locationService: jest.Mocked<LocationService>;
let app: express.Express;
let refreshService: RefreshService;
let permissionsService: jest.Mocked<PermissionsService>;
const fakeRule = createPermissionRule({
name: 'FAKE_RULE',
@@ -1092,6 +1121,7 @@ describe('NextRouter permissioning', () => {
}),
auth: mockServices.auth(),
httpAuth: mockServices.httpAuth(),
permissionsService: permissionsService,
});
app = express().use(router);
});
@@ -53,8 +53,10 @@ import {
HttpAuthService,
LoggerService,
SchedulerService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
import { AuthorizedValidationService } from './AuthorizedValidationService';
/**
* Options used by {@link createRouter}.
@@ -74,6 +76,7 @@ export interface RouterOptions {
permissionIntegrationRouter?: express.Router;
auth: AuthService;
httpAuth: HttpAuthService;
permissionsService: PermissionsService;
}
/**
@@ -98,6 +101,7 @@ export async function createRouter(
config,
logger,
permissionIntegrationRouter,
permissionsService,
auth,
httpAuth,
} = options;
@@ -301,9 +305,13 @@ export async function createRouter(
location: locationInput,
catalogFilename: z.string().optional(),
});
const credentials = await httpAuth.credentials(req);
const parsedBody = schema.parse(body);
try {
const output = await locationAnalyzer.analyzeLocation(parsedBody);
const output = await locationAnalyzer.analyzeLocation(
parsedBody,
credentials,
);
res.status(200).json(output);
} catch (err) {
if (
@@ -342,19 +350,27 @@ export async function createRouter(
});
}
const processingResult = await orchestrator.process({
entity: {
...entity,
metadata: {
...entity.metadata,
annotations: {
[ANNOTATION_LOCATION]: body.location,
[ANNOTATION_ORIGIN_LOCATION]: body.location,
...entity.metadata.annotations,
const credentials = await httpAuth.credentials(req);
const authorizedValidationService = new AuthorizedValidationService(
orchestrator,
permissionsService,
);
const processingResult = await authorizedValidationService.process(
{
entity: {
...entity,
metadata: {
...entity.metadata,
annotations: {
[ANNOTATION_LOCATION]: body.location,
[ANNOTATION_ORIGIN_LOCATION]: body.location,
...entity.metadata.annotations,
},
},
},
},
});
credentials,
);
if (!processingResult.ok)
res.status(400).json({
@@ -23,6 +23,12 @@ export const catalogEntityReadPermission: ResourcePermission<'catalog-entity'>;
// @alpha
export const catalogEntityRefreshPermission: ResourcePermission<'catalog-entity'>;
// @alpha
export const catalogEntityValidatePermission: BasicPermission;
// @alpha
export const catalogLocationAnalyzePermission: BasicPermission;
// @alpha
export const catalogLocationCreatePermission: BasicPermission;
+2
View File
@@ -20,9 +20,11 @@ export {
catalogEntityCreatePermission,
catalogEntityDeletePermission,
catalogEntityRefreshPermission,
catalogEntityValidatePermission,
catalogLocationReadPermission,
catalogLocationCreatePermission,
catalogLocationDeletePermission,
catalogLocationAnalyzePermission,
catalogPermissions,
} from './permissions';
export type { CatalogEntityPermission } from './permissions';
+20
View File
@@ -91,6 +91,15 @@ export const catalogEntityRefreshPermission = createPermission({
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
});
/**
* This permission is used to authorize validating catalog entities.
* @alpha
*/
export const catalogEntityValidatePermission = createPermission({
name: 'catalog.entity.validate',
attributes: {},
});
/**
* This permission is used to designate actions that involve reading one or more
* locations from the catalog.
@@ -118,6 +127,15 @@ export const catalogLocationCreatePermission = createPermission({
},
});
/**
* This permission is used to authorize analyzing catalog locations.
* @alpha
*/
export const catalogLocationAnalyzePermission = createPermission({
name: 'catalog.location.analyze',
attributes: {},
});
/**
* This permission is used to designate actions that involve deleting locations
* from the catalog.
@@ -139,7 +157,9 @@ export const catalogPermissions = [
catalogEntityCreatePermission,
catalogEntityDeletePermission,
catalogEntityRefreshPermission,
catalogEntityValidatePermission,
catalogLocationReadPermission,
catalogLocationCreatePermission,
catalogLocationDeletePermission,
catalogLocationAnalyzePermission,
];
+2
View File
@@ -8,6 +8,7 @@
import { AnalyzeLocationExistingEntity } from '@backstage/plugin-catalog-common';
import { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/types';
@@ -172,6 +173,7 @@ export type EntityRelationSpec = {
export type LocationAnalyzer = {
analyzeLocation(
location: AnalyzeLocationRequest,
credentials: BackstageCredentials,
): Promise<AnalyzeLocationResponse>;
};
@@ -22,6 +22,7 @@ import {
} from '@backstage/plugin-catalog-common';
import { JsonValue } from '@backstage/types';
import { CatalogProcessorEmit } from '../api';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
/**
* Entities that are not yet processed.
@@ -66,6 +67,7 @@ export type LocationAnalyzer = {
*/
analyzeLocation(
location: AnalyzeLocationRequest,
credentials: BackstageCredentials,
): Promise<AnalyzeLocationResponse>;
};