Merge pull request #10397 from backstage/permission-evaluator-split-permission-client

Split PermissionClient#authorize
This commit is contained in:
Vincenzo Scamporlino
2022-04-08 13:26:08 +02:00
committed by GitHub
38 changed files with 939 additions and 288 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Use new `PermissionEvaluator#authorizeConditional` method when retrieving permission conditions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Use `PermissionEvaluator` instead of `PermissionAuthorizer`, which is now deprecated.
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-permission-common': patch
---
Added `PermissionEvaluator`, which will replace the existing `PermissionAuthorizer` interface. This new interface provides stronger type safety and validation by splitting `PermissionAuthorizer.authorize()` into two methods:
- `authorize()`: Used when the caller requires a definitive decision.
- `authorizeConditional()`: Used when the caller can optimize the evaluation of any conditional decisions. For example, a plugin backend may want to use conditions in a database query instead of evaluating each resource in memory.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': minor
---
**BREAKING:** `ServerPermissionClient` now implements `PermissionEvaluator`, which moves out the capabilities for evaluating conditional decisions from `authorize()` to `authorizeConditional()` method.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-jenkins-backend': patch
---
Use `PermissionEvaluator` instead of `PermissionAuthorizer`, which is now deprecated.
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/create-app': patch
---
Accept `PermissionEvaluator` instead of the deprecated `PermissionAuthorizer`.
Apply the following to `packages/backend/src/types.ts`:
```diff
- import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
+ import { PermissionEvaluator } from '@backstage/plugin-permission-common';
export type PluginEnvironment = {
...
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
scheduler: PluginTaskScheduler;
- permissions: PermissionAuthorizer;
+ permissions: PermissionEvaluator;
};
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-react': patch
---
**BREAKING:** Make `IdentityPermissionApi#authorize` typing more strict, using `AuthorizePermissionRequest` and `AuthorizePermissionResponse`.
+5 -2
View File
@@ -23,8 +23,11 @@ import {
TokenManager,
UrlReader,
} from '@backstage/backend-common';
import { ServerPermissionClient } from '@backstage/plugin-permission-node';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import {
PermissionAuthorizer,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
export type PluginEnvironment = {
logger: Logger;
@@ -34,6 +37,6 @@ export type PluginEnvironment = {
reader: UrlReader;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
permissions: ServerPermissionClient;
permissions: PermissionEvaluator | PermissionAuthorizer;
scheduler: PluginTaskScheduler;
};
@@ -8,7 +8,7 @@ import {
UrlReader,
} from '@backstage/backend-common';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
export type PluginEnvironment = {
logger: Logger;
@@ -19,5 +19,5 @@ export type PluginEnvironment = {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
scheduler: PluginTaskScheduler;
permissions: PermissionAuthorizer;
permissions: PermissionEvaluator;
};
+2 -1
View File
@@ -22,6 +22,7 @@ import { Permission } from '@backstage/plugin-permission-common';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { PermissionRule } from '@backstage/plugin-permission-node';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
@@ -181,7 +182,7 @@ export type CatalogEnvironment = {
database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
permissions: PermissionAuthorizer;
permissions: PermissionEvaluator | PermissionAuthorizer;
};
// @alpha
@@ -30,6 +30,7 @@ describe('AuthorizedEntitiesCatalog', () => {
};
const fakePermissionApi = {
authorize: jest.fn(),
authorizeConditional: jest.fn(),
};
const createCatalog = (...rules: CatalogPermissionRule[]) =>
@@ -45,7 +46,7 @@ describe('AuthorizedEntitiesCatalog', () => {
describe('entities', () => {
it('returns empty response on DENY', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const catalog = createCatalog();
@@ -61,7 +62,7 @@ describe('AuthorizedEntitiesCatalog', () => {
});
it('calls underlying catalog method with correct filter on CONDITIONAL', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
@@ -78,7 +79,7 @@ describe('AuthorizedEntitiesCatalog', () => {
});
it('calls underlying catalog method on ALLOW', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const catalog = createCatalog();
@@ -98,7 +99,7 @@ describe('AuthorizedEntitiesCatalog', () => {
{ kind: 'component', namespace: 'default', name: 'my-component' },
],
});
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const catalog = new AuthorizedEntitiesCatalog(
@@ -113,7 +114,7 @@ describe('AuthorizedEntitiesCatalog', () => {
});
it('throws error on CONDITIONAL authorization that evaluates to 0 entities', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
@@ -132,7 +133,7 @@ describe('AuthorizedEntitiesCatalog', () => {
});
it('calls underlying catalog method on CONDITIONAL authorization that evaluates to nonzero entities', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
@@ -158,7 +159,7 @@ describe('AuthorizedEntitiesCatalog', () => {
{ kind: 'component', namespace: 'default', name: 'my-component' },
],
});
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const catalog = new AuthorizedEntitiesCatalog(
@@ -252,7 +253,7 @@ describe('AuthorizedEntitiesCatalog', () => {
describe('facets', () => {
it('returns empty response on DENY', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const catalog = createCatalog();
@@ -268,7 +269,7 @@ describe('AuthorizedEntitiesCatalog', () => {
});
it('calls underlying catalog method with correct filter on CONDITIONAL', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
@@ -286,7 +287,7 @@ describe('AuthorizedEntitiesCatalog', () => {
});
it('calls underlying catalog method on ALLOW', async () => {
fakePermissionApi.authorize.mockResolvedValue([
fakePermissionApi.authorizeConditional.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const catalog = createCatalog();
@@ -22,7 +22,7 @@ import {
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
AuthorizeResult,
PermissionAuthorizer,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { ConditionTransformer } from '@backstage/plugin-permission-node';
import {
@@ -39,13 +39,13 @@ import { basicEntityFilter } from './request/basicEntityFilter';
export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
constructor(
private readonly entitiesCatalog: EntitiesCatalog,
private readonly permissionApi: PermissionAuthorizer,
private readonly permissionApi: PermissionEvaluator,
private readonly transformConditions: ConditionTransformer<EntityFilter>,
) {}
async entities(request?: EntitiesRequest): Promise<EntitiesResponse> {
const authorizeDecision = (
await this.permissionApi.authorize(
await this.permissionApi.authorizeConditional(
[{ permission: catalogEntityReadPermission }],
{ token: request?.authorizationToken },
)
@@ -78,7 +78,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
options?: { authorizationToken?: string },
): Promise<void> {
const authorizeResponse = (
await this.permissionApi.authorize(
await this.permissionApi.authorizeConditional(
[{ permission: catalogEntityDeletePermission }],
{ token: options?.authorizationToken },
)
@@ -155,7 +155,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
async facets(request: EntityFacetsRequest): Promise<EntityFacetsResponse> {
const authorizeDecision = (
await this.permissionApi.authorize(
await this.permissionApi.authorizeConditional(
[{ permission: catalogEntityReadPermission }],
{ token: request?.authorizationToken },
)
@@ -27,6 +27,7 @@ describe('AuthorizedLocationService', () => {
};
const fakePermissionApi = {
authorize: jest.fn(),
authorizeConditional: jest.fn(),
};
const mockAllow = () => {
@@ -24,14 +24,14 @@ import {
} from '@backstage/plugin-catalog-common';
import {
AuthorizeResult,
PermissionAuthorizer,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { LocationInput, LocationService } from './types';
export class AuthorizedLocationService implements LocationService {
constructor(
private readonly locationService: LocationService,
private readonly permissionApi: PermissionAuthorizer,
private readonly permissionApi: PermissionEvaluator,
) {}
async createLocation(
@@ -25,6 +25,7 @@ describe('AuthorizedRefreshService', () => {
};
const permissionApi = {
authorize: jest.fn(),
query: jest.fn(),
};
afterEach(() => {
@@ -18,14 +18,14 @@ import { NotAllowedError } from '@backstage/errors';
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common';
import {
AuthorizeResult,
PermissionAuthorizer,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { RefreshOptions, RefreshService } from './types';
export class AuthorizedRefreshService implements RefreshService {
constructor(
private readonly service: RefreshService,
private readonly permissionApi: PermissionAuthorizer,
private readonly permissionApi: PermissionEvaluator,
) {}
async refresh(options: RefreshOptions) {
@@ -79,7 +79,11 @@ import {
CatalogPermissionRule,
permissionRules as catalogPermissionRules,
} from '../permissions/rules';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import {
PermissionAuthorizer,
PermissionEvaluator,
toPermissionEvaluator,
} from '@backstage/plugin-permission-common';
import {
createConditionTransformer,
createPermissionIntegrationRouter,
@@ -95,7 +99,7 @@ export type CatalogEnvironment = {
database: PluginDatabaseManager;
config: Config;
reader: UrlReader;
permissions: PermissionAuthorizer;
permissions: PermissionEvaluator | PermissionAuthorizer;
};
/**
@@ -376,9 +380,20 @@ export class CatalogBuilder {
policy,
});
const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(dbClient);
let permissionEvaluator: PermissionEvaluator;
if ('query' in permissions) {
permissionEvaluator = permissions as PermissionEvaluator;
} else {
logger.warn(
'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions',
);
permissionEvaluator = toPermissionEvaluator(permissions);
}
const entitiesCatalog = new AuthorizedEntitiesCatalog(
unauthorizedEntitiesCatalog,
permissions,
permissionEvaluator,
createConditionTransformer(this.permissionRules),
);
const permissionIntegrationRouter = createPermissionIntegrationRouter({
@@ -428,11 +443,11 @@ export class CatalogBuilder {
this.locationAnalyzer ?? new RepoLocationAnalyzer(logger, integrations);
const locationService = new AuthorizedLocationService(
new DefaultLocationService(locationStore, orchestrator),
permissions,
permissionEvaluator,
);
const refreshService = new AuthorizedRefreshService(
new DefaultRefreshService({ database: processingDatabase }),
permissions,
permissionEvaluator,
);
const router = await createRouter({
entitiesCatalog,
+2 -1
View File
@@ -9,6 +9,7 @@ import { Config } from '@backstage/config';
import express from 'express';
import { Logger } from 'winston';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -98,6 +99,6 @@ export interface RouterOptions {
// (undocumented)
logger: Logger;
// (undocumented)
permissions?: PermissionAuthorizer;
permissions?: PermissionEvaluator | PermissionAuthorizer;
}
```
@@ -49,6 +49,7 @@ const fakePermissionApi = {
result: AuthorizeResult.ALLOW,
},
]),
authorizeConditional: jest.fn(),
};
describe('JenkinsApi', () => {
@@ -25,7 +25,7 @@ import type {
} from '../types';
import {
AuthorizeResult,
PermissionAuthorizer,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common';
import { NotAllowedError } from '@backstage/errors';
@@ -64,7 +64,7 @@ export class JenkinsApiImpl {
${JenkinsApiImpl.jobTreeSpec}
]{0,50}`;
constructor(private readonly permissionApi?: PermissionAuthorizer) {}
constructor(private readonly permissionApi?: PermissionEvaluator) {}
/**
* Get a list of projects for the given JenkinsInfo.
+20 -4
View File
@@ -20,22 +20,38 @@ import Router from 'express-promise-router';
import { Logger } from 'winston';
import { JenkinsInfoProvider } from './jenkinsInfoProvider';
import { JenkinsApiImpl } from './jenkinsApi';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import {
PermissionAuthorizer,
PermissionEvaluator,
toPermissionEvaluator,
} from '@backstage/plugin-permission-common';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { stringifyEntityRef } from '@backstage/catalog-model';
export interface RouterOptions {
logger: Logger;
jenkinsInfoProvider: JenkinsInfoProvider;
permissions?: PermissionAuthorizer;
permissions?: PermissionEvaluator | PermissionAuthorizer;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { jenkinsInfoProvider } = options;
const { jenkinsInfoProvider, permissions, logger } = options;
const jenkinsApi = new JenkinsApiImpl(options.permissions);
let permissionEvaluator: PermissionEvaluator | undefined;
if (permissions && 'query' in permissions) {
permissionEvaluator = permissions as PermissionEvaluator;
} else {
logger.warn(
'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions',
);
permissionEvaluator = permissions
? toPermissionEvaluator(permissions)
: undefined;
}
const jenkinsApi = new JenkinsApiImpl(permissionEvaluator);
const router = Router();
router.use(express.json());
+54 -5
View File
@@ -15,6 +15,20 @@ export type AnyOfCriteria<TQuery> = {
anyOf: NonEmptyArray<PermissionCriteria<TQuery>>;
};
// @public
export type AuthorizePermissionRequest =
| {
permission: Exclude<Permission, ResourcePermission>;
resourceRef?: never;
}
| {
permission: ResourcePermission;
resourceRef: string;
};
// @public
export type AuthorizePermissionResponse = DefinitivePolicyDecision;
// @public
export type AuthorizeRequestOptions = {
token?: string;
@@ -78,6 +92,11 @@ export type EvaluatePermissionResponse = PolicyDecision;
export type EvaluatePermissionResponseBatch =
PermissionMessageBatch<EvaluatePermissionResponse>;
// @public
export type EvaluatorRequestOptions = {
token?: string;
};
// @public
export type IdentifiedPermissionMessage<T> = T & {
id: string;
@@ -120,7 +139,7 @@ export type PermissionAttributes = {
action?: 'create' | 'read' | 'update' | 'delete';
};
// @public
// @public @deprecated
export interface PermissionAuthorizer {
// (undocumented)
authorize(
@@ -138,12 +157,16 @@ export type PermissionBase<TType extends string, TFields extends object> = {
} & TFields;
// @public
export class PermissionClient implements PermissionAuthorizer {
export class PermissionClient implements PermissionEvaluator {
constructor(options: { discovery: DiscoveryApi; config: Config });
authorize(
queries: EvaluatePermissionRequest[],
options?: AuthorizeRequestOptions,
): Promise<EvaluatePermissionResponse[]>;
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]>;
authorizeConditional(
queries: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<QueryPermissionResponse[]>;
}
// @public
@@ -163,6 +186,18 @@ export type PermissionCriteria<TQuery> =
| NotCriteria<TQuery>
| TQuery;
// @public
export interface PermissionEvaluator {
authorize(
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]>;
authorizeConditional(
requests: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<QueryPermissionResponse[]>;
}
// @public
export type PermissionMessageBatch<T> = {
items: IdentifiedPermissionMessage<T>[];
@@ -173,6 +208,15 @@ export type PolicyDecision =
| DefinitivePolicyDecision
| ConditionalPolicyDecision;
// @public
export type QueryPermissionRequest = {
permission: ResourcePermission;
resourceRef?: never;
};
// @public
export type QueryPermissionResponse = PolicyDecision;
// @public
export type ResourcePermission<TResourceType extends string = string> =
PermissionBase<
@@ -181,4 +225,9 @@ export type ResourcePermission<TResourceType extends string = string> =
resourceType: TResourceType;
}
>;
// @public
export function toPermissionEvaluator(
permissionAuthorizer: PermissionAuthorizer,
): PermissionEvaluator;
```
@@ -22,6 +22,7 @@ import {
EvaluatePermissionRequest,
AuthorizeResult,
IdentifiedPermissionMessage,
ConditionalPolicyDecision,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import { createPermission } from './permissions';
@@ -52,7 +53,7 @@ describe('PermissionClient', () => {
afterEach(() => server.resetHandlers());
describe('authorize', () => {
const mockAuthorizeQuery = {
const mockAuthorizeConditional = {
permission: mockPermission,
resourceRef: 'foo:bar',
};
@@ -77,12 +78,12 @@ describe('PermissionClient', () => {
});
it('should fetch entities from correct endpoint', async () => {
await client.authorize([mockAuthorizeQuery]);
await client.authorize([mockAuthorizeConditional]);
expect(mockAuthorizeHandler).toHaveBeenCalled();
});
it('should include a request body', async () => {
await client.authorize([mockAuthorizeQuery]);
await client.authorize([mockAuthorizeConditional]);
const request = mockAuthorizeHandler.mock.calls[0][0];
@@ -97,21 +98,21 @@ describe('PermissionClient', () => {
});
it('should return the response from the fetch request', async () => {
const response = await client.authorize([mockAuthorizeQuery]);
const response = await client.authorize([mockAuthorizeConditional]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
});
it('should not include authorization headers if no token is supplied', async () => {
await client.authorize([mockAuthorizeQuery]);
await client.authorize([mockAuthorizeConditional]);
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.headers.has('authorization')).toEqual(false);
});
it('should include correctly-constructed authorization header if token is supplied', async () => {
await client.authorize([mockAuthorizeQuery], { token });
await client.authorize([mockAuthorizeConditional], { token });
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
@@ -124,7 +125,7 @@ describe('PermissionClient', () => {
},
);
await expect(
client.authorize([mockAuthorizeQuery], { token }),
client.authorize([mockAuthorizeConditional], { token }),
).rejects.toThrowError(/request failed with 401/i);
});
@@ -139,8 +140,8 @@ describe('PermissionClient', () => {
},
);
await expect(
client.authorize([mockAuthorizeQuery], { token }),
).rejects.toThrowError(/Unexpected authorization response/i);
client.authorize([mockAuthorizeConditional], { token }),
).rejects.toThrowError(/items in response do not match request/i);
});
it('should reject invalid responses', async () => {
@@ -157,7 +158,7 @@ describe('PermissionClient', () => {
},
);
await expect(
client.authorize([mockAuthorizeQuery], { token }),
client.authorize([mockAuthorizeConditional], { token }),
).rejects.toThrowError(/invalid input/i);
});
@@ -178,7 +179,7 @@ describe('PermissionClient', () => {
discovery,
config: new ConfigReader({ permission: { enabled: false } }),
});
const response = await disabled.authorize([mockAuthorizeQuery]);
const response = await disabled.authorize([mockAuthorizeConditional]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
@@ -202,11 +203,206 @@ describe('PermissionClient', () => {
discovery,
config: new ConfigReader({}),
});
const response = await disabled.authorize([mockAuthorizeQuery]);
const response = await disabled.authorize([mockAuthorizeConditional]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
expect(mockAuthorizeHandler).not.toBeCalled();
});
});
describe('authorizeConditional', () => {
const mockResourceAuthorizeConditional = {
permission: mockPermission,
};
const mockPolicyDecisionHandler = jest.fn(
(req, res, { json }: RestContext) => {
const responses = req.body.items.map(
(a: IdentifiedPermissionMessage<ConditionalPolicyDecision>) => ({
id: a.id,
pluginId: 'test-plugin',
resourceType: 'test-resource',
result: AuthorizeResult.CONDITIONAL,
conditions: {
resourceType: 'test-resource',
rule: 'FOO',
params: ['bar'],
},
}),
);
return res(json({ items: responses }));
},
);
beforeEach(() => {
server.use(
rest.post(`${mockBaseUrl}/authorize`, mockPolicyDecisionHandler),
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch entities from correct endpoint', async () => {
await client.authorizeConditional([mockResourceAuthorizeConditional]);
expect(mockPolicyDecisionHandler).toHaveBeenCalled();
});
it('should include a request body', async () => {
await client.authorizeConditional([mockResourceAuthorizeConditional]);
const request = mockPolicyDecisionHandler.mock.calls[0][0];
expect(request.body).toEqual({
items: [
expect.objectContaining({
permission: mockPermission,
}),
],
});
});
it('should return the response from the fetch request', async () => {
const response = await client.authorizeConditional([
mockResourceAuthorizeConditional,
]);
expect(response[0]).toEqual(
expect.objectContaining({
result: AuthorizeResult.CONDITIONAL,
conditions: {
rule: 'FOO',
resourceType: 'test-resource',
params: ['bar'],
},
}),
);
});
it('should not include authorization headers if no token is supplied', async () => {
await client.authorizeConditional([mockResourceAuthorizeConditional]);
const request = mockPolicyDecisionHandler.mock.calls[0][0];
expect(request.headers.has('authorization')).toEqual(false);
});
it('should include correctly-constructed authorization header if token is supplied', async () => {
await client.authorizeConditional([mockResourceAuthorizeConditional], {
token,
});
const request = mockPolicyDecisionHandler.mock.calls[0][0];
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
});
it('should forward response errors', async () => {
mockPolicyDecisionHandler.mockImplementationOnce(
(_req, res, { status }: RestContext) => {
return res(status(401));
},
);
await expect(
client.authorizeConditional([mockResourceAuthorizeConditional], {
token,
}),
).rejects.toThrowError(/request failed with 401/i);
});
it('should reject responses with missing ids', async () => {
mockPolicyDecisionHandler.mockImplementationOnce(
(_req, res, { json }: RestContext) => {
return res(
json({
items: [{ id: 'wrong-id', result: AuthorizeResult.ALLOW }],
}),
);
},
);
await expect(
client.authorizeConditional([mockResourceAuthorizeConditional], {
token,
}),
).rejects.toThrowError(/items in response do not match request/i);
});
it('should reject invalid responses', async () => {
mockPolicyDecisionHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.items.map(
(a: IdentifiedPermissionMessage<ConditionalPolicyDecision>) => ({
id: a.id,
outcome: AuthorizeResult.ALLOW,
}),
);
return res(json({ items: responses }));
},
);
await expect(
client.authorizeConditional([mockResourceAuthorizeConditional], {
token,
}),
).rejects.toThrowError(/invalid input/i);
});
it('should allow all when permission.enabled is false', async () => {
mockPolicyDecisionHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map(
(a: IdentifiedPermissionMessage<ConditionalPolicyDecision>) => ({
id: a.id,
result: AuthorizeResult.DENY,
}),
);
return res(json({ items: responses }));
},
);
const disabled = new PermissionClient({
discovery,
config: new ConfigReader({ permission: { enabled: false } }),
});
const response = await disabled.authorizeConditional(
[mockResourceAuthorizeConditional],
{
token,
},
);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
expect(mockPolicyDecisionHandler).not.toBeCalled();
});
it('should allow all when permission.enabled is not configured', async () => {
mockPolicyDecisionHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map(
(a: IdentifiedPermissionMessage<ConditionalPolicyDecision>) => ({
id: a.id,
outcome: AuthorizeResult.DENY,
}),
);
return res(json(responses));
},
);
const disabled = new PermissionClient({
discovery,
config: new ConfigReader({}),
});
const response = await disabled.authorizeConditional(
[mockResourceAuthorizeConditional],
{
token,
},
);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
expect(mockPolicyDecisionHandler).not.toBeCalled();
});
});
});
@@ -21,19 +21,18 @@ import * as uuid from 'uuid';
import { z } from 'zod';
import {
AuthorizeResult,
EvaluatePermissionRequest,
EvaluatePermissionResponse,
IdentifiedPermissionMessage,
PermissionMessageBatch,
PermissionCriteria,
PermissionCondition,
EvaluatePermissionResponseBatch,
EvaluatePermissionRequestBatch,
PermissionEvaluator,
QueryPermissionRequest,
AuthorizePermissionRequest,
EvaluatorRequestOptions,
AuthorizePermissionResponse,
QueryPermissionResponse,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import {
PermissionAuthorizer,
AuthorizeRequestOptions,
} from './types/permission';
import { AuthorizeRequestOptions } from './types/permission';
const permissionCriteriaSchema: z.ZodSchema<
PermissionCriteria<PermissionCondition>
@@ -58,30 +57,56 @@ const permissionCriteriaSchema: z.ZodSchema<
.or(z.object({ not: permissionCriteriaSchema }).strict()),
);
const responseSchema = z.object({
items: z.array(
z
.object({
id: z.string(),
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
})
.or(
z.object({
id: z.string(),
result: z.literal(AuthorizeResult.CONDITIONAL),
conditions: permissionCriteriaSchema,
}),
const authorizePermissionResponseSchema: z.ZodSchema<AuthorizePermissionResponse> =
z.object({
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
});
const queryPermissionResponseSchema: z.ZodSchema<QueryPermissionResponse> =
z.union([
z.object({
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
}),
z.object({
result: z.literal(AuthorizeResult.CONDITIONAL),
pluginId: z.string(),
resourceType: z.string(),
conditions: permissionCriteriaSchema,
}),
]);
const responseSchema = <T>(
itemSchema: z.ZodSchema<T>,
ids: Set<string>,
): z.ZodSchema<PermissionMessageBatch<T>> =>
z.object({
items: z
.array(
z.intersection(
z.object({
id: z.string(),
}),
itemSchema,
),
)
.refine(
items =>
items.length === ids.size && items.every(({ id }) => ids.has(id)),
{
message: 'Items in response do not match request',
},
),
),
});
});
/**
* An isomorphic client for requesting authorization for Backstage permissions.
* @public
*/
export class PermissionClient implements PermissionAuthorizer {
export class PermissionClient implements PermissionEvaluator {
private readonly enabled: boolean;
private readonly discovery: DiscoveryApi;
@@ -92,35 +117,39 @@ export class PermissionClient implements PermissionAuthorizer {
}
/**
* Request authorization from the permission-backend for the given set of permissions.
*
* Authorization requests check that a given Backstage user can perform a protected operation,
* potentially for a specific resource (such as a catalog entity). The Backstage identity token
* should be included in the `options` if available.
*
* Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`.
*
* The response will be either ALLOW or DENY when either the permission has no resourceType, or a
* resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be
* returned if no resourceRef is provided in the request. Conditional responses are intended only
* for backends which have access to the data source for permissioned resources, so that filters
* can be applied when loading collections of resources.
* @public
* {@inheritdoc PermissionEvaluator.authorize}
*/
async authorize(
queries: EvaluatePermissionRequest[],
options?: AuthorizeRequestOptions,
): Promise<EvaluatePermissionResponse[]> {
// TODO(permissions): it would be great to provide some kind of typing guarantee that
// conditional responses will only ever be returned for requests containing a resourceType
// but no resourceRef. That way clients who aren't prepared to handle filtering according
// to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response.
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]> {
return this.makeRequest(
requests,
authorizePermissionResponseSchema,
options,
);
}
/**
* {@inheritdoc PermissionEvaluator.authorizeConditional}
*/
async authorizeConditional(
queries: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<QueryPermissionResponse[]> {
return this.makeRequest(queries, queryPermissionResponseSchema, options);
}
private async makeRequest<TQuery, TResult>(
queries: TQuery[],
itemSchema: z.ZodSchema<TResult>,
options?: AuthorizeRequestOptions,
) {
if (!this.enabled) {
return queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
return queries.map(_ => ({ result: AuthorizeResult.ALLOW as const }));
}
const request: EvaluatePermissionRequestBatch = {
const request: PermissionMessageBatch<TQuery> = {
items: queries.map(query => ({
id: uuid.v4(),
...query,
@@ -141,12 +170,16 @@ export class PermissionClient implements PermissionAuthorizer {
}
const responseBody = await response.json();
this.assertValidResponse(request, responseBody);
const responsesById = responseBody.items.reduce((acc, r) => {
const parsedResponse = responseSchema(
itemSchema,
new Set(request.items.map(({ id }) => id)),
).parse(responseBody);
const responsesById = parsedResponse.items.reduce((acc, r) => {
acc[r.id] = r;
return acc;
}, {} as Record<string, IdentifiedPermissionMessage<EvaluatePermissionResponse>>);
}, {} as Record<string, z.infer<typeof itemSchema>>);
return request.items.map(query => responsesById[query.id]);
}
@@ -154,20 +187,4 @@ export class PermissionClient implements PermissionAuthorizer {
private getAuthorizationHeader(token?: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
private assertValidResponse(
request: EvaluatePermissionRequestBatch,
json: any,
): asserts json is EvaluatePermissionResponseBatch {
const authorizedResponses = responseSchema.parse(json);
const responseIds = authorizedResponses.items.map(r => r.id);
const hasAllRequestIds = request.items.every(r =>
responseIds.includes(r.id),
);
if (!hasAllRequestIds) {
throw new Error(
'Unexpected authorization response from permission-backend',
);
}
}
}
@@ -14,7 +14,18 @@
* limitations under the License.
*/
import { Permission, ResourcePermission } from '../types';
import {
AuthorizePermissionRequest,
AuthorizePermissionResponse,
DefinitivePolicyDecision,
EvaluatorRequestOptions,
Permission,
PermissionAuthorizer,
PermissionEvaluator,
QueryPermissionRequest,
QueryPermissionResponse,
ResourcePermission,
} from '../types';
/**
* Check if the two parameters are equivalent permissions.
@@ -75,3 +86,31 @@ export function isUpdatePermission(permission: Permission) {
export function isDeletePermission(permission: Permission) {
return permission.attributes.action === 'delete';
}
/**
* Convert {@link PermissionAuthorizer} to {@link PermissionEvaluator}.
*
* @public
*/
export function toPermissionEvaluator(
permissionAuthorizer: PermissionAuthorizer,
): PermissionEvaluator {
return {
authorize: async (
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]> => {
const response = await permissionAuthorizer.authorize(requests, options);
return response as DefinitivePolicyDecision[];
},
authorizeConditional(
requests: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<QueryPermissionResponse[]> {
const parsedRequests =
requests as unknown as AuthorizePermissionRequest[];
return permissionAuthorizer.authorize(parsedRequests, options);
},
};
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ResourcePermission } from '.';
import { Permission } from './permission';
/**
@@ -182,3 +183,73 @@ export type EvaluatePermissionResponse = PolicyDecision;
*/
export type EvaluatePermissionResponseBatch =
PermissionMessageBatch<EvaluatePermissionResponse>;
/**
* Request object for {@link PermissionEvaluator.authorize}. If a {@link ResourcePermission}
* is provided, it must include a corresponding `resourceRef`.
* @public
*/
export type AuthorizePermissionRequest =
| {
permission: Exclude<Permission, ResourcePermission>;
resourceRef?: never;
}
| { permission: ResourcePermission; resourceRef: string };
/**
* Response object for {@link PermissionEvaluator.authorize}.
* @public
*/
export type AuthorizePermissionResponse = DefinitivePolicyDecision;
/**
* Request object for {@link PermissionEvaluator.authorizeConditional}.
* @public
*/
export type QueryPermissionRequest = {
permission: ResourcePermission;
resourceRef?: never;
};
/**
* Response object for {@link PermissionEvaluator.authorizeConditional}.
* @public
*/
export type QueryPermissionResponse = PolicyDecision;
/**
* A client interacting with the permission backend can implement this evaluator interface.
*
* @public
*/
export interface PermissionEvaluator {
/**
* Evaluates {@link Permission | Permissions} and returns a definitive decision.
*/
authorize(
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]>;
/**
* Evaluates {@link ResourcePermission | ResourcePermissions} and returns both definitive and
* conditional decisions, depending on the configured
* {@link @backstage/plugin-permission-node#PermissionPolicy}. This method is useful when the
* caller needs more control over the processing of conditional decisions. For example, a plugin
* backend may want to use {@link PermissionCriteria | conditions} in a database query instead of
* evaluating each resource in memory.
*/
authorizeConditional(
requests: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<QueryPermissionResponse[]>;
}
/**
* Options for {@link PermissionEvaluator} requests.
* The Backstage identity token should be defined if available.
* @public
*/
export type EvaluatorRequestOptions = {
token?: string;
};
@@ -22,6 +22,12 @@ export type {
EvaluatePermissionResponseBatch,
IdentifiedPermissionMessage,
PermissionMessageBatch,
AuthorizePermissionRequest,
AuthorizePermissionResponse,
QueryPermissionRequest,
QueryPermissionResponse,
EvaluatorRequestOptions,
PermissionEvaluator,
ConditionalPolicyDecision,
DefinitivePolicyDecision,
PolicyDecision,
@@ -91,6 +91,7 @@ export type ResourcePermission<TResourceType extends string = string> =
/**
* A client interacting with the permission backend can implement this authorizer interface.
* @public
* @deprecated Use {@link @backstage/plugin-permission-common#PermissionEvaluator} instead
*/
export interface PermissionAuthorizer {
authorize(
+14 -8
View File
@@ -5,22 +5,23 @@
```ts
import { AllOfCriteria } from '@backstage/plugin-permission-common';
import { AnyOfCriteria } from '@backstage/plugin-permission-common';
import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common';
import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common';
import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common';
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common';
import { Config } from '@backstage/config';
import { DefinitivePolicyDecision } from '@backstage/plugin-permission-common';
import { EvaluatePermissionRequest } from '@backstage/plugin-permission-common';
import { EvaluatePermissionResponse } from '@backstage/plugin-permission-common';
import { EvaluatorRequestOptions } from '@backstage/plugin-permission-common';
import express from 'express';
import { IdentifiedPermissionMessage } from '@backstage/plugin-permission-common';
import { NotCriteria } from '@backstage/plugin-permission-common';
import { Permission } from '@backstage/plugin-permission-common';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PolicyDecision } from '@backstage/plugin-permission-common';
import { QueryPermissionRequest } from '@backstage/plugin-permission-common';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { TokenManager } from '@backstage/backend-common';
@@ -178,12 +179,17 @@ export type PolicyQuery = {
};
// @public
export class ServerPermissionClient implements PermissionAuthorizer {
export class ServerPermissionClient implements PermissionEvaluator {
// (undocumented)
authorize(
requests: EvaluatePermissionRequest[],
options?: AuthorizeRequestOptions,
): Promise<EvaluatePermissionResponse[]>;
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]>;
// (undocumented)
authorizeConditional(
queries: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<PolicyDecision[]>;
// (undocumented)
static fromConfig(
config: Config,
@@ -17,9 +17,10 @@
import { ServerPermissionClient } from './ServerPermissionClient';
import {
IdentifiedPermissionMessage,
EvaluatePermissionRequest,
AuthorizeResult,
createPermission,
DefinitivePolicyDecision,
ConditionalPolicyDecision,
} from '@backstage/plugin-permission-common';
import { ConfigReader } from '@backstage/config';
import {
@@ -31,16 +32,7 @@ import { setupServer } from 'msw/node';
import { RestContext, rest } from 'msw';
const server = setupServer();
const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
const responses = req.body.items.map(
(r: IdentifiedPermissionMessage<EvaluatePermissionRequest>) => ({
id: r.id,
result: AuthorizeResult.ALLOW,
}),
);
return res(json({ items: responses }));
});
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discovery: PluginEndpointDiscovery = {
async getBaseUrl() {
@@ -50,10 +42,17 @@ const discovery: PluginEndpointDiscovery = {
return mockBaseUrl;
},
};
const testPermission = createPermission({
const testBasicPermission = createPermission({
name: 'test.permission',
attributes: {},
});
const testResourcePermission = createPermission({
name: 'test.permission-2',
attributes: {},
resourceType: 'resource-type',
});
const config = new ConfigReader({
permission: { enabled: true },
backend: { auth: { keys: [{ secret: 'a-secret-key' }] } },
@@ -63,49 +62,6 @@ const logger = getVoidLogger();
describe('ServerPermissionClient', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
beforeEach(() => {
server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler));
});
afterEach(() => server.resetHandlers());
it('should bypass authorization if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
});
await client.authorize([{ permission: testPermission }]);
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should bypass authorization if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
await client.authorize([{ permission: testPermission }], {
token: (await tokenManager.getToken()).token,
});
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should authorize normally if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
await client.authorize([{ permission: testPermission }], {
token: 'a-user-token',
});
expect(mockAuthorizeHandler).toHaveBeenCalled();
});
it('should error if permissions are enabled but a no-op token manager is configured', async () => {
expect(() =>
@@ -117,4 +73,134 @@ describe('ServerPermissionClient', () => {
'Backend-to-backend authentication must be configured before enabling permissions. Read more here https://backstage.io/docs/tutorials/backend-to-backend-auth',
);
});
describe('authorize', () => {
let mockAuthorizeHandler: jest.Mock;
beforeEach(() => {
mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
const responses = req.body.items.map(
(r: IdentifiedPermissionMessage<DefinitivePolicyDecision>) => ({
id: r.id,
result: AuthorizeResult.ALLOW,
}),
);
return res(json({ items: responses }));
});
server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler));
});
afterEach(() => server.resetHandlers());
it('should bypass the permission backend if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
});
await client.authorize([
{
permission: testBasicPermission,
},
]);
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
await client.authorize([{ permission: testBasicPermission }], {
token: (await tokenManager.getToken()).token,
});
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
await client.authorize([{ permission: testBasicPermission }], {
token: 'a-user-token',
});
expect(mockAuthorizeHandler).toHaveBeenCalled();
});
});
describe('authorizeConditional', () => {
let mockAuthorizeHandler: jest.Mock;
beforeEach(() => {
mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
const responses = req.body.items.map(
(r: IdentifiedPermissionMessage<ConditionalPolicyDecision>) => ({
id: r.id,
result: AuthorizeResult.ALLOW,
}),
);
return res(json({ items: responses }));
});
server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler));
});
afterEach(() => server.resetHandlers());
it('should bypass the permission backend if permissions are disabled', async () => {
const client = ServerPermissionClient.fromConfig(new ConfigReader({}), {
discovery,
tokenManager: ServerTokenManager.noop(),
});
await client.authorizeConditional([
{ permission: testResourcePermission },
]);
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should bypass the permission backend if permissions are enabled and request has valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
await client.authorizeConditional(
[{ permission: testResourcePermission }],
{
token: (await tokenManager.getToken()).token,
},
);
expect(mockAuthorizeHandler).not.toHaveBeenCalled();
});
it('should call the permission backend if permissions are enabled and request does not have valid server token', async () => {
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
const client = ServerPermissionClient.fromConfig(config, {
discovery,
tokenManager,
});
await client.authorizeConditional(
[{ permission: testResourcePermission }],
{
token: 'a-user-token',
},
);
expect(mockAuthorizeHandler).toHaveBeenCalled();
});
});
});
@@ -20,12 +20,14 @@ import {
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import {
EvaluatePermissionRequest,
AuthorizeRequestOptions,
EvaluatePermissionResponse,
AuthorizeResult,
PermissionClient,
PermissionAuthorizer,
PermissionEvaluator,
AuthorizePermissionRequest,
EvaluatorRequestOptions,
AuthorizePermissionResponse,
PolicyDecision,
QueryPermissionRequest,
} from '@backstage/plugin-permission-common';
/**
@@ -34,7 +36,7 @@ import {
* backend-to-backend requests.
* @public
*/
export class ServerPermissionClient implements PermissionAuthorizer {
export class ServerPermissionClient implements PermissionEvaluator {
private readonly permissionClient: PermissionClient;
private readonly tokenManager: TokenManager;
private readonly permissionEnabled: boolean;
@@ -77,21 +79,22 @@ export class ServerPermissionClient implements PermissionAuthorizer {
this.permissionEnabled = options.permissionEnabled;
}
async authorizeConditional(
queries: QueryPermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<PolicyDecision[]> {
return (await this.isEnabled(options?.token))
? this.permissionClient.authorizeConditional(queries, options)
: queries.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
async authorize(
requests: EvaluatePermissionRequest[],
options?: AuthorizeRequestOptions,
): Promise<EvaluatePermissionResponse[]> {
// Check if permissions are enabled before validating the server token. That
// way when permissions are disabled, the noop token manager can be used
// without fouling up the logic inside the ServerPermissionClient, because
// the code path won't be reached.
if (
!this.permissionEnabled ||
(await this.isValidServerToken(options?.token))
) {
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
return this.permissionClient.authorize(requests, options);
requests: AuthorizePermissionRequest[],
options?: EvaluatorRequestOptions,
): Promise<AuthorizePermissionResponse[]> {
return (await this.isEnabled(options?.token))
? this.permissionClient.authorize(requests, options)
: requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
private async isValidServerToken(
@@ -105,4 +108,12 @@ export class ServerPermissionClient implements PermissionAuthorizer {
.then(() => true)
.catch(() => false);
}
private async isEnabled(token?: string) {
// Check if permissions are enabled before validating the server token. That
// way when permissions are disabled, the noop token manager can be used
// without fouling up the logic inside the ServerPermissionClient, because
// the code path won't be reached.
return this.permissionEnabled && !(await this.isValidServerToken(token));
}
}
+4 -2
View File
@@ -4,6 +4,8 @@
```ts
import { ApiRef } from '@backstage/core-plugin-api';
import { AuthorizePermissionRequest } from '@backstage/plugin-permission-common';
import { AuthorizePermissionResponse } from '@backstage/plugin-permission-common';
import { ComponentProps } from 'react';
import { Config } from '@backstage/config';
import { DiscoveryApi } from '@backstage/core-plugin-api';
@@ -26,8 +28,8 @@ export type AsyncPermissionResult = {
export class IdentityPermissionApi implements PermissionApi {
// (undocumented)
authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse>;
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse>;
// (undocumented)
static create(options: {
config: Config;
@@ -17,8 +17,8 @@
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { PermissionApi } from './PermissionApi';
import {
EvaluatePermissionRequest,
EvaluatePermissionResponse,
AuthorizePermissionRequest,
AuthorizePermissionResponse,
PermissionClient,
} from '@backstage/plugin-permission-common';
import { Config } from '@backstage/config';
@@ -45,8 +45,8 @@ export class IdentityPermissionApi implements PermissionApi {
}
async authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse> {
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse> {
const response = await this.permissionClient.authorize(
[request],
await this.identityApi.getCredentials(),
+2 -1
View File
@@ -8,6 +8,7 @@ import { DocumentTypeInfo } from '@backstage/plugin-search-common';
import express from 'express';
import { Logger } from 'winston';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { SearchEngine } from '@backstage/plugin-search-backend-node';
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -21,7 +22,7 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
export type RouterOptions = {
engine: SearchEngine;
types: Record<string, DocumentTypeInfo>;
permissions: PermissionAuthorizer;
permissions: PermissionEvaluator | PermissionAuthorizer;
config: Config;
logger: Logger;
};
@@ -19,7 +19,8 @@ import {
EvaluatePermissionResponse,
AuthorizeResult,
createPermission,
PermissionAuthorizer,
PolicyDecision,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';
import {
DocumentTypeInfo,
@@ -69,12 +70,15 @@ describe('AuthorizedSearchEngine', () => {
query: mockedQuery,
};
const mockedAuthorize: jest.MockedFunction<
PermissionAuthorizer['authorize']
const mockedAuthorize: jest.MockedFunction<PermissionEvaluator['authorize']> =
jest.fn();
const mockedPermissionQuery: jest.MockedFunction<
PermissionEvaluator['authorizeConditional']
> = jest.fn();
const permissionAuthorizer: PermissionAuthorizer = {
const permissionEvaluator: PermissionEvaluator = {
authorize: mockedAuthorize,
authorizeConditional: mockedPermissionQuery,
};
const defaultTypes: Record<string, DocumentTypeInfo> = {
@@ -111,13 +115,14 @@ describe('AuthorizedSearchEngine', () => {
const authorizedSearchEngine = new AuthorizedSearchEngine(
searchEngine,
defaultTypes,
permissionAuthorizer,
permissionEvaluator,
new ConfigReader({}),
);
const options = { token: 'token' };
const allowAll: PermissionAuthorizer['authorize'] = async queries => {
const allowAll: PermissionEvaluator['authorize'] &
PermissionEvaluator['authorizeConditional'] = async queries => {
return queries.map(() => ({
result: AuthorizeResult.ALLOW,
}));
@@ -126,11 +131,12 @@ describe('AuthorizedSearchEngine', () => {
beforeEach(() => {
mockedQuery.mockReset();
mockedAuthorize.mockClear();
mockedPermissionQuery.mockClear();
});
it('should forward the parameters correctly', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
const filters = { just: 1, a: 2, filter: 3 };
await authorizedSearchEngine.query(
{ term: 'term', filters, types: ['one', 'two'] },
@@ -148,7 +154,8 @@ describe('AuthorizedSearchEngine', () => {
it('should forward the default types if none are passed', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
mockedPermissionQuery.mockImplementation(allowAll);
await authorizedSearchEngine.query({ term: '' }, options);
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users', 'templates', 'services', 'groups'] },
@@ -158,17 +165,17 @@ describe('AuthorizedSearchEngine', () => {
it('should return all the results if all queries are allowed', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
mockedPermissionQuery.mockImplementation(allowAll);
await expect(
authorizedSearchEngine.query({ term: '' }, options),
).resolves.toEqual({ results });
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
expect(mockedPermissionQuery).toHaveBeenCalledTimes(1);
});
it('should batch authorized requests', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
mockedPermissionQuery.mockImplementation(allowAll);
await authorizedSearchEngine.query(
{ term: '', types: [typeUsers, typeTemplates] },
@@ -178,8 +185,8 @@ describe('AuthorizedSearchEngine', () => {
{ term: '', types: ['users', 'templates'] },
{ token: 'token' },
);
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
expect(mockedAuthorize).toHaveBeenLastCalledWith(
expect(mockedPermissionQuery).toHaveBeenCalledTimes(1);
expect(mockedPermissionQuery).toHaveBeenLastCalledWith(
[
{ permission: defaultTypes[typeUsers].visibilityPermission },
{ permission: defaultTypes[typeTemplates].visibilityPermission },
@@ -190,7 +197,7 @@ describe('AuthorizedSearchEngine', () => {
it('should skip sending request for types that are not allowed', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(async queries => {
mockedPermissionQuery.mockImplementation(async queries => {
return queries.map(query => {
if (
query.permission.name ===
@@ -213,7 +220,7 @@ describe('AuthorizedSearchEngine', () => {
{ token: 'token' },
);
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
expect(mockedPermissionQuery).toHaveBeenCalledTimes(1);
});
it('should perform result-by-result filtering', async () => {
@@ -226,21 +233,12 @@ describe('AuthorizedSearchEngine', () => {
results: resultsWithAuth,
}));
const userToBeReturned = 8;
mockedAuthorize.mockImplementation(async queries =>
mockedPermissionQuery.mockImplementation(async queries =>
queries.map(query => {
if (
query.permission.name ===
defaultTypes.users.visibilityPermission?.name
) {
if (query.resourceRef) {
return {
result: query.resourceRef.endsWith(userToBeReturned.toString())
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
};
}
return {
result: AuthorizeResult.CONDITIONAL,
} as EvaluatePermissionResponse;
@@ -252,9 +250,20 @@ describe('AuthorizedSearchEngine', () => {
}),
);
mockedAuthorize.mockImplementation(async queries =>
queries.map(query => {
return {
result:
query.resourceRef! === `users_doc_8`
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
};
}),
);
await expect(
authorizedSearchEngine.query({ term: '' }, options),
).resolves.toEqual({ results: [usersWithAuth[userToBeReturned]] });
).resolves.toEqual({ results: [usersWithAuth[8]] });
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users'] },
@@ -284,27 +293,27 @@ describe('AuthorizedSearchEngine', () => {
results: searchResults,
}));
mockedAuthorize.mockImplementation(async queries =>
queries.map(query => {
if (query.resourceRef) {
return {
result: AuthorizeResult.ALLOW,
};
}
mockedPermissionQuery.mockImplementation(async queries =>
queries.map(
_ =>
({
result: AuthorizeResult.CONDITIONAL,
} as EvaluatePermissionResponse),
),
);
return {
result: AuthorizeResult.CONDITIONAL,
} as EvaluatePermissionResponse;
}),
mockedAuthorize.mockImplementation(async queries =>
queries.map(_ => ({
result: AuthorizeResult.ALLOW,
})),
);
await expect(
authorizedSearchEngine.query({ term: '', types: ['templates'] }, options),
).resolves.toEqual({ results: searchResults });
expect(mockedAuthorize).toHaveBeenCalledTimes(2);
expect(mockedAuthorize).toHaveBeenNthCalledWith(
1,
expect(mockedPermissionQuery).toHaveBeenCalledTimes(1);
expect(mockedPermissionQuery).toHaveBeenCalledWith(
[
{
permission: expect.objectContaining({
@@ -314,8 +323,8 @@ describe('AuthorizedSearchEngine', () => {
],
{ token: 'token' },
);
expect(mockedAuthorize).toHaveBeenNthCalledWith(
2,
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
expect(mockedAuthorize).toHaveBeenCalledWith(
[
{
permission: expect.objectContaining({
@@ -329,7 +338,16 @@ describe('AuthorizedSearchEngine', () => {
});
it('should perform search until the target number of results is reached', async () => {
mockedAuthorize.mockImplementation(async queries =>
mockedPermissionQuery.mockImplementation(async queries =>
queries.map(
_ =>
({
result: AuthorizeResult.CONDITIONAL,
} as PolicyDecision),
),
);
mockedPermissionQuery.mockImplementation(async queries =>
queries.map(query => {
if (query.resourceRef) {
return {
@@ -342,6 +360,12 @@ describe('AuthorizedSearchEngine', () => {
}),
);
mockedAuthorize.mockImplementation(async queries =>
queries.map(_ => ({
result: AuthorizeResult.ALLOW,
})),
);
const usersWithAuth = generateSampleResults(typeUsers, true);
const templatesWithAuth = generateSampleResults(typeTemplates, true);
const servicesWithAuth = generateSampleResults(typeServices, true);
@@ -405,20 +429,22 @@ describe('AuthorizedSearchEngine', () => {
});
it('should perform search until the target number of results is reached, excluding unauthorized results', async () => {
mockedPermissionQuery.mockImplementation(async queries =>
queries.map(
_ =>
({
result: AuthorizeResult.CONDITIONAL,
} as PolicyDecision),
),
);
mockedAuthorize.mockImplementation(async queries =>
queries.map(query => {
if (query.resourceRef) {
return {
result:
query.permission.name === 'search.services.read'
? AuthorizeResult.DENY
: AuthorizeResult.ALLOW,
};
}
return {
result: AuthorizeResult.CONDITIONAL,
} as EvaluatePermissionResponse;
}),
queries.map(query => ({
result:
query.permission.name === 'search.services.read'
? AuthorizeResult.DENY
: AuthorizeResult.ALLOW,
})),
);
const usersWithAuth = generateSampleResults(typeUsers, true);
@@ -494,15 +520,19 @@ describe('AuthorizedSearchEngine', () => {
});
it('should discard results until the target cursor is reached', async () => {
mockedPermissionQuery.mockImplementation(async queries =>
queries.map(
_ =>
({
result: AuthorizeResult.CONDITIONAL,
} as PolicyDecision),
),
);
mockedAuthorize.mockImplementation(async queries =>
queries.map(query => {
if (query.resourceRef) {
return { result: AuthorizeResult.ALLOW };
}
return {
result: AuthorizeResult.CONDITIONAL,
} as EvaluatePermissionResponse;
}),
queries.map(_ => ({
result: AuthorizeResult.ALLOW,
})),
);
const usersWithAuth = generateSampleResults(typeUsers, true);
@@ -22,7 +22,9 @@ import {
EvaluatePermissionRequest,
AuthorizeResult,
isResourcePermission,
PermissionAuthorizer,
PermissionEvaluator,
AuthorizePermissionRequest,
QueryPermissionRequest,
} from '@backstage/plugin-permission-common';
import {
DocumentTypeInfo,
@@ -67,7 +69,7 @@ export class AuthorizedSearchEngine implements SearchEngine {
constructor(
private readonly searchEngine: SearchEngine,
private readonly types: Record<string, DocumentTypeInfo>,
private readonly permissions: PermissionAuthorizer,
private readonly permissions: PermissionEvaluator,
config: Config,
) {
this.queryLatencyBudgetMs =
@@ -89,8 +91,16 @@ export class AuthorizedSearchEngine implements SearchEngine {
): Promise<IndexableResultSet> {
const queryStartTime = Date.now();
const conditionFetcher = new DataLoader(
(requests: readonly QueryPermissionRequest[]) =>
this.permissions.authorizeConditional(requests.slice(), options),
{
cacheKeyFn: ({ permission: { name } }) => name,
},
);
const authorizer = new DataLoader(
(requests: readonly EvaluatePermissionRequest[]) =>
(requests: readonly AuthorizePermissionRequest[]) =>
this.permissions.authorize(requests.slice(), options),
{
// Serialize the permission name and resourceRef as
@@ -100,6 +110,7 @@ export class AuthorizedSearchEngine implements SearchEngine {
qs.stringify({ name, resourceRef }),
},
);
const requestedTypes = query.types || Object.keys(this.types);
const typeDecisions = zipObject(
@@ -108,9 +119,18 @@ export class AuthorizedSearchEngine implements SearchEngine {
requestedTypes.map(type => {
const permission = this.types[type]?.visibilityPermission;
return permission
? authorizer.load({ permission })
: { result: AuthorizeResult.ALLOW as const };
// No permission configured for this document type - always allow.
if (!permission) {
return { result: AuthorizeResult.ALLOW as const };
}
// Resource permission supplied, so we need to check for conditional decisions.
if (isResourcePermission(permission)) {
return conditionFetcher.load({ permission });
}
// Non-resource permission supplied - we can perform a standard authorization.
return authorizer.load({ permission });
}),
),
);
@@ -16,7 +16,7 @@
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import {
IndexBuilder,
SearchEngine,
@@ -26,10 +26,13 @@ import request from 'supertest';
import { createRouter } from './router';
const mockPermissionAuthorizer: PermissionAuthorizer = {
const mockPermissionEvaluator: PermissionEvaluator = {
authorize: () => {
throw new Error('Not implemented');
},
authorizeConditional: () => {
throw new Error('Not implemented');
},
};
describe('createRouter', () => {
@@ -59,7 +62,7 @@ describe('createRouter', () => {
'second-type': {},
},
config: new ConfigReader({ permissions: { enabled: false } }),
permissions: mockPermissionAuthorizer,
permissions: mockPermissionEvaluator,
logger,
});
app = express().use(router);
@@ -164,7 +167,7 @@ describe('createRouter', () => {
engine: indexBuilder.getSearchEngine(),
types: indexBuilder.getDocumentTypes(),
config: new ConfigReader({ permissions: { enabled: false } }),
permissions: mockPermissionAuthorizer,
permissions: mockPermissionEvaluator,
logger,
});
app = express().use(router);
+22 -3
View File
@@ -23,7 +23,11 @@ import { InputError } from '@backstage/errors';
import { Config } from '@backstage/config';
import { JsonObject, JsonValue } from '@backstage/types';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import {
PermissionAuthorizer,
PermissionEvaluator,
toPermissionEvaluator,
} from '@backstage/plugin-permission-common';
import {
DocumentTypeInfo,
IndexableResultSet,
@@ -50,7 +54,7 @@ const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
export type RouterOptions = {
engine: SearchEngine;
types: Record<string, DocumentTypeInfo>;
permissions: PermissionAuthorizer;
permissions: PermissionEvaluator | PermissionAuthorizer;
config: Config;
logger: Logger;
};
@@ -71,8 +75,23 @@ export async function createRouter(
pageCursor: z.string().optional(),
});
let permissionEvaluator: PermissionEvaluator;
if ('query' in permissions) {
permissionEvaluator = permissions as PermissionEvaluator;
} else {
logger.warn(
'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions',
);
permissionEvaluator = toPermissionEvaluator(permissions);
}
const engine = config.getOptionalBoolean('permission.enabled')
? new AuthorizedSearchEngine(inputEngine, types, permissions, config)
? new AuthorizedSearchEngine(
inputEngine,
types,
permissionEvaluator,
config,
)
: inputEngine;
const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({