diff --git a/x-pack/plugins/search_indices/common/index.ts b/x-pack/plugins/search_indices/common/index.ts index a0e7d3fe35e68..f2b3e62577e4f 100644 --- a/x-pack/plugins/search_indices/common/index.ts +++ b/x-pack/plugins/search_indices/common/index.ts @@ -7,3 +7,5 @@ export const PLUGIN_ID = 'searchIndices'; export const PLUGIN_NAME = 'searchIndices'; + +export type { IndicesStatusResponse, UserStartPrivilegesResponse } from './types'; diff --git a/x-pack/plugins/search_indices/common/types.ts b/x-pack/plugins/search_indices/common/types.ts new file mode 100644 index 0000000000000..8c7af3889a5a2 --- /dev/null +++ b/x-pack/plugins/search_indices/common/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface IndicesStatusResponse { + indexNames: string[]; +} + +export interface UserStartPrivilegesResponse { + privileges: { + canCreateApiKeys: boolean; + canCreateIndex: boolean; + }; +} diff --git a/x-pack/plugins/search_indices/server/lib/status.test.ts b/x-pack/plugins/search_indices/server/lib/status.test.ts new file mode 100644 index 0000000000000..ff5a8fc1eadd5 --- /dev/null +++ b/x-pack/plugins/search_indices/server/lib/status.test.ts @@ -0,0 +1,231 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + IndicesGetResponse, + SecurityHasPrivilegesResponse, +} from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { Logger } from '@kbn/logging'; +import { fetchIndicesStatus, fetchUserStartPrivileges } from './status'; + +const mockLogger = { + warn: jest.fn(), + error: jest.fn(), +}; +const logger: Logger = mockLogger as unknown as Logger; + +const mockClient = { + indices: { + get: jest.fn(), + }, + security: { + hasPrivileges: jest.fn(), + }, +}; +const client = mockClient as unknown as ElasticsearchClient; + +describe('status api lib', function () { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('fetchIndicesStatus', function () { + it('should return results from get', async () => { + const mockResult: IndicesGetResponse = {}; + mockClient.indices.get.mockResolvedValue(mockResult); + + await expect(fetchIndicesStatus(client, logger)).resolves.toEqual({ indexNames: [] }); + expect(mockClient.indices.get).toHaveBeenCalledTimes(1); + expect(mockClient.indices.get).toHaveBeenCalledWith({ + expand_wildcards: ['open'], + features: ['settings'], + index: '*', + }); + }); + it('should return index names', async () => { + const mockResult: IndicesGetResponse = { + 'unit-test-index': { + settings: {}, + }, + }; + mockClient.indices.get.mockResolvedValue(mockResult); + + await expect(fetchIndicesStatus(client, logger)).resolves.toEqual({ + indexNames: ['unit-test-index'], + }); + }); + it('should not return hidden indices', async () => { + const mockResult: IndicesGetResponse = { + 'unit-test-index': { + settings: {}, + }, + 'hidden-index': { + settings: { + index: { + hidden: true, + }, + }, + }, + }; + mockClient.indices.get.mockResolvedValue(mockResult); + + await expect(fetchIndicesStatus(client, logger)).resolves.toEqual({ + indexNames: ['unit-test-index'], + }); + + mockResult['hidden-index']!.settings!.index!.hidden = 'true'; + await expect(fetchIndicesStatus(client, logger)).resolves.toEqual({ + indexNames: ['unit-test-index'], + }); + }); + it('should not return closed indices', async () => { + const mockResult: IndicesGetResponse = { + 'unit-test-index': { + settings: {}, + }, + 'closed-index': { + settings: { + index: { + verified_before_close: true, + }, + }, + }, + }; + mockClient.indices.get.mockResolvedValue(mockResult); + + await expect(fetchIndicesStatus(client, logger)).resolves.toEqual({ + indexNames: ['unit-test-index'], + }); + + mockResult['closed-index']!.settings!.index!.verified_before_close = 'true'; + await expect(fetchIndicesStatus(client, logger)).resolves.toEqual({ + indexNames: ['unit-test-index'], + }); + }); + it('should raise exceptions', async () => { + const error = new Error('boom'); + mockClient.indices.get.mockRejectedValue(error); + + await expect(fetchIndicesStatus(client, logger)).rejects.toThrow(error); + }); + }); + + describe('fetchUserStartPrivileges', function () { + it('should return privileges true', async () => { + const result: SecurityHasPrivilegesResponse = { + application: {}, + cluster: { + manage_api_key: true, + }, + has_all_requested: true, + index: { + 'test-index-name': { + create_index: true, + }, + }, + username: 'unit-test', + }; + mockClient.security.hasPrivileges.mockResolvedValue(result); + + await expect(fetchUserStartPrivileges(client, logger)).resolves.toEqual({ + privileges: { + canCreateIndex: true, + canCreateApiKeys: true, + }, + }); + + expect(mockClient.security.hasPrivileges).toHaveBeenCalledTimes(1); + expect(mockClient.security.hasPrivileges).toHaveBeenCalledWith({ + cluster: ['manage_api_key'], + index: [ + { + names: ['test-index-name'], + privileges: ['create_index'], + }, + ], + }); + }); + it('should return privileges false', async () => { + const result: SecurityHasPrivilegesResponse = { + application: {}, + cluster: { + manage_api_key: false, + }, + has_all_requested: false, + index: { + 'test-index-name': { + create_index: false, + }, + }, + username: 'unit-test', + }; + mockClient.security.hasPrivileges.mockResolvedValue(result); + + await expect(fetchUserStartPrivileges(client, logger)).resolves.toEqual({ + privileges: { + canCreateIndex: false, + canCreateApiKeys: false, + }, + }); + }); + it('should return mixed privileges', async () => { + const result: SecurityHasPrivilegesResponse = { + application: {}, + cluster: { + manage_api_key: false, + }, + has_all_requested: false, + index: { + 'test-index-name': { + create_index: true, + }, + }, + username: 'unit-test', + }; + mockClient.security.hasPrivileges.mockResolvedValue(result); + + await expect(fetchUserStartPrivileges(client, logger)).resolves.toEqual({ + privileges: { + canCreateIndex: true, + canCreateApiKeys: false, + }, + }); + }); + it('should handle malformed responses', async () => { + const result: SecurityHasPrivilegesResponse = { + application: {}, + cluster: {}, + has_all_requested: true, + index: { + 'test-index-name': { + create_index: true, + }, + }, + username: 'unit-test', + }; + mockClient.security.hasPrivileges.mockResolvedValue(result); + + await expect(fetchUserStartPrivileges(client, logger)).resolves.toEqual({ + privileges: { + canCreateIndex: true, + canCreateApiKeys: false, + }, + }); + }); + it('should default privileges on exceptions', async () => { + mockClient.security.hasPrivileges.mockRejectedValue(new Error('Boom!!')); + + await expect(fetchUserStartPrivileges(client, logger)).resolves.toEqual({ + privileges: { + canCreateIndex: false, + canCreateApiKeys: false, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/search_indices/server/lib/status.ts b/x-pack/plugins/search_indices/server/lib/status.ts new file mode 100644 index 0000000000000..752e897ab1707 --- /dev/null +++ b/x-pack/plugins/search_indices/server/lib/status.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { Logger } from '@kbn/logging'; + +import type { IndicesStatusResponse, UserStartPrivilegesResponse } from '../../common/types'; + +import { isHidden, isClosed } from '../utils/index_utils'; + +export async function fetchIndicesStatus( + client: ElasticsearchClient, + logger: Logger +): Promise { + const indexMatches = await client.indices.get({ + expand_wildcards: ['open'], + // for better performance only compute settings of indices but not mappings + features: ['settings'], + index: '*', + }); + + const indexNames = Object.keys(indexMatches).filter( + (indexName) => + indexMatches[indexName] && + !isHidden(indexMatches[indexName]) && + !isClosed(indexMatches[indexName]) + ); + + return { + indexNames, + }; +} + +export async function fetchUserStartPrivileges( + client: ElasticsearchClient, + logger: Logger, + indexName: string = 'test-index-name' +): Promise { + try { + const securityCheck = await client.security.hasPrivileges({ + cluster: ['manage_api_key'], + index: [ + { + names: [indexName], + privileges: ['create_index'], + }, + ], + }); + + return { + privileges: { + canCreateIndex: securityCheck?.index?.[indexName]?.create_index ?? false, + canCreateApiKeys: securityCheck?.cluster?.manage_api_key ?? false, + }, + }; + } catch (e) { + logger.error(`Error checking user privileges for searchIndices elasticsearch start`); + logger.error(e); + return { + privileges: { + canCreateIndex: false, + canCreateApiKeys: false, + }, + }; + } +} diff --git a/x-pack/plugins/search_indices/server/plugin.ts b/x-pack/plugins/search_indices/server/plugin.ts index 666e49016f551..f0adbb112356e 100644 --- a/x-pack/plugins/search_indices/server/plugin.ts +++ b/x-pack/plugins/search_indices/server/plugin.ts @@ -31,7 +31,7 @@ export class SearchIndicesPlugin const router = core.http.createRouter(); // Register server side APIs - defineRoutes(router); + defineRoutes(router, this.logger); return {}; } diff --git a/x-pack/plugins/search_indices/server/routes/index.ts b/x-pack/plugins/search_indices/server/routes/index.ts index c98be5b6a4490..03cb1371870cb 100644 --- a/x-pack/plugins/search_indices/server/routes/index.ts +++ b/x-pack/plugins/search_indices/server/routes/index.ts @@ -6,5 +6,10 @@ */ import type { IRouter } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; -export function defineRoutes(router: IRouter) {} +import { registerStatusRoutes } from './status'; + +export function defineRoutes(router: IRouter, logger: Logger) { + registerStatusRoutes(router, logger); +} diff --git a/x-pack/plugins/search_indices/server/routes/status.ts b/x-pack/plugins/search_indices/server/routes/status.ts new file mode 100644 index 0000000000000..ab3a426510e6e --- /dev/null +++ b/x-pack/plugins/search_indices/server/routes/status.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IRouter } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +import { fetchIndicesStatus, fetchUserStartPrivileges } from '../lib/status'; + +export function registerStatusRoutes(router: IRouter, logger: Logger) { + router.get( + { + path: '/internal/search_indices/status', + validate: {}, + options: { + access: 'internal', + }, + }, + async (context, _request, response) => { + const core = await context.core; + const client = core.elasticsearch.client.asCurrentUser; + const body = await fetchIndicesStatus(client, logger); + + return response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }); + } + ); + + router.get( + { + path: '/internal/search_indices/start_privileges', + validate: {}, + options: { + access: 'internal', + }, + }, + async (context, _request, response) => { + const core = await context.core; + const client = core.elasticsearch.client.asCurrentUser; + const body = await fetchUserStartPrivileges(client, logger); + + return response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }); + } + ); +} diff --git a/x-pack/plugins/search_indices/server/utils/index_utils.test.ts b/x-pack/plugins/search_indices/server/utils/index_utils.test.ts new file mode 100644 index 0000000000000..95860468e5fcf --- /dev/null +++ b/x-pack/plugins/search_indices/server/utils/index_utils.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isClosed, isHidden } from './index_utils'; + +describe('index utils', function () { + describe('isClosed', function () { + it('handles boolean values', () => { + expect( + isClosed({ + settings: { + index: { + verified_before_close: true, + }, + }, + }) + ).toBe(true); + expect( + isClosed({ + settings: { + index: { + verified_before_close: false, + }, + }, + }) + ).toBe(false); + }); + it('handles string values', () => { + expect( + isClosed({ + settings: { + index: { + verified_before_close: 'true', + }, + }, + }) + ).toBe(true); + expect( + isClosed({ + settings: { + index: { + verified_before_close: 'false', + }, + }, + }) + ).toBe(false); + }); + it('handles undefined index settings', () => { + expect( + isClosed({ + settings: {}, + }) + ).toBe(false); + }); + }); + describe('isHidden', function () { + it('handles boolean values', () => { + expect( + isHidden({ + settings: { + index: { + hidden: true, + }, + }, + }) + ).toBe(true); + expect( + isHidden({ + settings: { + index: { + hidden: false, + }, + }, + }) + ).toBe(false); + }); + it('handles string values', () => { + expect( + isHidden({ + settings: { + index: { + hidden: 'true', + }, + }, + }) + ).toBe(true); + expect( + isHidden({ + settings: { + index: { + hidden: 'false', + }, + }, + }) + ).toBe(false); + }); + it('handles undefined index settings', () => { + expect( + isHidden({ + settings: {}, + }) + ).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/search_indices/server/utils/index_utils.ts b/x-pack/plugins/search_indices/server/utils/index_utils.ts new file mode 100644 index 0000000000000..d0b47b303679e --- /dev/null +++ b/x-pack/plugins/search_indices/server/utils/index_utils.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IndicesIndexState } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +export function isHidden(index: IndicesIndexState): boolean { + return index.settings?.index?.hidden === true || index.settings?.index?.hidden === 'true'; +} + +export function isClosed(index: IndicesIndexState): boolean { + return ( + index.settings?.index?.verified_before_close === true || + index.settings?.index?.verified_before_close === 'true' + ); +} diff --git a/x-pack/plugins/search_indices/tsconfig.json b/x-pack/plugins/search_indices/tsconfig.json index 48969cf7142bb..406b9de2400ce 100644 --- a/x-pack/plugins/search_indices/tsconfig.json +++ b/x-pack/plugins/search_indices/tsconfig.json @@ -14,6 +14,8 @@ "@kbn/core", "@kbn/navigation-plugin", "@kbn/config-schema", + "@kbn/core-elasticsearch-server", + "@kbn/logging", ], "exclude": [ "target/**/*", diff --git a/x-pack/test_serverless/api_integration/test_suites/search/config.feature_flags.ts b/x-pack/test_serverless/api_integration/test_suites/search/config.feature_flags.ts index 87064adf39d9f..3126619ae60ee 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/config.feature_flags.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/config.feature_flags.ts @@ -18,7 +18,10 @@ export default createTestConfig({ }, suiteTags: { exclude: ['skipSvlSearch'] }, // add feature flags - kbnServerArgs: ['--xpack.security.roleManagementEnabled=true'], + kbnServerArgs: [ + '--xpack.security.roleManagementEnabled=true', + `--xpack.searchIndices.enabled=true`, // global empty state FF + ], // load tests in the index file testFiles: [require.resolve('./index.feature_flags.ts')], diff --git a/x-pack/test_serverless/api_integration/test_suites/search/index.feature_flags.ts b/x-pack/test_serverless/api_integration/test_suites/search/index.feature_flags.ts index a757df76f10f3..a4a99f1e42641 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/index.feature_flags.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/index.feature_flags.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Serverless search API - feature flags', function () { + loadTestFile(require.resolve('./search_indices')); loadTestFile(require.resolve('./platform_security')); loadTestFile(require.resolve('../common/platform_security/roles_routes_feature_flag.ts')); }); diff --git a/x-pack/test_serverless/api_integration/test_suites/search/search_indices/index.ts b/x-pack/test_serverless/api_integration/test_suites/search/search_indices/index.ts new file mode 100644 index 0000000000000..b48985faaecd5 --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/search/search_indices/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('search indices APIs', function () { + loadTestFile(require.resolve('./status')); + }); +} diff --git a/x-pack/test_serverless/api_integration/test_suites/search/search_indices/status.ts b/x-pack/test_serverless/api_integration/test_suites/search/search_indices/status.ts new file mode 100644 index 0000000000000..b9e9fedbb0396 --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/search/search_indices/status.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from 'expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const svlCommonApi = getService('svlCommonApi'); + const svlUserManager = getService('svlUserManager'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + let credentials: { Cookie: string }; + + describe('search_indices Status APIs', function () { + describe('indices status', function () { + before(async () => { + // get auth header for Viewer role + credentials = await svlUserManager.getM2MApiCredentialsWithRoleScope('developer'); + }); + it('returns list of index names', async () => { + const { body } = await supertestWithoutAuth + .get('/internal/search_indices/status') + .set(svlCommonApi.getInternalRequestHeader()) + .set(credentials) + .expect(200); + + expect(body.indexNames).toBeDefined(); + expect(Array.isArray(body.indexNames)).toBe(true); + }); + }); + describe('user privileges', function () { + // GET /internal/search_indices/start_privileges + describe('developer', function () { + before(async () => { + // get auth header for Viewer role + credentials = await svlUserManager.getM2MApiCredentialsWithRoleScope('developer'); + }); + + it('returns expected privileges', async () => { + const { body } = await supertestWithoutAuth + .get('/internal/search_indices/start_privileges') + .set(svlCommonApi.getInternalRequestHeader()) + .set(credentials) + .expect(200); + + expect(body).toEqual({ + privileges: { + canCreateApiKeys: true, + canCreateIndex: true, + }, + }); + }); + }); + describe('viewer', function () { + before(async () => { + // get auth header for Viewer role + credentials = await svlUserManager.getM2MApiCredentialsWithRoleScope('viewer'); + }); + + it('returns expected privileges', async () => { + const { body } = await supertestWithoutAuth + .get('/internal/search_indices/start_privileges') + .set(svlCommonApi.getInternalRequestHeader()) + .set(credentials) + .expect(200); + + expect(body).toEqual({ + privileges: { + canCreateApiKeys: false, + canCreateIndex: false, + }, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/search/config.feature_flags.ts b/x-pack/test_serverless/functional/test_suites/search/config.feature_flags.ts index ee652f0f7eb2d..05eb6136bf008 100644 --- a/x-pack/test_serverless/functional/test_suites/search/config.feature_flags.ts +++ b/x-pack/test_serverless/functional/test_suites/search/config.feature_flags.ts @@ -25,6 +25,7 @@ export default createTestConfig({ `--xpack.cloud.organization_url='/account/members'`, `--xpack.security.roleManagementEnabled=true`, `--xpack.spaces.maxSpaces=100`, // enables spaces UI capabilities + `--xpack.searchIndices.enabled=true`, // global empty state FF ], // load tests in the index file testFiles: [require.resolve('./index.feature_flags.ts')], diff --git a/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts b/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts new file mode 100644 index 0000000000000..5b5adf48319db --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/search/elasticsearch_start.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({}: FtrProviderContext) { + describe('Elasticsearch Start [Onboarding Empty State]', function () {}); +} diff --git a/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts b/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts index acf3e6f98b7db..44cd17a0c9fd3 100644 --- a/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts +++ b/x-pack/test_serverless/functional/test_suites/search/index.feature_flags.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('serverless search UI - feature flags', function () { // add tests that require feature flags, defined in config.feature_flags.ts + loadTestFile(require.resolve('./elasticsearch_start.ts')); loadTestFile(require.resolve('../common/platform_security/navigation/management_nav_cards.ts')); loadTestFile(require.resolve('../common/platform_security/roles.ts')); loadTestFile(require.resolve('../common/spaces/spaces_selection_enabled.ts'));