diff --git a/src/platform/plugins/shared/data/server/search/routes/search.ts b/src/platform/plugins/shared/data/server/search/routes/search.ts index 98ecdfb277125..784c677e3d6c7 100644 --- a/src/platform/plugins/shared/data/server/search/routes/search.ts +++ b/src/platform/plugins/shared/data/server/search/routes/search.ts @@ -35,6 +35,10 @@ export function registerSearchRoute( enabled: false, reason: 'This route is opted out from authorization', }, + authc: { + enabled: 'minimal', + reason: 'This route is optimized for performant retrieval of data from Elasticsearch.', + }, }, }) .addVersion( diff --git a/src/platform/plugins/shared/data/server/search/search_service.test.ts b/src/platform/plugins/shared/data/server/search/search_service.test.ts index 91199aa96dce0..df50b1bb5a2af 100644 --- a/src/platform/plugins/shared/data/server/search/search_service.test.ts +++ b/src/platform/plugins/shared/data/server/search/search_service.test.ts @@ -286,7 +286,7 @@ describe('Search service', () => { expect(mockSessionClient.trackId).toBeCalledTimes(1); - expect(mockSessionClient.trackId.mock.calls[0]).toEqual(['my_id', options]); + expect(mockSessionClient.trackId.mock.calls[0]).toEqual(['my_id', options, true]); }); it('does not call `trackId` if search is already tracked', async () => { diff --git a/src/platform/plugins/shared/data/server/search/search_service.ts b/src/platform/plugins/shared/data/server/search/search_service.ts index 406ba60e8666d..fc0b2a7c57a5a 100644 --- a/src/platform/plugins/shared/data/server/search/search_service.ts +++ b/src/platform/plugins/shared/data/server/search/search_service.ts @@ -374,7 +374,12 @@ export class SearchService { return request; } else { try { - const id = await deps.searchSessionsClient.getId(request, options); + const id = await deps.searchSessionsClient.getId( + request, + options, + // The search route uses minimal auth for performance, which doesn't include realm information. + true + ); this.logger.debug(`Found search session id for request ${id}`); return { ...request, @@ -417,7 +422,14 @@ export class SearchService { isStored: true, }); } else { - return from(deps.searchSessionsClient.trackId(response.id, options)).pipe( + return from( + deps.searchSessionsClient.trackId( + response.id, + options, + // The search route uses minimal auth for performance, which doesn't include realm information. + true + ) + ).pipe( tap(() => { isInternalSearchStored = true; }), diff --git a/src/platform/plugins/shared/data/server/search/session/session_service.test.ts b/src/platform/plugins/shared/data/server/search/session/session_service.test.ts index 22b51f06e3ab6..9b6f79999ee9d 100644 --- a/src/platform/plugins/shared/data/server/search/session/session_service.test.ts +++ b/src/platform/plugins/shared/data/server/search/session/session_service.test.ts @@ -26,6 +26,24 @@ const MAX_UPDATE_RETRIES = 3; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); +const mockMinimalAuthUser = new Proxy( + { + username: 'my_username', + enabled: true, + authentication_provider: { type: 'basic', name: 'basic1' }, + } as any, + { + get(target, prop) { + if (prop === 'authentication_realm') { + throw new Error( + `Property "${String(prop)}" is not available for minimally authenticated users.` + ); + } + return Reflect.get(target, prop); + }, + } +); + describe('SearchSessionService', () => { let savedObjectsClient: jest.Mocked; let asCurrentUserElasticsearchClient: ElasticsearchClientMock; @@ -838,6 +856,64 @@ describe('SearchSessionService', () => { }, }); }); + + describe('minimal auth user', () => { + it('throws by default', async () => { + const requestHash = faker.string.alpha(64); + const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; + + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await expect( + service.trackId({ savedObjectsClient }, mockMinimalAuthUser, searchId, { + sessionId, + strategy: MOCK_STRATEGY, + requestHash, + }) + ).rejects.toThrow(); + + expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(mockLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Skipping realm check') + ); + }); + + it('works when auth realm check is skipped', async () => { + const requestHash = faker.string.alpha(64); + const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; + + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await expect( + service.trackId( + { savedObjectsClient }, + mockMinimalAuthUser, + searchId, + { + sessionId, + strategy: MOCK_STRATEGY, + requestHash, + }, + true // skipRealmCheck + ) + ).resolves.not.toThrow(); + + expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Skipping realm check') + ); + }); + }); }); describe('getId', () => { @@ -902,6 +978,84 @@ describe('SearchSessionService', () => { expect(id).toBe(searchId); }); + + describe('minimal auth user', () => { + it('throws by default', async () => { + const searchRequest = { params: {} }; + const requestHash = faker.string.alpha(64); + const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; + const mockSession = { + ...mockSavedObject, + attributes: { + ...mockSavedObject.attributes, + idMapping: { + [requestHash]: { + id: searchId, + }, + }, + }, + }; + savedObjectsClient.get.mockResolvedValue(mockSession); + + const getIdPromise = service.getId( + { savedObjectsClient }, + mockMinimalAuthUser, + searchRequest, + { + sessionId, + isStored: true, + isRestore: true, + requestHash, + } + ); + + await expect(getIdPromise).rejects.toThrow(); + + expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(mockLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('Skipping realm check') + ); + }); + + it('works with minimal auth user (no authentication_realm access)', async () => { + const searchRequest = { params: {} }; + const requestHash = faker.string.alpha(64); + const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; + const mockSession = { + ...mockSavedObject, + attributes: { + ...mockSavedObject.attributes, + idMapping: { + [requestHash]: { + id: searchId, + }, + }, + }, + }; + savedObjectsClient.get.mockResolvedValue(mockSession); + + const getIdPromise = service.getId( + { savedObjectsClient }, + mockMinimalAuthUser, + searchRequest, + { + sessionId, + isStored: true, + isRestore: true, + requestHash, + }, + true + ); + + await expect(getIdPromise).resolves.not.toThrow(); + + const id = await getIdPromise; + expect(id).toBe(searchId); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Skipping realm check') + ); + }); + }); }); describe('getSearchIdMapping', () => { diff --git a/src/platform/plugins/shared/data/server/search/session/session_service.ts b/src/platform/plugins/shared/data/server/search/session/session_service.ts index 1d985100119bc..8bdab51754860 100644 --- a/src/platform/plugins/shared/data/server/search/session/session_service.ts +++ b/src/platform/plugins/shared/data/server/search/session/session_service.ts @@ -72,6 +72,7 @@ interface TrackIdQueueEntry { searchInfo: SearchSessionRequestInfo; requestHash: string; + skipRealmCheck: boolean; } export class SearchSessionService implements ISearchSessionService { @@ -163,14 +164,16 @@ export class SearchSessionService implements ISearchSessionService { public get = async ( { savedObjectsClient }: SearchSessionDependencies, user: AuthenticatedUser | null, - sessionId: string + sessionId: string, + skipRealmCheck: boolean = false ) => { this.logger.debug(`get | ${sessionId}`); const session = await savedObjectsClient.get( SEARCH_SESSION_TYPE, sessionId ); - this.throwOnUserConflict(user, session); + + this.throwOnUserConflict(user, session, skipRealmCheck); return session; }; @@ -226,11 +229,12 @@ export class SearchSessionService implements ISearchSessionService { deps: SearchSessionDependencies, user: AuthenticatedUser | null, sessionId: string, - attributes: Partial + attributes: Partial, + skipRealmCheck: boolean = false ) => { this.logger.debug(`SearchSessionService: update | ${sessionId}`); if (!this.sessionConfig.enabled) throw new Error('Search sessions are disabled'); - await this.get(deps, user, sessionId); // Verify correct user + await this.get(deps, user, sessionId, skipRealmCheck); // Verify correct user return deps.savedObjectsClient.update( SEARCH_SESSION_TYPE, sessionId, @@ -293,7 +297,8 @@ export class SearchSessionService implements ISearchSessionService { deps: SearchSessionDependencies, user: AuthenticatedUser | null, searchId: string, - options: ISearchOptions + options: ISearchOptions, + skipRealmCheck: boolean = false ) => { const { sessionId, strategy = ENHANCED_ES_SEARCH_STRATEGY, requestHash } = options; if (!this.sessionConfig.enabled || !sessionId || !searchId) return; @@ -328,7 +333,13 @@ export class SearchSessionService implements ISearchSessionService { }, {} ); - this.update(queue[0].deps, queue[0].user, sessionId, { idMapping: batchedIdMapping }) + this.update( + queue[0].deps, + queue[0].user, + sessionId, + { idMapping: batchedIdMapping }, + queue[0].skipRealmCheck + ) .then(() => { queue.forEach((q) => q.resolve()); }) @@ -352,6 +363,7 @@ export class SearchSessionService implements ISearchSessionService { resolve: deferred.resolve, reject: deferred.reject, user, + skipRealmCheck, }); scheduleProcessQueue(); @@ -450,7 +462,8 @@ export class SearchSessionService implements ISearchSessionService { deps: SearchSessionDependencies, user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, - { sessionId, isStored, isRestore, requestHash }: ISearchOptions + { sessionId, isStored, isRestore, requestHash }: ISearchOptions, + skipRealmCheck: boolean = false ) => { if (!this.sessionConfig.enabled) { throw new Error('Background search is disabled'); @@ -464,7 +477,7 @@ export class SearchSessionService implements ISearchSessionService { throw new Error('Request hash is required to get search ID from session'); } - const session = await this.get(deps, user, sessionId); + const session = await this.get(deps, user, sessionId, skipRealmCheck); if (!Object.hasOwn(session.attributes.idMapping, requestHash)) { this.logger.debug(`SearchSessionService: getId | ${sessionId} | ${requestHash} not found`); this.logger.error( @@ -512,14 +525,32 @@ export class SearchSessionService implements ISearchSessionService { private throwOnUserConflict = ( user: AuthenticatedUser | null, - session?: SavedObject + session?: SavedObject, + skipRealmCheck: boolean = false ) => { if (user === null || !session) return; + + if (skipRealmCheck) { + this.logger.debug( + `Skipping realm check for user ${user.username} when accessing search session ${session.attributes.sessionId}.` + ); + } + + let matchesUser = true; + + if (user.username !== session.attributes.username) { + matchesUser = false; + } + if ( - user.authentication_realm.type !== session.attributes.realmType || - user.authentication_realm.name !== session.attributes.realmName || - user.username !== session.attributes.username + !skipRealmCheck && + (user.authentication_realm.type !== session.attributes.realmType || + user.authentication_realm.name !== session.attributes.realmName) ) { + matchesUser = false; + } + + if (!matchesUser) { this.logger.debug( `User ${user.username} has no access to search session ${session.attributes.sessionId}` ); diff --git a/src/platform/plugins/shared/data/server/search/session/types.ts b/src/platform/plugins/shared/data/server/search/session/types.ts index d826f8a1c75be..6245af224a2c0 100644 --- a/src/platform/plugins/shared/data/server/search/session/types.ts +++ b/src/platform/plugins/shared/data/server/search/session/types.ts @@ -28,8 +28,12 @@ import type { SearchSessionsConfigSchema } from '../../config'; export { SearchStatus } from '../../../common/search'; export interface IScopedSearchSessionsClient { - getId: (request: IKibanaSearchRequest, options: ISearchOptions) => Promise; - trackId: (searchId: string, options: ISearchOptions) => Promise; + getId: ( + request: IKibanaSearchRequest, + options: ISearchOptions, + skipRealmCheck?: boolean + ) => Promise; + trackId: (searchId: string, options: ISearchOptions, skipRealmCheck?: boolean) => Promise; getSearchIdMapping: (sessionId: string) => Promise>; save: ( sessionId: string, diff --git a/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts b/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts index 133deb343805b..8da137ce73815 100644 --- a/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts +++ b/x-pack/platform/plugins/shared/security/server/authorization/authorization_service.ts @@ -183,13 +183,6 @@ export class AuthorizationService { http.registerOnPreResponse(async (request, preResponse, toolkit) => { if (preResponse.statusCode === 403) { - const user = getCurrentUser(request); - if (user?.roles.length === 0) { - this.logger.warn( - `A user authenticated with the "${user.authentication_realm.name}" (${user.authentication_realm.type}) realm doesn't have any roles and isn't authorized to perform request.` - ); - } - if (canRedirectRequest(request)) { const next = `${http.basePath.get(request)}${request.url.pathname}${request.url.search}`;