diff --git a/x-pack/plugins/data_enhanced/common/search/session/types.ts b/x-pack/plugins/data_enhanced/common/search/session/types.ts index 4c5fe846cebd..788ab30756e1 100644 --- a/x-pack/plugins/data_enhanced/common/search/session/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/session/types.ts @@ -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 { diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 037f52fcb4b0..a0489ecd30aa 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -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"] diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 3aaf50fbeb3e..c3d342b8159e 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -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: { diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts index fe522005e455..fd3d24b71f97 100644 --- a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts @@ -53,6 +53,15 @@ export const searchSessionMapping: SavedObjectsType = { type: 'object', enabled: false, }, + realmType: { + type: 'keyword', + }, + realmName: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, }, }, }; diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts index b195a32ad481..f61d89e2301a 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.test.ts @@ -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 = { 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', {}); - }); - }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts index 6a36b1b4859e..c95c58a8dc06 100644 --- a/x-pack/plugins/data_enhanced/server/search/session/session_service.ts +++ b/x-pack/plugins/data_enhanced/server/search/session/session_service.ts @@ -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; 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 ): Promise => { 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, 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; @@ -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 ) => { this.logger.debug(`create | ${sessionId}`); + + const realmType = user?.authentication_realm.type; + const realmName = user?.authentication_realm.name; + const username = user?.username; + return savedObjectsClient.create( 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( + const session = await savedObjectsClient.get( 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 ) => { + 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({ ...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 ) => { this.logger.debug(`update | ${sessionId}`); - return savedObjectsClient.update( + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.update( 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(); 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 + ) => { + 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(); + } + }; } diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index 29bfd71cb32b..216c115545a4 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -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" }, diff --git a/x-pack/test/api_integration/apis/search/session.ts b/x-pack/test/api_integration/apis/search/session.ts index 8412d6c1ad5d..27010a6ab90f 100644 --- a/x-pack/test/api_integration/apis/search/session.ts +++ b/x-pack/test/api_integration/apis/search/session.ts @@ -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', {