[Search][Onboarding] Empty State page endpoints (#191229)

## Summary

This PR introduces two endpoints for the `search_indices` plugin that
will be used by the start (empty state) page to determine if the user
has an indices and what their permissions are.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Rodney Norris 2024-08-26 14:44:29 -05:00 committed by GitHub
parent 9ea2adb7ae
commit 486df8cf5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 623 additions and 3 deletions

View file

@ -7,3 +7,5 @@
export const PLUGIN_ID = 'searchIndices';
export const PLUGIN_NAME = 'searchIndices';
export type { IndicesStatusResponse, UserStartPrivilegesResponse } from './types';

View file

@ -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;
};
}

View file

@ -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,
},
});
});
});
});

View file

@ -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<IndicesStatusResponse> {
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<UserStartPrivilegesResponse> {
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,
},
};
}
}

View file

@ -31,7 +31,7 @@ export class SearchIndicesPlugin
const router = core.http.createRouter();
// Register server side APIs
defineRoutes(router);
defineRoutes(router, this.logger);
return {};
}

View file

@ -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);
}

View file

@ -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' },
});
}
);
}

View file

@ -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);
});
});
});

View file

@ -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'
);
}

View file

@ -14,6 +14,8 @@
"@kbn/core",
"@kbn/navigation-plugin",
"@kbn/config-schema",
"@kbn/core-elasticsearch-server",
"@kbn/logging",
],
"exclude": [
"target/**/*",

View file

@ -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')],

View file

@ -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'));
});

View file

@ -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'));
});
}

View file

@ -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,
},
});
});
});
});
});
}

View file

@ -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')],

View file

@ -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 () {});
}

View file

@ -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'));