[data.search] Add user information to background session service (#84975)

* [data.search] Move search method inside session service and add tests

* Move background session service to data_enhanced plugin

* Fix types

* [data.search] Add user information to background session service

* Update trackId & getId to accept user

* Fix remaining merge conflicts

* Fix test

* Remove todos

* Fix session service to use user

* Remove user conflicts and update SO filter

* Allow filter as string or KQL node

* Add back user checks

* Add API integration tests

* Remove unnecessary get calls
This commit is contained in:
Lukas Olson 2021-02-13 01:42:56 -07:00 committed by GitHub
parent 874fadf388
commit 104eacb59a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 839 additions and 197 deletions

View file

@ -57,6 +57,12 @@ export interface SearchSessionSavedObjectAttributes {
* This value is true if the session was actively stored by the user. If it is false, the session may be purged by the system.
*/
persisted: boolean;
/**
* The realm type/name & username uniquely identifies the user who created this search session
*/
realmType?: string;
realmName?: string;
username?: string;
}
export interface SearchSessionRequestInfo {

View file

@ -4,7 +4,7 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "data_enhanced"],
"requiredPlugins": ["bfetch", "data", "features", "management", "share", "taskManager"],
"optionalPlugins": ["kibanaUtils", "usageCollection"],
"optionalPlugins": ["kibanaUtils", "usageCollection", "security"],
"server": true,
"ui": true,
"requiredBundles": ["kibanaUtils", "kibanaReact"]

View file

@ -24,12 +24,15 @@ import {
import { getUiSettings } from './ui_settings';
import type { DataEnhancedRequestHandlerContext } from './type';
import { ConfigSchema } from '../config';
import { SecurityPluginSetup } from '../../security/server';
interface SetupDependencies {
data: DataPluginSetup;
usageCollection?: UsageCollectionSetup;
taskManager: TaskManagerSetupContract;
security?: SecurityPluginSetup;
}
export interface StartDependencies {
data: DataPluginStart;
taskManager: TaskManagerStartContract;
@ -67,7 +70,7 @@ export class EnhancedDataServerPlugin
eqlSearchStrategyProvider(this.logger)
);
this.sessionService = new SearchSessionService(this.logger, this.config);
this.sessionService = new SearchSessionService(this.logger, this.config, deps.security);
deps.data.__enhance({
search: {

View file

@ -53,6 +53,15 @@ export const searchSessionMapping: SavedObjectsType = {
type: 'object',
enabled: false,
},
realmType: {
type: 'keyword',
},
realmName: {
type: 'keyword',
},
username: {
type: 'keyword',
},
},
},
};

View file

@ -19,6 +19,8 @@ import { coreMock } from 'src/core/server/mocks';
import { ConfigSchema } from '../../../config';
// @ts-ignore
import { taskManagerMock } from '../../../../task_manager/server/mocks';
import { AuthenticatedUser } from '../../../../security/common/model';
import { nodeBuilder } from '../../../../../../src/plugins/data/common';
const MAX_UPDATE_RETRIES = 3;
@ -31,7 +33,21 @@ describe('SearchSessionService', () => {
const MOCK_STRATEGY = 'ese';
const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489';
const mockSavedObject: SavedObject = {
const mockUser1 = {
username: 'my_username',
authentication_realm: {
type: 'my_realm_type',
name: 'my_realm_name',
},
} as AuthenticatedUser;
const mockUser2 = {
username: 'bar',
authentication_realm: {
type: 'bar',
name: 'bar',
},
} as AuthenticatedUser;
const mockSavedObject: SavedObject<any> = {
id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
type: SEARCH_SESSION_TYPE,
attributes: {
@ -39,6 +55,9 @@ describe('SearchSessionService', () => {
appId: 'my_app_id',
urlGeneratorId: 'my_url_generator_id',
idMapping: {},
realmType: mockUser1.authentication_realm.type,
realmName: mockUser1.authentication_realm.name,
username: mockUser1.username,
},
references: [],
};
@ -77,66 +96,551 @@ describe('SearchSessionService', () => {
service.stop();
});
it('get calls saved objects client', async () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
describe('save', () => {
it('throws if `name` is not provided', () => {
expect(() =>
service.save({ savedObjectsClient }, mockUser1, sessionId, {})
).rejects.toMatchInlineSnapshot(`[Error: Name is required]`);
});
const response = await service.get({ savedObjectsClient }, sessionId);
it('throws if `appId` is not provided', () => {
expect(
service.save({ savedObjectsClient }, mockUser1, sessionId, { name: 'banana' })
).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`);
});
expect(response).toBe(mockSavedObject);
expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId);
});
it('throws if `generator id` is not provided', () => {
expect(
service.save({ savedObjectsClient }, mockUser1, sessionId, {
name: 'banana',
appId: 'nanana',
})
).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`);
});
it('find calls saved objects client', async () => {
const mockFindSavedObject = {
...mockSavedObject,
score: 1,
};
const mockResponse = {
saved_objects: [mockFindSavedObject],
total: 1,
per_page: 1,
page: 0,
};
savedObjectsClient.find.mockResolvedValue(mockResponse);
it('saving updates an existing saved object and persists it', async () => {
const mockUpdateSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
const options = { page: 0, perPage: 5 };
const response = await service.find({ savedObjectsClient }, options);
await service.save({ savedObjectsClient }, mockUser1, sessionId, {
name: 'banana',
appId: 'nanana',
urlGeneratorId: 'panama',
});
expect(response).toBe(mockResponse);
expect(savedObjectsClient.find).toHaveBeenCalledWith({
...options,
type: SEARCH_SESSION_TYPE,
expect(savedObjectsClient.update).toHaveBeenCalled();
expect(savedObjectsClient.create).not.toHaveBeenCalled();
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).not.toHaveProperty('idMapping');
expect(callAttributes).toHaveProperty('touched');
expect(callAttributes).toHaveProperty('persisted', true);
expect(callAttributes).toHaveProperty('name', 'banana');
expect(callAttributes).toHaveProperty('appId', 'nanana');
expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama');
expect(callAttributes).toHaveProperty('initialState', {});
expect(callAttributes).toHaveProperty('restoreState', {});
});
it('saving creates a new persisted saved object, if it did not exist', async () => {
const mockCreatedSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.update.mockRejectedValue(
SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId)
);
savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject);
await service.save({ savedObjectsClient }, mockUser1, sessionId, {
name: 'banana',
appId: 'nanana',
urlGeneratorId: 'panama',
});
expect(savedObjectsClient.update).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(options?.id).toBe(sessionId);
expect(callAttributes).toHaveProperty('idMapping', {});
expect(callAttributes).toHaveProperty('touched');
expect(callAttributes).toHaveProperty('expires');
expect(callAttributes).toHaveProperty('created');
expect(callAttributes).toHaveProperty('persisted', true);
expect(callAttributes).toHaveProperty('name', 'banana');
expect(callAttributes).toHaveProperty('appId', 'nanana');
expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama');
expect(callAttributes).toHaveProperty('initialState', {});
expect(callAttributes).toHaveProperty('restoreState', {});
expect(callAttributes).toHaveProperty('realmType', mockUser1.authentication_realm.type);
expect(callAttributes).toHaveProperty('realmName', mockUser1.authentication_realm.name);
expect(callAttributes).toHaveProperty('username', mockUser1.username);
});
it('throws error if user conflicts', () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
expect(
service.get({ savedObjectsClient }, mockUser2, sessionId)
).rejects.toMatchInlineSnapshot(`[Error: Not Found]`);
});
it('works without security', async () => {
savedObjectsClient.update.mockRejectedValue(
SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId)
);
await service.save(
{ savedObjectsClient },
null,
sessionId,
{
name: 'my_name',
appId: 'my_app_id',
urlGeneratorId: 'my_url_generator_id',
}
);
expect(savedObjectsClient.create).toHaveBeenCalled();
const [[, attributes]] = savedObjectsClient.create.mock.calls;
expect(attributes).toHaveProperty('realmType', undefined);
expect(attributes).toHaveProperty('realmName', undefined);
expect(attributes).toHaveProperty('username', undefined);
});
});
it('update calls saved objects client with added touch time', async () => {
const mockUpdateSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
describe('get', () => {
it('calls saved objects client', async () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
const attributes = { name: 'new_name' };
const response = await service.update({ savedObjectsClient }, sessionId, attributes);
const response = await service.get({ savedObjectsClient }, mockUser1, sessionId);
expect(response).toBe(mockUpdateSavedObject);
expect(response).toBe(mockSavedObject);
expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId);
});
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
it('works without security', async () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).toHaveProperty('name', attributes.name);
expect(callAttributes).toHaveProperty('touched');
const response = await service.get({ savedObjectsClient }, null, sessionId);
expect(response).toBe(mockSavedObject);
expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId);
});
});
it('cancel updates object status', async () => {
await service.cancel({ savedObjectsClient }, sessionId);
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
describe('find', () => {
it('calls saved objects client with user filter', async () => {
const mockFindSavedObject = {
...mockSavedObject,
score: 1,
};
const mockResponse = {
saved_objects: [mockFindSavedObject],
total: 1,
per_page: 1,
page: 0,
};
savedObjectsClient.find.mockResolvedValue(mockResponse);
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED);
expect(callAttributes).toHaveProperty('touched');
const options = { page: 0, perPage: 5 };
const response = await service.find({ savedObjectsClient }, mockUser1, options);
expect(response).toBe(mockResponse);
const [[findOptions]] = savedObjectsClient.find.mock.calls;
expect(findOptions).toMatchInlineSnapshot(`
Object {
"filter": Object {
"arguments": Array [
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.realmType",
},
Object {
"type": "literal",
"value": "my_realm_type",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.realmName",
},
Object {
"type": "literal",
"value": "my_realm_name",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.username",
},
Object {
"type": "literal",
"value": "my_username",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
],
"function": "and",
"type": "function",
},
"page": 0,
"perPage": 5,
"type": "search-session",
}
`);
});
it('mixes in passed-in filter as string and KQL node', async () => {
const mockFindSavedObject = {
...mockSavedObject,
score: 1,
};
const mockResponse = {
saved_objects: [mockFindSavedObject],
total: 1,
per_page: 1,
page: 0,
};
savedObjectsClient.find.mockResolvedValue(mockResponse);
const options1 = { filter: 'foobar' };
const response1 = await service.find({ savedObjectsClient }, mockUser1, options1);
const options2 = { filter: nodeBuilder.is('foo', 'bar') };
const response2 = await service.find({ savedObjectsClient }, mockUser1, options2);
expect(response1).toBe(mockResponse);
expect(response2).toBe(mockResponse);
const [[findOptions1], [findOptions2]] = savedObjectsClient.find.mock.calls;
expect(findOptions1).toMatchInlineSnapshot(`
Object {
"filter": Object {
"arguments": Array [
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.realmType",
},
Object {
"type": "literal",
"value": "my_realm_type",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.realmName",
},
Object {
"type": "literal",
"value": "my_realm_name",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.username",
},
Object {
"type": "literal",
"value": "my_username",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": null,
},
Object {
"type": "literal",
"value": "foobar",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
],
"function": "and",
"type": "function",
},
"type": "search-session",
}
`);
expect(findOptions2).toMatchInlineSnapshot(`
Object {
"filter": Object {
"arguments": Array [
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.realmType",
},
Object {
"type": "literal",
"value": "my_realm_type",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.realmName",
},
Object {
"type": "literal",
"value": "my_realm_name",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "search-session.attributes.username",
},
Object {
"type": "literal",
"value": "my_username",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
Object {
"arguments": Array [
Object {
"type": "literal",
"value": "foo",
},
Object {
"type": "literal",
"value": "bar",
},
Object {
"type": "literal",
"value": false,
},
],
"function": "is",
"type": "function",
},
],
"function": "and",
"type": "function",
},
"type": "search-session",
}
`);
});
it('has no filter without security', async () => {
const mockFindSavedObject = {
...mockSavedObject,
score: 1,
};
const mockResponse = {
saved_objects: [mockFindSavedObject],
total: 1,
per_page: 1,
page: 0,
};
savedObjectsClient.find.mockResolvedValue(mockResponse);
const options = { page: 0, perPage: 5 };
const response = await service.find({ savedObjectsClient }, null, options);
expect(response).toBe(mockResponse);
const [[findOptions]] = savedObjectsClient.find.mock.calls;
expect(findOptions).toMatchInlineSnapshot(`
Object {
"filter": undefined,
"page": 0,
"perPage": 5,
"type": "search-session",
}
`);
});
});
describe('update', () => {
it('update calls saved objects client with added touch time', async () => {
const mockUpdateSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
const attributes = { name: 'new_name' };
const response = await service.update(
{ savedObjectsClient },
mockUser1,
sessionId,
attributes
);
expect(response).toBe(mockUpdateSavedObject);
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).toHaveProperty('name', attributes.name);
expect(callAttributes).toHaveProperty('touched');
});
it('throws if user conflicts', () => {
const mockUpdateSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
const attributes = { name: 'new_name' };
expect(
service.update({ savedObjectsClient }, mockUser2, sessionId, attributes)
).rejects.toMatchInlineSnapshot(`[Error: Not Found]`);
});
it('works without security', async () => {
const mockUpdateSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
const attributes = { name: 'new_name' };
const response = await service.update({ savedObjectsClient }, null, sessionId, attributes);
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
expect(response).toBe(mockUpdateSavedObject);
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).toHaveProperty('name', 'new_name');
expect(callAttributes).toHaveProperty('touched');
});
});
describe('cancel', () => {
it('updates object status', async () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
await service.cancel({ savedObjectsClient }, mockUser1, sessionId);
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED);
expect(callAttributes).toHaveProperty('touched');
});
it('throws if user conflicts', () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
expect(
service.cancel({ savedObjectsClient }, mockUser2, sessionId)
).rejects.toMatchInlineSnapshot(`[Error: Not Found]`);
});
it('works without security', async () => {
savedObjectsClient.get.mockResolvedValue(mockSavedObject);
await service.cancel({ savedObjectsClient }, null, sessionId);
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED);
expect(callAttributes).toHaveProperty('touched');
});
});
describe('trackId', () => {
@ -151,7 +655,7 @@ describe('SearchSessionService', () => {
};
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
await service.trackId({ savedObjectsClient }, searchRequest, searchId, {
await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, {
sessionId,
strategy: MOCK_STRATEGY,
});
@ -194,7 +698,7 @@ describe('SearchSessionService', () => {
});
});
await service.trackId({ savedObjectsClient }, searchRequest, searchId, {
await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, {
sessionId,
strategy: MOCK_STRATEGY,
});
@ -213,7 +717,7 @@ describe('SearchSessionService', () => {
});
});
await service.trackId({ savedObjectsClient }, searchRequest, searchId, {
await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, {
sessionId,
strategy: MOCK_STRATEGY,
});
@ -238,7 +742,7 @@ describe('SearchSessionService', () => {
);
savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject);
await service.trackId({ savedObjectsClient }, searchRequest, searchId, {
await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, {
sessionId,
strategy: MOCK_STRATEGY,
});
@ -289,7 +793,7 @@ describe('SearchSessionService', () => {
SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId)
);
await service.trackId({ savedObjectsClient }, searchRequest, searchId, {
await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, {
sessionId,
strategy: MOCK_STRATEGY,
});
@ -309,7 +813,7 @@ describe('SearchSessionService', () => {
SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId)
);
await service.trackId({ savedObjectsClient }, searchRequest, searchId, {
await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, {
sessionId,
strategy: MOCK_STRATEGY,
});
@ -341,15 +845,15 @@ describe('SearchSessionService', () => {
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
await Promise.all([
service.trackId({ savedObjectsClient }, searchRequest1, searchId1, {
service.trackId({ savedObjectsClient }, mockUser1, searchRequest1, searchId1, {
sessionId: sessionId1,
strategy: MOCK_STRATEGY,
}),
service.trackId({ savedObjectsClient }, searchRequest2, searchId2, {
service.trackId({ savedObjectsClient }, mockUser1, searchRequest2, searchId2, {
sessionId: sessionId1,
strategy: MOCK_STRATEGY,
}),
service.trackId({ savedObjectsClient }, searchRequest3, searchId3, {
service.trackId({ savedObjectsClient }, mockUser1, searchRequest3, searchId3, {
sessionId: sessionId2,
strategy: MOCK_STRATEGY,
}),
@ -394,7 +898,7 @@ describe('SearchSessionService', () => {
const searchRequest = { params: {} };
expect(() =>
service.getId({ savedObjectsClient }, searchRequest, {})
service.getId({ savedObjectsClient }, mockUser1, searchRequest, {})
).rejects.toMatchInlineSnapshot(`[Error: Session ID is required]`);
});
@ -402,7 +906,10 @@ describe('SearchSessionService', () => {
const searchRequest = { params: {} };
expect(() =>
service.getId({ savedObjectsClient }, searchRequest, { sessionId, isStored: false })
service.getId({ savedObjectsClient }, mockUser1, searchRequest, {
sessionId,
isStored: false,
})
).rejects.toMatchInlineSnapshot(
`[Error: Cannot get search ID from a session that is not stored]`
);
@ -412,7 +919,7 @@ describe('SearchSessionService', () => {
const searchRequest = { params: {} };
expect(() =>
service.getId({ savedObjectsClient }, searchRequest, {
service.getId({ savedObjectsClient }, mockUser1, searchRequest, {
sessionId,
isStored: true,
isRestore: false,
@ -427,24 +934,19 @@ describe('SearchSessionService', () => {
const requestHash = createRequestHash(searchRequest.params);
const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0';
const mockSession = {
id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
type: SEARCH_SESSION_TYPE,
...mockSavedObject,
attributes: {
name: 'my_name',
appId: 'my_app_id',
urlGeneratorId: 'my_url_generator_id',
...mockSavedObject.attributes,
idMapping: {
[requestHash]: {
id: searchId,
strategy: MOCK_STRATEGY,
},
},
},
references: [],
};
savedObjectsClient.get.mockResolvedValue(mockSession);
const id = await service.getId({ savedObjectsClient }, searchRequest, {
const id = await service.getId({ savedObjectsClient }, mockUser1, searchRequest, {
sessionId,
isStored: true,
isRestore: true,
@ -457,12 +959,9 @@ describe('SearchSessionService', () => {
describe('getSearchIdMapping', () => {
it('retrieves the search IDs and strategies from the saved object', async () => {
const mockSession = {
id: 'd7170a35-7e2c-48d6-8dec-9a056721b489',
type: SEARCH_SESSION_TYPE,
...mockSavedObject,
attributes: {
name: 'my_name',
appId: 'my_app_id',
urlGeneratorId: 'my_url_generator_id',
...mockSavedObject.attributes,
idMapping: {
foo: {
id: 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0',
@ -470,11 +969,11 @@ describe('SearchSessionService', () => {
},
},
},
references: [],
};
savedObjectsClient.get.mockResolvedValue(mockSession);
const searchIdMapping = await service.getSearchIdMapping(
{ savedObjectsClient },
mockUser1,
mockSession.id
);
expect(searchIdMapping).toMatchInlineSnapshot(`
@ -484,88 +983,4 @@ describe('SearchSessionService', () => {
`);
});
});
describe('save', () => {
it('save throws if `name` is not provided', () => {
expect(service.save({ savedObjectsClient }, sessionId, {})).rejects.toMatchInlineSnapshot(
`[Error: Name is required]`
);
});
it('save throws if `appId` is not provided', () => {
expect(
service.save({ savedObjectsClient }, sessionId, { name: 'banana' })
).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`);
});
it('save throws if `generator id` is not provided', () => {
expect(
service.save({ savedObjectsClient }, sessionId, { name: 'banana', appId: 'nanana' })
).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`);
});
it('saving updates an existing saved object and persists it', async () => {
const mockUpdateSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject);
await service.save({ savedObjectsClient }, sessionId, {
name: 'banana',
appId: 'nanana',
urlGeneratorId: 'panama',
});
expect(savedObjectsClient.update).toHaveBeenCalled();
expect(savedObjectsClient.create).not.toHaveBeenCalled();
const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(id).toBe(sessionId);
expect(callAttributes).not.toHaveProperty('idMapping');
expect(callAttributes).toHaveProperty('touched');
expect(callAttributes).toHaveProperty('persisted', true);
expect(callAttributes).toHaveProperty('name', 'banana');
expect(callAttributes).toHaveProperty('appId', 'nanana');
expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama');
expect(callAttributes).toHaveProperty('initialState', {});
expect(callAttributes).toHaveProperty('restoreState', {});
});
it('saving creates a new persisted saved object, if it did not exist', async () => {
const mockCreatedSavedObject = {
...mockSavedObject,
attributes: {},
};
savedObjectsClient.update.mockRejectedValue(
SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId)
);
savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject);
await service.save({ savedObjectsClient }, sessionId, {
name: 'banana',
appId: 'nanana',
urlGeneratorId: 'panama',
});
expect(savedObjectsClient.update).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.create).toHaveBeenCalledTimes(1);
const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0];
expect(type).toBe(SEARCH_SESSION_TYPE);
expect(options?.id).toBe(sessionId);
expect(callAttributes).toHaveProperty('idMapping', {});
expect(callAttributes).toHaveProperty('touched');
expect(callAttributes).toHaveProperty('expires');
expect(callAttributes).toHaveProperty('created');
expect(callAttributes).toHaveProperty('persisted', true);
expect(callAttributes).toHaveProperty('name', 'banana');
expect(callAttributes).toHaveProperty('appId', 'nanana');
expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama');
expect(callAttributes).toHaveProperty('initialState', {});
expect(callAttributes).toHaveProperty('restoreState', {});
});
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { notFound } from '@hapi/boom';
import { debounce } from 'lodash';
import {
CoreSetup,
@ -16,8 +17,13 @@ import {
SavedObjectsFindOptions,
SavedObjectsErrorHelpers,
} from '../../../../../../src/core/server';
import { IKibanaSearchRequest, ISearchOptions } from '../../../../../../src/plugins/data/common';
import { ISearchSessionService } from '../../../../../../src/plugins/data/server';
import {
IKibanaSearchRequest,
ISearchOptions,
nodeBuilder,
} from '../../../../../../src/plugins/data/common';
import { esKuery, ISearchSessionService } from '../../../../../../src/plugins/data/server';
import { AuthenticatedUser, SecurityPluginSetup } from '../../../../security/server';
import {
TaskManagerSetupContract,
TaskManagerStartContract,
@ -49,6 +55,7 @@ const DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT = 5000;
interface UpdateOrCreateQueueEntry {
deps: SearchSessionDependencies;
user: AuthenticatedUser | null;
sessionId: string;
attributes: Partial<SearchSessionSavedObjectAttributes>;
resolve: () => void;
@ -63,7 +70,11 @@ export class SearchSessionService
private sessionConfig: SearchSessionsConfig;
private readonly updateOrCreateBatchQueue: UpdateOrCreateQueueEntry[] = [];
constructor(private readonly logger: Logger, private readonly config: ConfigSchema) {
constructor(
private readonly logger: Logger,
private readonly config: ConfigSchema,
private readonly security?: SecurityPluginSetup
) {
this.sessionConfig = this.config.search.sessions;
}
@ -114,7 +125,12 @@ export class SearchSessionService
Object.keys(batchedSessionAttributes).forEach((sessionId) => {
const thisSession = queue.filter((s) => s.sessionId === sessionId);
this.updateOrCreate(thisSession[0].deps, sessionId, batchedSessionAttributes[sessionId])
this.updateOrCreate(
thisSession[0].deps,
thisSession[0].user,
sessionId,
batchedSessionAttributes[sessionId]
)
.then(() => {
thisSession.forEach((s) => s.resolve());
})
@ -128,11 +144,12 @@ export class SearchSessionService
);
private scheduleUpdateOrCreate = (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string,
attributes: Partial<SearchSessionSavedObjectAttributes>
): Promise<void> => {
return new Promise((resolve, reject) => {
this.updateOrCreateBatchQueue.push({ deps, sessionId, attributes, resolve, reject });
this.updateOrCreateBatchQueue.push({ deps, user, sessionId, attributes, resolve, reject });
// TODO: this would be better if we'd debounce per sessionId
this.processUpdateOrCreateBatchQueue();
});
@ -140,6 +157,7 @@ export class SearchSessionService
private updateOrCreate = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string,
attributes: Partial<SearchSessionSavedObjectAttributes>,
retry: number = 1
@ -148,13 +166,14 @@ export class SearchSessionService
this.logger.debug(`Conflict error | ${sessionId}`);
// Randomize sleep to spread updates out in case of conflicts
await sleep(100 + Math.random() * 50);
return await this.updateOrCreate(deps, sessionId, attributes, retry + 1);
return await this.updateOrCreate(deps, user, sessionId, attributes, retry + 1);
};
this.logger.debug(`updateOrCreate | ${sessionId} | ${retry}`);
try {
return (await this.update(
deps,
user,
sessionId,
attributes
)) as SavedObject<SearchSessionSavedObjectAttributes>;
@ -162,7 +181,7 @@ export class SearchSessionService
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
try {
this.logger.debug(`Object not found | ${sessionId}`);
return await this.create(deps, sessionId, attributes);
return await this.create(deps, user, sessionId, attributes);
} catch (createError) {
if (
SavedObjectsErrorHelpers.isConflictError(createError) &&
@ -188,6 +207,7 @@ export class SearchSessionService
public save = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string,
{
name,
@ -201,7 +221,7 @@ export class SearchSessionService
if (!appId) throw new Error('AppId is required');
if (!urlGeneratorId) throw new Error('UrlGeneratorId is required');
return this.updateOrCreate(deps, sessionId, {
return this.updateOrCreate(deps, user, sessionId, {
name,
appId,
urlGeneratorId,
@ -213,10 +233,16 @@ export class SearchSessionService
private create = (
{ savedObjectsClient }: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string,
attributes: Partial<SearchSessionSavedObjectAttributes>
) => {
this.logger.debug(`create | ${sessionId}`);
const realmType = user?.authentication_realm.type;
const realmName = user?.authentication_realm.name;
const username = user?.username;
return savedObjectsClient.create<SearchSessionSavedObjectAttributes>(
SEARCH_SESSION_TYPE,
{
@ -229,40 +255,69 @@ export class SearchSessionService
touched: new Date().toISOString(),
idMapping: {},
persisted: false,
realmType,
realmName,
username,
...attributes,
},
{ id: sessionId }
);
};
// TODO: Throw an error if this session doesn't belong to this user
public get = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => {
public get = async (
{ savedObjectsClient }: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string
) => {
this.logger.debug(`get | ${sessionId}`);
return savedObjectsClient.get<SearchSessionSavedObjectAttributes>(
const session = await savedObjectsClient.get<SearchSessionSavedObjectAttributes>(
SEARCH_SESSION_TYPE,
sessionId
);
this.throwOnUserConflict(user, session);
return session;
};
// TODO: Throw an error if this session doesn't belong to this user
public find = (
{ savedObjectsClient }: SearchSessionDependencies,
user: AuthenticatedUser | null,
options: Omit<SavedObjectsFindOptions, 'type'>
) => {
const userFilters =
user === null
? []
: [
nodeBuilder.is(
`${SEARCH_SESSION_TYPE}.attributes.realmType`,
`${user.authentication_realm.type}`
),
nodeBuilder.is(
`${SEARCH_SESSION_TYPE}.attributes.realmName`,
`${user.authentication_realm.name}`
),
nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.username`, `${user.username}`),
];
const filterKueryNode =
typeof options.filter === 'string'
? esKuery.fromKueryExpression(options.filter)
: options.filter;
const filter = nodeBuilder.and(userFilters.concat(filterKueryNode ?? []));
return savedObjectsClient.find<SearchSessionSavedObjectAttributes>({
...options,
filter,
type: SEARCH_SESSION_TYPE,
});
};
// TODO: Throw an error if this session doesn't belong to this user
public update = (
{ savedObjectsClient }: SearchSessionDependencies,
public update = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string,
attributes: Partial<SearchSessionSavedObjectAttributes>
) => {
this.logger.debug(`update | ${sessionId}`);
return savedObjectsClient.update<SearchSessionSavedObjectAttributes>(
await this.get(deps, user, sessionId); // Verify correct user
return deps.savedObjectsClient.update<SearchSessionSavedObjectAttributes>(
SEARCH_SESSION_TYPE,
sessionId,
{
@ -272,22 +327,35 @@ export class SearchSessionService
);
};
public extend(deps: SearchSessionDependencies, sessionId: string, expires: Date) {
public async extend(
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string,
expires: Date
) {
this.logger.debug(`extend | ${sessionId}`);
return this.update(deps, sessionId, { expires: expires.toISOString() });
return this.update(deps, user, sessionId, { expires: expires.toISOString() });
}
// TODO: Throw an error if this session doesn't belong to this user
public cancel = (deps: SearchSessionDependencies, sessionId: string) => {
return this.update(deps, sessionId, {
public cancel = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string
) => {
this.logger.debug(`delete | ${sessionId}`);
return this.update(deps, user, sessionId, {
status: SearchSessionStatus.CANCELLED,
});
};
// TODO: Throw an error if this session doesn't belong to this user
public delete = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => {
return savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId);
public delete = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string
) => {
this.logger.debug(`delete | ${sessionId}`);
await this.get(deps, user, sessionId); // Verify correct user
return deps.savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId);
};
/**
@ -296,6 +364,7 @@ export class SearchSessionService
*/
public trackId = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
searchRequest: IKibanaSearchRequest,
searchId: string,
{ sessionId, strategy }: ISearchOptions
@ -315,11 +384,15 @@ export class SearchSessionService
idMapping = { [requestHash]: searchInfo };
}
await this.scheduleUpdateOrCreate(deps, sessionId, { idMapping });
await this.scheduleUpdateOrCreate(deps, user, sessionId, { idMapping });
};
public async getSearchIdMapping(deps: SearchSessionDependencies, sessionId: string) {
const searchSession = await this.get(deps, sessionId);
public async getSearchIdMapping(
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
sessionId: string
) {
const searchSession = await this.get(deps, user, sessionId);
const searchIdMapping = new Map<string, string>();
Object.values(searchSession.attributes.idMapping).forEach((requestInfo) => {
searchIdMapping.set(requestInfo.id, requestInfo.strategy);
@ -334,6 +407,7 @@ export class SearchSessionService
*/
public getId = async (
deps: SearchSessionDependencies,
user: AuthenticatedUser | null,
searchRequest: IKibanaSearchRequest,
{ sessionId, isStored, isRestore }: ISearchOptions
) => {
@ -345,7 +419,7 @@ export class SearchSessionService
throw new Error('Get search ID is only supported when restoring a session');
}
const session = await this.get(deps, sessionId);
const session = await this.get(deps, user, sessionId);
const requestHash = createRequestHash(searchRequest.params);
if (!session.attributes.idMapping.hasOwnProperty(requestHash)) {
this.logger.error(`getId | ${sessionId} | ${requestHash} not found`);
@ -358,22 +432,40 @@ export class SearchSessionService
public asScopedProvider = ({ savedObjects }: CoreStart) => {
return (request: KibanaRequest) => {
const user = this.security?.authc.getCurrentUser(request) ?? null;
const savedObjectsClient = savedObjects.getScopedClient(request, {
includedHiddenTypes: [SEARCH_SESSION_TYPE],
});
const deps = { savedObjectsClient };
return {
getId: this.getId.bind(this, deps),
trackId: this.trackId.bind(this, deps),
getSearchIdMapping: this.getSearchIdMapping.bind(this, deps),
save: this.save.bind(this, deps),
get: this.get.bind(this, deps),
find: this.find.bind(this, deps),
update: this.update.bind(this, deps),
extend: this.extend.bind(this, deps),
cancel: this.cancel.bind(this, deps),
delete: this.delete.bind(this, deps),
getId: this.getId.bind(this, deps, user),
trackId: this.trackId.bind(this, deps, user),
getSearchIdMapping: this.getSearchIdMapping.bind(this, deps, user),
save: this.save.bind(this, deps, user),
get: this.get.bind(this, deps, user),
find: this.find.bind(this, deps, user),
update: this.update.bind(this, deps, user),
extend: this.extend.bind(this, deps, user),
cancel: this.cancel.bind(this, deps, user),
delete: this.delete.bind(this, deps, user),
};
};
};
private throwOnUserConflict = (
user: AuthenticatedUser | null,
session?: SavedObject<SearchSessionSavedObjectAttributes>
) => {
if (user === null || !session) return;
if (
user.authentication_realm.type !== session.attributes.realmType ||
user.authentication_realm.name !== session.attributes.realmName ||
user.username !== session.attributes.username
) {
this.logger.debug(
`User ${user.username} has no access to search session ${session.attributes.sessionId}`
);
throw notFound();
}
};
}

View file

@ -25,6 +25,7 @@
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../../src/plugins/management/tsconfig.json" },
{ "path": "../security/tsconfig.json" },
{ "path": "../task_manager/tsconfig.json" },
{ "path": "../features/tsconfig.json" },

View file

@ -328,6 +328,122 @@ export default function ({ getService }: FtrProviderContext) {
);
});
describe('with security', () => {
before(async () => {
await security.user.create('other_user', {
password: 'password',
roles: ['superuser'],
full_name: 'other user',
});
});
after(async () => {
await security.user.delete('other_user');
});
it(`should prevent users from accessing other users' sessions`, async () => {
const sessionId = `my-session-${Math.random()}`;
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
await supertestWithoutAuth
.get(`/internal/session/${sessionId}`)
.set('kbn-xsrf', 'foo')
.auth('other_user', 'password')
.expect(404);
});
it(`should prevent users from deleting other users' sessions`, async () => {
const sessionId = `my-session-${Math.random()}`;
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
await supertestWithoutAuth
.delete(`/internal/session/${sessionId}`)
.set('kbn-xsrf', 'foo')
.auth('other_user', 'password')
.expect(404);
});
it(`should prevent users from cancelling other users' sessions`, async () => {
const sessionId = `my-session-${Math.random()}`;
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
await supertestWithoutAuth
.post(`/internal/session/${sessionId}/cancel`)
.set('kbn-xsrf', 'foo')
.auth('other_user', 'password')
.expect(404);
});
it(`should prevent users from extending other users' sessions`, async () => {
const sessionId = `my-session-${Math.random()}`;
await supertest
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(200);
await supertestWithoutAuth
.post(`/internal/session/${sessionId}/_extend`)
.set('kbn-xsrf', 'foo')
.auth('other_user', 'password')
.send({
expires: '2021-02-26T21:02:43.742Z',
})
.expect(404);
});
it(`should prevent unauthorized users from creating sessions`, async () => {
const sessionId = `my-session-${Math.random()}`;
await supertestWithoutAuth
.post(`/internal/session`)
.set('kbn-xsrf', 'foo')
.send({
sessionId,
name: 'My Session',
appId: 'discover',
expires: '123',
urlGeneratorId: 'discover',
})
.expect(401);
});
});
describe('search session permissions', () => {
before(async () => {
await security.role.create('data_analyst', {