Add apply-conditions endpoint for evaluating conditional permissions (#8675)
Add apply-conditions endpoint for evaluating conditional permissions in catalog backend. Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Add apply-conditions endpoint for evaluating conditional permissions in catalog backend.
|
||||
@@ -1309,6 +1309,7 @@ export class NextCatalogBuilder {
|
||||
addEntityPolicy(...policies: EntityPolicy[]): NextCatalogBuilder;
|
||||
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
addEntityProvider(...providers: EntityProvider[]): NextCatalogBuilder;
|
||||
addPermissionRules(...permissionRules: CatalogPermissionRule[]): void;
|
||||
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
|
||||
addProcessor(...processors: CatalogProcessor[]): NextCatalogBuilder;
|
||||
build(): Promise<{
|
||||
@@ -1356,6 +1357,8 @@ export interface NextRouterOptions {
|
||||
// (undocumented)
|
||||
logger: Logger_2;
|
||||
// (undocumented)
|
||||
permissionRules?: CatalogPermissionRule[];
|
||||
// (undocumented)
|
||||
refreshService?: RefreshService;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.10.1",
|
||||
"@backstage/catalog-client": "^0.5.3",
|
||||
"@backstage/plugin-catalog-common": "^0.1.0",
|
||||
"@backstage/catalog-model": "^0.9.8",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.5",
|
||||
@@ -65,6 +66,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "^0.1.12",
|
||||
"@backstage/cli": "^0.10.4",
|
||||
"@backstage/plugin-permission-common": "^0.3.0",
|
||||
"@backstage/test-utils": "^0.2.1",
|
||||
"@types/core-js": "^2.5.4",
|
||||
"@types/git-url-parse": "^9.0.0",
|
||||
|
||||
@@ -82,6 +82,8 @@ import { Config } from '@backstage/config';
|
||||
import { Logger } from 'winston';
|
||||
import { LocationService } from './types';
|
||||
import { connectEntityProviders } from '../processing/connectEntityProviders';
|
||||
import { CatalogPermissionRule } from '../permissions/types';
|
||||
import { permissionRules as catalogPermissionRules } from '../permissions/rules';
|
||||
|
||||
export type CatalogEnvironment = {
|
||||
logger: Logger;
|
||||
@@ -125,6 +127,7 @@ export class NextCatalogBuilder {
|
||||
maxSeconds: 150,
|
||||
});
|
||||
private locationAnalyzer: LocationAnalyzer | undefined = undefined;
|
||||
private permissionRules: CatalogPermissionRule[];
|
||||
|
||||
constructor(env: CatalogEnvironment) {
|
||||
this.env = env;
|
||||
@@ -136,6 +139,7 @@ export class NextCatalogBuilder {
|
||||
this.processors = [];
|
||||
this.processorsReplace = false;
|
||||
this.parser = undefined;
|
||||
this.permissionRules = Object.values(catalogPermissionRules);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,6 +321,17 @@ export class NextCatalogBuilder {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds additional permission rules. Permission rules are used to evaluate
|
||||
* catalog resources against queries. See
|
||||
* {@link @backstage/plugin-permission-node#PermissionRule}.
|
||||
*
|
||||
* @param permissionRules - Additional permission rules
|
||||
*/
|
||||
addPermissionRules(...permissionRules: CatalogPermissionRule[]) {
|
||||
this.permissionRules.push(...permissionRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up and returns all of the component parts of the catalog
|
||||
*/
|
||||
@@ -393,6 +408,7 @@ export class NextCatalogBuilder {
|
||||
refreshService,
|
||||
logger,
|
||||
config,
|
||||
permissionRules: this.permissionRules,
|
||||
});
|
||||
|
||||
await connectEntityProviders(processingDatabase, entityProviders);
|
||||
|
||||
@@ -24,6 +24,7 @@ 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>;
|
||||
@@ -51,6 +52,7 @@ describe('createNextRouter readonly disabled', () => {
|
||||
logger: getVoidLogger(),
|
||||
refreshService,
|
||||
config: new ConfigReader(undefined),
|
||||
permissionRules: [],
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
@@ -336,6 +338,7 @@ describe('createNextRouter readonly enabled', () => {
|
||||
readonly: true,
|
||||
},
|
||||
}),
|
||||
permissionRules: [],
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
@@ -429,3 +432,72 @@ 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,17 +17,22 @@
|
||||
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 } 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 } from '../catalog';
|
||||
import { LocationAnalyzer } from '../ingestion/types';
|
||||
import { CatalogPermissionRule } from '../permissions/types';
|
||||
import {
|
||||
basicEntityFilter,
|
||||
parseEntityFilterParams,
|
||||
@@ -44,6 +49,7 @@ export interface NextRouterOptions {
|
||||
refreshService?: RefreshService;
|
||||
logger: Logger;
|
||||
config: Config;
|
||||
permissionRules?: CatalogPermissionRule[];
|
||||
}
|
||||
|
||||
export async function createNextRouter(
|
||||
@@ -56,6 +62,7 @@ export async function createNextRouter(
|
||||
refreshService,
|
||||
config,
|
||||
logger,
|
||||
permissionRules,
|
||||
} = options;
|
||||
|
||||
const router = Router();
|
||||
@@ -77,6 +84,14 @@ export async function createNextRouter(
|
||||
|
||||
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),
|
||||
@@ -182,3 +197,20 @@ export async function createNextRouter(
|
||||
router.use(errorHandler());
|
||||
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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user