Merge pull request #9115 from backstage/vinzscam/authz-search-backend

Add AuthorizedSearchEngine tests
This commit is contained in:
MT Lewis
2022-01-25 18:26:05 +00:00
committed by GitHub
2 changed files with 512 additions and 1 deletions
@@ -0,0 +1,511 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import {
AuthorizeDecision,
AuthorizeResult,
PermissionAuthorizer,
} from '@backstage/plugin-permission-common';
import {
DocumentTypeInfo,
IndexableDocument,
SearchEngine,
} from '@backstage/search-common';
import {
encodePageCursor,
decodePageCursor,
AuthorizedSearchEngine,
} from './AuthorizedSearchEngine';
describe('AuthorizedSearchEngine', () => {
const typeUsers = 'users';
const typeTemplates = 'templates';
const typeServices = 'services';
const typeGroups = 'groups';
function generateSampleResults(type: string, withAuthorization?: boolean) {
return Array(10)
.fill(0)
.map((_, index) => ({
type,
document: {
title: `${type}_doc_${index}`,
authorization: withAuthorization
? { resourceRef: `${type}_doc_${index}` }
: undefined,
} as IndexableDocument,
}));
}
const allUsers = generateSampleResults(typeUsers);
const allTemplates = generateSampleResults(typeTemplates);
const results = allUsers.concat(allTemplates);
const mockedQuery: jest.MockedFunction<SearchEngine['query']> = jest.fn();
const searchEngine: SearchEngine = {
setTranslator: () => {
throw new Error('Function not implemented. 1');
},
index: () => {
throw new Error('Function not implemented.2');
},
query: mockedQuery,
};
const mockedAuthorize: jest.MockedFunction<
PermissionAuthorizer['authorize']
> = jest.fn();
const permissionAuthorizer: PermissionAuthorizer = {
authorize: mockedAuthorize,
};
const defaultTypes: Record<string, DocumentTypeInfo> = {
[typeUsers]: {
visibilityPermission: {
name: 'search.users.read',
attributes: { action: 'read' },
},
},
[typeTemplates]: {
visibilityPermission: {
name: 'search.templates.read',
attributes: { action: 'read' },
},
},
[typeServices]: {
visibilityPermission: {
name: 'search.services.read',
attributes: { action: 'read' },
},
},
[typeGroups]: {
visibilityPermission: {
name: 'search.groups.read',
attributes: { action: 'read' },
},
},
};
const authorizedSearchEngine = new AuthorizedSearchEngine(
searchEngine,
defaultTypes,
permissionAuthorizer,
new ConfigReader({}),
);
const options = { token: 'token' };
const allowAll: PermissionAuthorizer['authorize'] = async queries => {
return queries.map(() => ({
result: AuthorizeResult.ALLOW,
}));
};
beforeEach(() => {
mockedQuery.mockReset();
mockedAuthorize.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'] },
options,
);
expect(mockedQuery).toHaveBeenCalledWith(
{
term: 'term',
types: ['one', 'two'],
filters,
},
{ token: 'token' },
);
});
it('should forward the default types if none are passed', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
await authorizedSearchEngine.query({ term: '' }, options);
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users', 'templates', 'services', 'groups'] },
{ token: 'token' },
);
});
it('should return all the results if all queries are allowed', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
await expect(
authorizedSearchEngine.query({ term: '' }, options),
).resolves.toEqual({ results });
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
});
it('should batch authorized requests', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(allowAll);
await authorizedSearchEngine.query(
{ term: '', types: [typeUsers, typeTemplates] },
options,
);
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users', 'templates'] },
{ token: 'token' },
);
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
expect(mockedAuthorize).toHaveBeenLastCalledWith(
[
{ permission: defaultTypes[typeUsers].visibilityPermission },
{ permission: defaultTypes[typeTemplates].visibilityPermission },
],
{ token: 'token' },
);
});
it('should skip sending request for types that are not allowed', async () => {
mockedQuery.mockImplementation(async () => ({ results }));
mockedAuthorize.mockImplementation(async queries => {
return queries.map(query => {
if (
query.permission.name ===
defaultTypes.users.visibilityPermission?.name
) {
return {
result: AuthorizeResult.DENY,
};
}
return {
result: AuthorizeResult.ALLOW,
};
});
});
await authorizedSearchEngine.query({ term: '' }, options);
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['templates', 'services', 'groups'] },
{ token: 'token' },
);
expect(mockedAuthorize).toHaveBeenCalledTimes(1);
});
it('should perform result-by-result filtering', async () => {
const usersWithAuth = generateSampleResults(typeUsers, true);
const templatesWithAuth = generateSampleResults(typeTemplates, true);
const resultsWithAuth = usersWithAuth.concat(templatesWithAuth);
mockedQuery.mockImplementation(async () => ({
results: resultsWithAuth,
}));
const userToBeReturned = 8;
mockedAuthorize.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 AuthorizeDecision;
}
return {
result: AuthorizeResult.DENY,
};
}),
);
await expect(
authorizedSearchEngine.query({ term: '' }, options),
).resolves.toEqual({ results: [usersWithAuth[userToBeReturned]] });
expect(mockedQuery).toHaveBeenCalledWith(
{ term: '', types: ['users'] },
{ token: 'token' },
);
});
it('should perform search until the target number of results is reached', async () => {
mockedAuthorize.mockImplementation(async queries =>
queries.map(query => {
if (query.resourceRef) {
return {
result: AuthorizeResult.ALLOW,
};
}
return { result: AuthorizeResult.CONDITIONAL } as AuthorizeDecision;
}),
);
const usersWithAuth = generateSampleResults(typeUsers, true);
const templatesWithAuth = generateSampleResults(typeTemplates, true);
const servicesWithAuth = generateSampleResults(typeServices, true);
const allDocuments = [
...usersWithAuth,
...templatesWithAuth,
...servicesWithAuth,
];
mockedQuery
.mockImplementationOnce(async () => ({
results: allDocuments.slice(0, 10),
nextPageCursor: encodePageCursor({ page: 1 }),
}))
.mockImplementationOnce(async () => ({
results: allDocuments.slice(10, 20),
nextPageCursor: encodePageCursor({ page: 2 }),
}))
.mockImplementationOnce(async () => ({
results: allDocuments.slice(20, 30),
}));
const result = await authorizedSearchEngine.query(
{ term: '', types: ['users', 'templates', 'services'] },
options,
);
expect(mockedQuery).toHaveBeenCalledTimes(3);
expect(mockedQuery).toHaveBeenNthCalledWith(
1,
{ term: '', types: ['users', 'templates', 'services'] },
{ token: 'token' },
);
expect(mockedQuery).toHaveBeenNthCalledWith(
2,
{
term: '',
types: ['users', 'templates', 'services'],
pageCursor: 'MQ==',
},
{ token: 'token' },
);
expect(mockedQuery).toHaveBeenNthCalledWith(
3,
{
term: '',
types: ['users', 'templates', 'services'],
pageCursor: 'Mg==',
},
{ token: 'token' },
);
const expectedResult = allDocuments.slice(0, 25);
const expectedFirstRequestCursor = 'MQ==';
expect(result).toEqual({
results: expectedResult,
nextPageCursor: expectedFirstRequestCursor,
});
});
it('should perform search until the target number of results is reached, excluding unauthorized results', async () => {
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 AuthorizeDecision;
}),
);
const usersWithAuth = generateSampleResults(typeUsers, true);
const templatesWithAuth = generateSampleResults(typeTemplates, true);
const servicesWithAuth = generateSampleResults(typeServices, true);
const groupsWithAuth = generateSampleResults(typeGroups, true);
const allDocuments = [
...usersWithAuth,
...templatesWithAuth,
...servicesWithAuth,
...groupsWithAuth,
].sort(() => Math.floor(Math.random() * 3 - 1));
mockedQuery
.mockImplementationOnce(async () => ({
results: allDocuments.slice(0, 10),
nextPageCursor: encodePageCursor({ page: 1 }),
}))
.mockImplementationOnce(async () => ({
results: allDocuments.slice(10, 20),
nextPageCursor: encodePageCursor({ page: 2 }),
}))
.mockImplementationOnce(async () => ({
results: allDocuments.slice(20, 30),
nextPageCursor: encodePageCursor({ page: 3 }),
}))
.mockImplementationOnce(async () => ({
results: allDocuments.slice(30, 40),
}));
const result = await authorizedSearchEngine.query({ term: '' }, options);
// check if a fourth request is needed for retrieving all results
const fourthRequestNeeded =
allDocuments.slice(0, 30).filter(d => d.type !== typeServices).length <
25;
expect(mockedQuery).toHaveBeenCalledTimes(fourthRequestNeeded ? 4 : 3);
expect(mockedQuery).toHaveBeenNthCalledWith(
1,
{ term: '', types: ['users', 'templates', 'services', 'groups'] },
{ token: 'token' },
);
expect(mockedQuery).toHaveBeenNthCalledWith(
2,
{
term: '',
types: ['users', 'templates', 'services', 'groups'],
pageCursor: 'MQ==',
},
{ token: 'token' },
);
expect(mockedQuery).toHaveBeenNthCalledWith(
3,
{
term: '',
types: ['users', 'templates', 'services', 'groups'],
pageCursor: 'Mg==',
},
{ token: 'token' },
);
const expectedResult = allDocuments
.filter(d => d.type !== typeServices)
.slice(0, 25);
const expectedFirstRequestCursor = 'MQ==';
expect(result).toEqual({
results: expectedResult,
nextPageCursor: expectedFirstRequestCursor,
});
});
it('should discard results until the target cursor is reached', async () => {
mockedAuthorize.mockImplementation(async queries =>
queries.map(query => {
if (query.resourceRef) {
return { result: AuthorizeResult.ALLOW };
}
return { result: AuthorizeResult.CONDITIONAL } as AuthorizeDecision;
}),
);
const usersWithAuth = generateSampleResults(typeUsers, true);
const templatesWithAuth = generateSampleResults(typeTemplates, true);
const servicesWithAuth = generateSampleResults(typeServices, true);
mockedQuery
.mockImplementationOnce(async () => ({
results: usersWithAuth,
nextPageCursor: encodePageCursor({ page: 1 }),
}))
.mockImplementationOnce(async () => ({
results: templatesWithAuth,
nextPageCursor: encodePageCursor({ page: 2 }),
}))
.mockImplementationOnce(async () => ({
results: servicesWithAuth,
}));
const startingFromCursor = encodePageCursor({ page: 1 });
const result = await authorizedSearchEngine.query(
{
term: '',
pageCursor: startingFromCursor,
types: ['users', 'templates', 'services'],
},
options,
);
expect(mockedQuery).toHaveBeenCalledTimes(3);
expect(mockedQuery).toHaveBeenNthCalledWith(
1,
{ term: '', types: ['users', 'templates', 'services'] },
{ token: 'token' },
);
expect(mockedQuery).toHaveBeenNthCalledWith(
2,
{
term: '',
types: ['users', 'templates', 'services'],
pageCursor: 'MQ==',
},
{ token: 'token' },
);
expect(mockedQuery).toHaveBeenNthCalledWith(
3,
{
term: '',
types: ['users', 'templates', 'services'],
pageCursor: 'Mg==',
},
{ token: 'token' },
);
expect(result).toEqual({
results: servicesWithAuth.slice(5),
previousPageCursor: encodePageCursor({ page: 0 }),
});
});
});
describe('decodePageCursor', () => {
it('should correctly decode the cursor', () => {
expect(decodePageCursor()).toEqual({ page: 0 });
expect(decodePageCursor(encodePageCursor({ page: 1 }))).toEqual({
page: 1,
});
expect(decodePageCursor('Mg==')).toEqual({
page: 2,
});
expect(decodePageCursor(encodePageCursor({ page: 0 }))).toEqual({
page: 0,
});
expect(decodePageCursor(encodePageCursor({ page: 100 }))).toEqual({
page: 100,
});
});
it('should throw an error if the cursor is not valid', () => {
expect(() => decodePageCursor(encodePageCursor({ page: -100 }))).toThrow();
expect(() => decodePageCursor('something')).toThrow();
});
});
@@ -59,7 +59,7 @@ export function encodePageCursor({ page }: { page: number }): string {
}
export class AuthorizedSearchEngine implements SearchEngine {
private readonly pageSize: number = 25;
private readonly pageSize = 25;
private readonly queryLatencyBudgetMs: number;
constructor(