Integrate permissions into catalog-backend location endpoints (#8887)
Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-common': patch
|
||||
---
|
||||
|
||||
Remove Catalog Location resource type
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Integrate permissions into catalog-backend location endpoints
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user