Split PermissionClient#authorize
Co-authored-by: Mike Lewis <mtlewis@users.noreply.github.com> Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
EvaluatePermissionRequest,
|
||||
AuthorizeResult,
|
||||
IdentifiedPermissionMessage,
|
||||
ConditionalPolicyDecision,
|
||||
} from './types/api';
|
||||
import { DiscoveryApi } from './types/discovery';
|
||||
import { createPermission } from './permissions';
|
||||
@@ -140,7 +141,7 @@ describe('PermissionClient', () => {
|
||||
);
|
||||
await expect(
|
||||
client.authorize([mockAuthorizeQuery], { token }),
|
||||
).rejects.toThrowError(/Unexpected authorization response/i);
|
||||
).rejects.toThrowError(/items in response do not match request/i);
|
||||
});
|
||||
|
||||
it('should reject invalid responses', async () => {
|
||||
@@ -209,4 +210,189 @@ describe('PermissionClient', () => {
|
||||
expect(mockAuthorizeHandler).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
const mockResourceAuthorizeQuery = {
|
||||
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: {
|
||||
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.query([mockResourceAuthorizeQuery]);
|
||||
expect(mockPolicyDecisionHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include a request body', async () => {
|
||||
await client.query([mockResourceAuthorizeQuery]);
|
||||
|
||||
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.query([mockResourceAuthorizeQuery]);
|
||||
expect(response[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
rule: 'FOO',
|
||||
params: ['bar'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include authorization headers if no token is supplied', async () => {
|
||||
await client.query([mockResourceAuthorizeQuery]);
|
||||
|
||||
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.query([mockResourceAuthorizeQuery], {
|
||||
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.query([mockResourceAuthorizeQuery], {
|
||||
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.query([mockResourceAuthorizeQuery], {
|
||||
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.query([mockResourceAuthorizeQuery], {
|
||||
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.query([mockResourceAuthorizeQuery], {
|
||||
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.query([mockResourceAuthorizeQuery], {
|
||||
token,
|
||||
});
|
||||
expect(response[0]).toEqual(
|
||||
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
|
||||
);
|
||||
expect(mockPolicyDecisionHandler).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,19 +21,20 @@ import * as uuid from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
EvaluatePermissionRequest,
|
||||
EvaluatePermissionResponse,
|
||||
IdentifiedPermissionMessage,
|
||||
DefinitivePolicyDecision,
|
||||
PermissionMessageBatch,
|
||||
PermissionCriteria,
|
||||
PermissionCondition,
|
||||
EvaluatePermissionResponseBatch,
|
||||
EvaluatePermissionRequestBatch,
|
||||
PermissionEvaluator,
|
||||
PolicyDecision,
|
||||
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 +59,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 authorizeDecisionSchema: z.ZodSchema<DefinitivePolicyDecision> = z.object(
|
||||
{
|
||||
result: z
|
||||
.literal(AuthorizeResult.ALLOW)
|
||||
.or(z.literal(AuthorizeResult.DENY)),
|
||||
},
|
||||
);
|
||||
|
||||
const policyDecisionSchema: z.ZodSchema<PolicyDecision> = 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 +119,67 @@ export class PermissionClient implements PermissionAuthorizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request authorization from the permission-backend for the given set of permissions.
|
||||
* 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.
|
||||
* @remarks
|
||||
*
|
||||
* Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`.
|
||||
* Checks that a given Backstage user can perform a protected operation. When
|
||||
* authorization is for a {@link ResourcePermission}s, a resourceRef
|
||||
* corresponding to the resource should always be supplied along with the
|
||||
* permission. The Backstage identity token should be included in the
|
||||
* `options` if available.
|
||||
*
|
||||
* Permissions can be imported from plugins exposing them, such as
|
||||
* `catalogEntityReadPermission`.
|
||||
*
|
||||
* For each query, the response will be either ALLOW or DENY.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
async authorize(
|
||||
queries: EvaluatePermissionRequest[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
): Promise<EvaluatePermissionResponse[]> {
|
||||
requests: AuthorizePermissionRequest[],
|
||||
options?: EvaluatorRequestOptions,
|
||||
): Promise<AuthorizePermissionResponse[]> {
|
||||
// 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.
|
||||
|
||||
return this.makeRequest(requests, authorizeDecisionSchema, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the conditional authorization decisions for the given set of
|
||||
* {@link ResourcePermission}s in order to apply the conditions to an upstream
|
||||
* data source.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* For each query, the response will be either ALLOW, DENY, or CONDITIONAL.
|
||||
* 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
|
||||
*/
|
||||
async query(
|
||||
queries: QueryPermissionRequest[],
|
||||
options?: EvaluatorRequestOptions,
|
||||
): Promise<QueryPermissionResponse[]> {
|
||||
return this.makeRequest(queries, policyDecisionSchema, 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 = {
|
||||
items: queries.map(query => ({
|
||||
id: uuid.v4(),
|
||||
...query,
|
||||
@@ -141,12 +200,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, typeof itemSchema._type>);
|
||||
|
||||
return request.items.map(query => responsesById[query.id]);
|
||||
}
|
||||
@@ -154,20 +217,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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,20 @@ export type PolicyDecision =
|
||||
| DefinitivePolicyDecision
|
||||
| ConditionalPolicyDecision;
|
||||
|
||||
/**
|
||||
* A query to be evaluated by the {@link PermissionPolicy}.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Unlike other parts of the permission API, the policy does not accept a resource ref. This keeps
|
||||
* the policy decoupled from the resource loading and condition applying logic.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PolicyQuery = {
|
||||
permission: Permission;
|
||||
};
|
||||
|
||||
/**
|
||||
* A condition returned with a CONDITIONAL authorization response.
|
||||
*
|
||||
|
||||
@@ -36,6 +36,7 @@ export type {
|
||||
AllOfCriteria,
|
||||
AnyOfCriteria,
|
||||
NotCriteria,
|
||||
PolicyQuery,
|
||||
} from './api';
|
||||
export type { DiscoveryApi } from './discovery';
|
||||
export type {
|
||||
|
||||
@@ -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,126 @@ 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('query', () => {
|
||||
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.query([{ 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.query([{ 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.query([{ 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;
|
||||
@@ -76,22 +78,22 @@ export class ServerPermissionClient implements PermissionAuthorizer {
|
||||
this.tokenManager = options.tokenManager;
|
||||
this.permissionEnabled = options.permissionEnabled;
|
||||
}
|
||||
async query(
|
||||
queries: QueryPermissionRequest[],
|
||||
options?: EvaluatorRequestOptions,
|
||||
): Promise<PolicyDecision[]> {
|
||||
return (await this.isEnabled(options?.token))
|
||||
? this.permissionClient.query(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 +107,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type { PermissionPolicy, PolicyQuery } from './types';
|
||||
export type { PermissionPolicy } from './types';
|
||||
|
||||
@@ -15,25 +15,11 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Permission,
|
||||
PolicyDecision,
|
||||
PolicyQuery,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* A query to be evaluated by the {@link PermissionPolicy}.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Unlike other parts of the permission API, the policy does not accept a resource ref. This keeps
|
||||
* the policy decoupled from the resource loading and condition applying logic.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PolicyQuery = {
|
||||
permission: Permission;
|
||||
};
|
||||
|
||||
/**
|
||||
* A policy to evaluate authorization requests for any permissioned action performed in Backstage.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user