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:
Joon Park
2022-01-13 10:59:13 +00:00
committed by GitHub
parent 4578d5c347
commit 0a6c68582a
8 changed files with 226 additions and 111 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Add authorization to catalog-backend entities GET endpoints
+2 -1
View File
@@ -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 {