Integrate permissions into catalog-backend location endpoints (#8887)

Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
Joon Park
2022-01-18 15:55:49 +00:00
committed by GitHub
parent 79a7d37964
commit 7e38acaa9e
10 changed files with 303 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-common': patch
---
Remove Catalog Location resource type
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Integrate permissions into catalog-backend location endpoints
+18 -3
View File
@@ -884,17 +884,32 @@ export interface LocationService {
createLocation(
spec: LocationSpec,
dryRun: boolean,
options?: {
authorizationToken?: string;
},
): Promise<{
location: Location_2;
entities: Entity[];
exists?: boolean;
}>;
// (undocumented)
deleteLocation(id: string): Promise<void>;
deleteLocation(
id: string,
options?: {
authorizationToken?: string;
},
): Promise<void>;
// (undocumented)
getLocation(id: string): Promise<Location_2>;
getLocation(
id: string,
options?: {
authorizationToken?: string;
},
): Promise<Location_2>;
// (undocumented)
listLocations(): Promise<Location_2[]>;
listLocations(options?: {
authorizationToken?: string;
}): Promise<Location_2[]>;
}
// Warning: (ae-missing-release-tag) "LocationStore" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -0,0 +1,146 @@
/*
* Copyright 2022 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, NotFoundError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { AuthorizedLocationService } from './AuthorizedLocationService';
describe('AuthorizedLocationService', () => {
const fakeLocationService = {
createLocation: jest.fn(),
listLocations: jest.fn(),
getLocation: jest.fn(),
deleteLocation: jest.fn(),
};
const fakePermissionApi = {
authorize: jest.fn(),
};
const mockAllow = () => {
fakePermissionApi.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
]);
};
const mockDeny = () => {
fakePermissionApi.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.DENY },
]);
};
const createService = () =>
new AuthorizedLocationService(fakeLocationService, fakePermissionApi);
afterEach(() => {
jest.resetAllMocks();
});
describe('createLocation', () => {
it('calls underlying service to create location on ALLOW', async () => {
mockAllow();
const service = createService();
const spec = { type: 'type', target: 'target' };
await service.createLocation(spec, false, {
authorizationToken: 'Bearer authtoken',
});
expect(fakeLocationService.createLocation).toHaveBeenCalledWith(
spec,
false,
);
});
it('throws Error on DENY', async () => {
mockDeny();
const service = createService();
const spec = { type: 'type', target: 'target' };
await expect(() =>
service.createLocation(spec, false, {
authorizationToken: 'Bearer authtoken',
}),
).rejects.toThrowError(NotAllowedError);
});
});
describe('listLocations', () => {
it('calls underlying service to list locations on ALLOW', async () => {
mockAllow();
const service = createService();
await service.listLocations({ authorizationToken: 'Bearer authtoken' });
expect(fakeLocationService.listLocations).toHaveBeenCalled();
});
it('returns empty array on DENY', async () => {
mockDeny();
const service = createService();
const locations = await service.listLocations({
authorizationToken: 'Bearer authtoken',
});
expect(locations).toEqual([]);
});
});
describe('getLocation', () => {
it('calls underlying service to get location on ALLOW', async () => {
mockAllow();
const service = createService();
await service.getLocation('id', {
authorizationToken: 'Bearer authtoken',
});
expect(fakeLocationService.getLocation).toHaveBeenCalledWith('id');
});
it('throws error on DENY', async () => {
mockDeny();
const service = createService();
await expect(() =>
service.getLocation('id', { authorizationToken: 'Bearer authtoken' }),
).rejects.toThrowError(NotFoundError);
});
});
describe('deleteLocation', () => {
it('calls underlying service to delete location on ALLOW', async () => {
mockAllow();
const service = createService();
await service.deleteLocation('id', {
authorizationToken: 'Bearer authtoken',
});
expect(fakeLocationService.deleteLocation).toHaveBeenCalledWith('id');
});
it('throws error on DENY', async () => {
mockDeny();
const service = createService();
await expect(() =>
service.deleteLocation('id', {
authorizationToken: 'Bearer authtoken',
}),
).rejects.toThrowError(NotAllowedError);
});
});
});
@@ -0,0 +1,113 @@
/*
* Copyright 2022 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 { LocationSpec, Location, Entity } from '@backstage/catalog-model';
import { NotAllowedError, NotFoundError } from '@backstage/errors';
import {
catalogLocationCreatePermission,
catalogLocationDeletePermission,
catalogLocationReadPermission,
} from '@backstage/plugin-catalog-common';
import {
AuthorizeResult,
PermissionAuthorizer,
} from '@backstage/plugin-permission-common';
import { LocationService } from './types';
export class AuthorizedLocationService implements LocationService {
constructor(
private readonly locationService: LocationService,
private readonly permissionApi: PermissionAuthorizer,
) {}
async createLocation(
spec: LocationSpec,
dryRun: boolean,
options?: {
authorizationToken?: string;
},
): Promise<{
location: Location;
entities: Entity[];
exists?: boolean | undefined;
}> {
const authorizationResponse = (
await this.permissionApi.authorize(
[{ permission: catalogLocationCreatePermission }],
{ token: options?.authorizationToken },
)
)[0];
if (authorizationResponse.result === AuthorizeResult.DENY) {
throw new NotAllowedError();
}
return this.locationService.createLocation(spec, dryRun);
}
async listLocations(options?: {
authorizationToken?: string;
}): Promise<Location[]> {
const authorizationResponse = (
await this.permissionApi.authorize(
[{ permission: catalogLocationReadPermission }],
{ token: options?.authorizationToken },
)
)[0];
if (authorizationResponse.result === AuthorizeResult.DENY) {
return [];
}
return this.locationService.listLocations();
}
async getLocation(
id: string,
options?: { authorizationToken?: string },
): Promise<Location> {
const authorizationResponse = (
await this.permissionApi.authorize(
[{ permission: catalogLocationReadPermission }],
{ token: options?.authorizationToken },
)
)[0];
if (authorizationResponse.result === AuthorizeResult.DENY) {
throw new NotFoundError(`Found no location with ID ${id}`);
}
return this.locationService.getLocation(id);
}
async deleteLocation(
id: string,
options?: { authorizationToken?: string },
): Promise<void> {
const authorizationResponse = (
await this.permissionApi.authorize(
[{ permission: catalogLocationDeletePermission }],
{ token: options?.authorizationToken },
)
)[0];
if (authorizationResponse.result === AuthorizeResult.DENY) {
throw new NotAllowedError();
}
return this.locationService.deleteLocation(id);
}
}
@@ -95,6 +95,7 @@ import {
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
import { basicEntityFilter } from './request/basicEntityFilter';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { AuthorizedLocationService } from './AuthorizedLocationService';
export type CatalogEnvironment = {
logger: Logger;
@@ -455,9 +456,9 @@ export class CatalogBuilder {
const locationAnalyzer =
this.locationAnalyzer ?? new RepoLocationAnalyzer(logger, integrations);
const locationService = new DefaultLocationService(
locationStore,
orchestrator,
const locationService = new AuthorizedLocationService(
new DefaultLocationService(locationStore, orchestrator),
permissions,
);
const refreshService = new AuthorizedRefreshService(
new DefaultRefreshService({ database: processingDatabase }),
+12 -3
View File
@@ -20,10 +20,19 @@ export interface LocationService {
createLocation(
spec: LocationSpec,
dryRun: boolean,
options?: {
authorizationToken?: string;
},
): Promise<{ location: Location; entities: Entity[]; exists?: boolean }>;
listLocations(): Promise<Location[]>;
getLocation(id: string): Promise<Location>;
deleteLocation(id: string): Promise<void>;
listLocations(options?: { authorizationToken?: string }): Promise<Location[]>;
getLocation(
id: string,
options?: { authorizationToken?: string },
): Promise<Location>;
deleteLocation(
id: string,
options?: { authorizationToken?: string },
): Promise<void>;
}
/**
-3
View File
@@ -25,7 +25,4 @@ export const catalogLocationReadPermission: Permission;
// @public (undocumented)
export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity';
// @public (undocumented)
export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location';
```
-1
View File
@@ -23,7 +23,6 @@
export {
RESOURCE_TYPE_CATALOG_ENTITY,
RESOURCE_TYPE_CATALOG_LOCATION,
catalogEntityReadPermission,
catalogEntityDeletePermission,
catalogEntityRefreshPermission,
@@ -22,12 +22,6 @@ import { Permission } from '@backstage/plugin-permission-common';
*/
export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity';
/**
* {@link https://backstage.io/docs/features/software-catalog/descriptor-format#kind-location}
* @public
*/
export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location';
/**
* This permission is used to authorize actions that involve reading one or more
* entities from the catalog.
@@ -83,7 +77,6 @@ export const catalogLocationReadPermission: Permission = {
attributes: {
action: 'read',
},
resourceType: RESOURCE_TYPE_CATALOG_LOCATION,
};
/**
@@ -96,7 +89,6 @@ export const catalogLocationCreatePermission: Permission = {
attributes: {
action: 'create',
},
resourceType: RESOURCE_TYPE_CATALOG_LOCATION,
};
/**
@@ -109,5 +101,4 @@ export const catalogLocationDeletePermission: Permission = {
attributes: {
action: 'delete',
},
resourceType: RESOURCE_TYPE_CATALOG_LOCATION,
};