Add authorization to entities get endpoints in catalog-backend. (#8711)
* Add authorization to entities get endpoints in catalog-backend. The delete endpoint and the ancestry endpoint, as well as the rest of the endpoints in catalog-backend will be covered in later PRs. Signed-off-by: Joon Park <joonp@spotify.com> * Refactor permissioning into AuthorizedNextEntitiesCatalog Signed-off-by: Joon Park <joonp@spotify.com> * Create integration router in builder Signed-off-by: Joon Park <joonp@spotify.com> * Fix test authorization strings Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Add authorization to catalog-backend entities GET endpoints
|
||||
@@ -849,6 +849,7 @@ export type EntitiesRequest = {
|
||||
filter?: EntityFilter;
|
||||
fields?: (entity: Entity) => Entity;
|
||||
pagination?: EntityPagination;
|
||||
authorizationToken?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntitiesResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -1358,7 +1359,7 @@ export interface NextRouterOptions {
|
||||
// (undocumented)
|
||||
logger: Logger_2;
|
||||
// (undocumented)
|
||||
permissionRules?: PermissionRule<Entity, EntitiesSearchFilter, unknown[]>[];
|
||||
permissionIntegrationRouter?: express.Router;
|
||||
// (undocumented)
|
||||
refreshService?: RefreshService;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export type EntitiesRequest = {
|
||||
filter?: EntityFilter;
|
||||
fields?: (entity: Entity) => Entity;
|
||||
pagination?: EntityPagination;
|
||||
authorizationToken?: string;
|
||||
};
|
||||
|
||||
export type EntitiesResponse = {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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 { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { createConditionTransformer } from '@backstage/plugin-permission-node';
|
||||
import { isEntityKind } from '../permissions/rules/isEntityKind';
|
||||
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
|
||||
|
||||
describe('AuthorizedEntitiesCatalog', () => {
|
||||
const fakeCatalog = {
|
||||
entities: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
};
|
||||
const fakePermissionApi = {
|
||||
authorize: jest.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('entities', () => {
|
||||
it('returns empty response on DENY', async () => {
|
||||
fakePermissionApi.authorize.mockResolvedValue([
|
||||
{ result: AuthorizeResult.DENY },
|
||||
]);
|
||||
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer([]),
|
||||
);
|
||||
|
||||
expect(
|
||||
await catalog.entities({
|
||||
authorizationToken: 'abcd',
|
||||
}),
|
||||
).toEqual({
|
||||
entities: [],
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls underlying catalog method with correct filter on CONDITIONAL', async () => {
|
||||
fakePermissionApi.authorize.mockResolvedValue([
|
||||
{
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
|
||||
},
|
||||
]);
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer([isEntityKind]),
|
||||
);
|
||||
|
||||
await catalog.entities({ authorizationToken: 'abcd' });
|
||||
|
||||
expect(fakeCatalog.entities).toHaveBeenCalledWith({
|
||||
authorizationToken: 'abcd',
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('calls underlying catalog method on ALLOW', async () => {
|
||||
fakePermissionApi.authorize.mockResolvedValue([
|
||||
{ result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
const catalog = new AuthorizedEntitiesCatalog(
|
||||
fakeCatalog,
|
||||
fakePermissionApi,
|
||||
createConditionTransformer([]),
|
||||
);
|
||||
|
||||
await catalog.entities({ authorizationToken: 'abcd' });
|
||||
|
||||
expect(fakeCatalog.entities).toHaveBeenCalledWith({
|
||||
authorizationToken: 'abcd',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 { catalogEntityReadPermission } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionAuthorizer,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { ConditionTransformer } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
EntitiesCatalog,
|
||||
EntitiesRequest,
|
||||
EntitiesResponse,
|
||||
EntityAncestryResponse,
|
||||
EntityFilter,
|
||||
} from '../catalog/types';
|
||||
|
||||
export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
constructor(
|
||||
private readonly entitiesCatalog: EntitiesCatalog,
|
||||
private readonly permissionApi: PermissionAuthorizer,
|
||||
private readonly transformConditions: ConditionTransformer<EntityFilter>,
|
||||
) {}
|
||||
|
||||
async entities(request?: EntitiesRequest): Promise<EntitiesResponse> {
|
||||
const authorizeResponse = (
|
||||
await this.permissionApi.authorize(
|
||||
[{ permission: catalogEntityReadPermission }],
|
||||
{ token: request?.authorizationToken },
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (authorizeResponse.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
entities: [],
|
||||
pageInfo: { hasNextPage: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (authorizeResponse.result === AuthorizeResult.CONDITIONAL) {
|
||||
const permissionFilter: EntityFilter = this.transformConditions(
|
||||
authorizeResponse.conditions,
|
||||
);
|
||||
return this.entitiesCatalog.entities({
|
||||
...request,
|
||||
filter: request?.filter
|
||||
? { allOf: [permissionFilter, request.filter] }
|
||||
: permissionFilter,
|
||||
});
|
||||
}
|
||||
|
||||
return this.entitiesCatalog.entities(request);
|
||||
}
|
||||
|
||||
removeEntityByUid(uid: string): Promise<void> {
|
||||
// TODO: Implement permissioning
|
||||
return this.entitiesCatalog.removeEntityByUid(uid);
|
||||
}
|
||||
|
||||
entityAncestry(entityRef: string): Promise<EntityAncestryResponse> {
|
||||
// TODO: Implement permissioning
|
||||
return this.entitiesCatalog.entityAncestry(entityRef);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
FieldFormatEntityPolicy,
|
||||
makeValidator,
|
||||
NoForeignRootFieldsEntityPolicy,
|
||||
parseEntityRef,
|
||||
SchemaValidEntityPolicy,
|
||||
Validators,
|
||||
} from '@backstage/catalog-model';
|
||||
@@ -90,7 +91,14 @@ import { LocationService } from './types';
|
||||
import { connectEntityProviders } from '../processing/connectEntityProviders';
|
||||
import { permissionRules as catalogPermissionRules } from '../permissions/rules';
|
||||
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
|
||||
import { PermissionRule } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
PermissionRule,
|
||||
createConditionTransformer,
|
||||
createPermissionIntegrationRouter,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
|
||||
import { basicEntityFilter } from './request/basicEntityFilter';
|
||||
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
|
||||
|
||||
export type CatalogEnvironment = {
|
||||
logger: Logger;
|
||||
@@ -399,7 +407,29 @@ export class NextCatalogBuilder {
|
||||
parser,
|
||||
policy,
|
||||
});
|
||||
const entitiesCatalog = new NextEntitiesCatalog(dbClient);
|
||||
const unauthorizedEntitiesCatalog = new NextEntitiesCatalog(dbClient);
|
||||
const entitiesCatalog = new AuthorizedEntitiesCatalog(
|
||||
unauthorizedEntitiesCatalog,
|
||||
permissions,
|
||||
createConditionTransformer(this.permissionRules),
|
||||
);
|
||||
const permissionIntegrationRouter = createPermissionIntegrationRouter({
|
||||
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
getResource: async (resourceRef: string) => {
|
||||
const parsed = parseEntityRef(resourceRef);
|
||||
|
||||
const { entities } = await unauthorizedEntitiesCatalog.entities({
|
||||
filter: basicEntityFilter({
|
||||
kind: parsed.kind,
|
||||
'metadata.namespace': parsed.namespace,
|
||||
'metadata.name': parsed.name,
|
||||
}),
|
||||
});
|
||||
|
||||
return entities[0];
|
||||
},
|
||||
rules: this.permissionRules,
|
||||
});
|
||||
const stitcher = new Stitcher(dbClient, logger);
|
||||
|
||||
const locationStore = new DefaultLocationStore(dbClient);
|
||||
@@ -435,7 +465,7 @@ export class NextCatalogBuilder {
|
||||
refreshService,
|
||||
logger,
|
||||
config,
|
||||
permissionRules: this.permissionRules,
|
||||
permissionIntegrationRouter,
|
||||
});
|
||||
|
||||
await connectEntityProviders(processingDatabase, entityProviders);
|
||||
|
||||
@@ -24,7 +24,6 @@ import { EntitiesCatalog } from '../catalog';
|
||||
import { LocationService, RefreshService } from './types';
|
||||
import { basicEntityFilter } from './request';
|
||||
import { createNextRouter } from './NextRouter';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
|
||||
describe('createNextRouter readonly disabled', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
@@ -52,7 +51,7 @@ describe('createNextRouter readonly disabled', () => {
|
||||
logger: getVoidLogger(),
|
||||
refreshService,
|
||||
config: new ConfigReader(undefined),
|
||||
permissionRules: [],
|
||||
permissionIntegrationRouter: express.Router(),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
@@ -340,7 +339,7 @@ describe('createNextRouter readonly enabled', () => {
|
||||
readonly: true,
|
||||
},
|
||||
}),
|
||||
permissionRules: [],
|
||||
permissionIntegrationRouter: express.Router(),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
@@ -434,72 +433,3 @@ describe('createNextRouter readonly enabled', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextRouter permissioning', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
let locationService: jest.Mocked<LocationService>;
|
||||
let app: express.Express;
|
||||
let refreshService: RefreshService;
|
||||
|
||||
const fakeRule = {
|
||||
name: 'FAKE_RULE',
|
||||
description: 'fake rule',
|
||||
apply: () => true,
|
||||
toQuery: () => ({ key: '', values: [] }),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
batchAddOrUpdateEntities: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
};
|
||||
locationService = {
|
||||
getLocation: jest.fn(),
|
||||
createLocation: jest.fn(),
|
||||
listLocations: jest.fn(),
|
||||
deleteLocation: jest.fn(),
|
||||
};
|
||||
refreshService = { refresh: jest.fn() };
|
||||
const router = await createNextRouter({
|
||||
entitiesCatalog,
|
||||
locationService,
|
||||
logger: getVoidLogger(),
|
||||
refreshService,
|
||||
config: new ConfigReader(undefined),
|
||||
permissionRules: [fakeRule],
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('accepts and evaluates conditions at the apply-conditions endpoint', async () => {
|
||||
const spideySense: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'spidey-sense',
|
||||
},
|
||||
};
|
||||
entitiesCatalog.entities.mockResolvedValueOnce({
|
||||
entities: [spideySense],
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
|
||||
const requestBody = {
|
||||
resourceType: 'catalog-entity',
|
||||
resourceRef: 'component:default/spidey-sense',
|
||||
conditions: { rule: 'FAKE_RULE', params: ['user:default/spiderman'] },
|
||||
};
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send(requestBody);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ result: AuthorizeResult.ALLOW });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,23 +17,16 @@
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import {
|
||||
analyzeLocationSchema,
|
||||
Entity,
|
||||
locationSpecSchema,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
createPermissionIntegrationRouter,
|
||||
PermissionRule,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import yn from 'yn';
|
||||
import { EntitiesCatalog, EntitiesSearchFilter } from '../catalog';
|
||||
import { EntitiesCatalog } from '../catalog';
|
||||
import { LocationAnalyzer } from '../ingestion/types';
|
||||
import {
|
||||
basicEntityFilter,
|
||||
@@ -51,7 +44,7 @@ export interface NextRouterOptions {
|
||||
refreshService?: RefreshService;
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
permissionRules?: PermissionRule<Entity, EntitiesSearchFilter, unknown[]>[];
|
||||
permissionIntegrationRouter?: express.Router;
|
||||
}
|
||||
|
||||
export async function createNextRouter(
|
||||
@@ -64,7 +57,7 @@ export async function createNextRouter(
|
||||
refreshService,
|
||||
config,
|
||||
logger,
|
||||
permissionRules,
|
||||
permissionIntegrationRouter,
|
||||
} = options;
|
||||
|
||||
const router = Router();
|
||||
@@ -88,21 +81,18 @@ export async function createNextRouter(
|
||||
});
|
||||
}
|
||||
|
||||
if (permissionIntegrationRouter) {
|
||||
router.use(permissionIntegrationRouter);
|
||||
}
|
||||
|
||||
if (entitiesCatalog) {
|
||||
router
|
||||
.use(
|
||||
createPermissionIntegrationRouter({
|
||||
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
getResource: resourceRef =>
|
||||
getEntityResource(resourceRef, entitiesCatalog),
|
||||
rules: permissionRules ?? [],
|
||||
}),
|
||||
)
|
||||
.get('/entities', async (req, res) => {
|
||||
const { entities, pageInfo } = await entitiesCatalog.entities({
|
||||
filter: parseEntityFilterParams(req.query),
|
||||
fields: parseEntityTransformParams(req.query),
|
||||
pagination: parseEntityPaginationParams(req.query),
|
||||
authorizationToken: getBearerToken(req.header('authorization')),
|
||||
});
|
||||
|
||||
// Add a Link header to the next page
|
||||
@@ -120,6 +110,7 @@ export async function createNextRouter(
|
||||
const { uid } = req.params;
|
||||
const { entities } = await entitiesCatalog.entities({
|
||||
filter: basicEntityFilter({ 'metadata.uid': uid }),
|
||||
authorizationToken: getBearerToken(req.header('authorization')),
|
||||
});
|
||||
if (!entities.length) {
|
||||
throw new NotFoundError(`No entity with uid ${uid}`);
|
||||
@@ -139,6 +130,7 @@ export async function createNextRouter(
|
||||
'metadata.namespace': namespace,
|
||||
'metadata.name': name,
|
||||
}),
|
||||
authorizationToken: getBearerToken(req.header('authorization')),
|
||||
});
|
||||
if (!entities.length) {
|
||||
throw new NotFoundError(
|
||||
@@ -204,23 +196,6 @@ export async function createNextRouter(
|
||||
return router;
|
||||
}
|
||||
|
||||
async function getEntityResource(
|
||||
resourceRef: string,
|
||||
entitiesCatalog: EntitiesCatalog,
|
||||
): Promise<Entity | undefined> {
|
||||
const parsed = parseEntityRef(resourceRef);
|
||||
|
||||
const { entities } = await entitiesCatalog.entities({
|
||||
filter: basicEntityFilter({
|
||||
kind: parsed.kind,
|
||||
'metadata.namespace': parsed.namespace,
|
||||
'metadata.name': parsed.name,
|
||||
}),
|
||||
});
|
||||
|
||||
return entities[0];
|
||||
}
|
||||
|
||||
function getBearerToken(
|
||||
authorizationHeader: string | undefined,
|
||||
): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user