diff --git a/src/plugins/home/server/services/index.ts b/src/plugins/home/server/services/index.ts index 7f26c886ab4b6..5674a3501f064 100644 --- a/src/plugins/home/server/services/index.ts +++ b/src/plugins/home/server/services/index.ts @@ -15,7 +15,6 @@ export type { TutorialsRegistrySetup, TutorialsRegistryStart } from './tutorials export { TutorialsCategory } from './tutorials'; export type { - ParamTypes, InstructionSetSchema, ParamsSchema, InstructionsSchema, diff --git a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts index 4d9dc3885e67d..09af7728f74d2 100644 --- a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts +++ b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { SavedObject } from 'src/core/server'; +import type { SampleDatasetSchema } from './sample_dataset_schema'; +export type { SampleDatasetSchema, AppLinkSchema, DataIndexSchema } from './sample_dataset_schema'; export enum DatasetStatusTypes { NOT_INSTALLED = 'not_installed', @@ -26,57 +27,4 @@ export enum EmbeddableTypes { SEARCH_EMBEDDABLE_TYPE = 'search', VISUALIZE_EMBEDDABLE_TYPE = 'visualization', } -export interface DataIndexSchema { - id: string; - - // path to newline delimented JSON file containing data relative to KIBANA_HOME - dataPath: string; - - // Object defining Elasticsearch field mappings (contents of index.mappings.type.properties) - fields: object; - - // times fields that will be updated relative to now when data is installed - timeFields: string[]; - - // Reference to now in your test data set. - // When data is installed, timestamps are converted to the present time. - // The distance between a timestamp and currentTimeMarker is preserved but the date and time will change. - // For example: - // sample data set: timestamp: 2018-01-01T00:00:00Z, currentTimeMarker: 2018-01-01T12:00:00Z - // installed data set: timestamp: 2018-04-18T20:33:14Z, currentTimeMarker: 2018-04-19T08:33:14Z - currentTimeMarker: string; - - // Set to true to move timestamp to current week, preserving day of week and time of day - // Relative distance from timestamp to currentTimeMarker will not remain the same - preserveDayOfWeekTimeOfDay: boolean; -} - -export interface AppLinkSchema { - path: string; - icon: string; - label: string; -} - -export interface SampleDatasetSchema { - id: string; - name: string; - description: string; - previewImagePath: string; - darkPreviewImagePath: string; - - // saved object id of main dashboard for sample data set - overviewDashboard: string; - appLinks: AppLinkSchema[]; - - // saved object id of default index-pattern for sample data set - defaultIndex: string; - - // Kibana saved objects (index patter, visualizations, dashboard, ...) - // Should provide a nice demo of Kibana's functionality with the sample data set - savedObjects: Array>; - dataIndices: DataIndexSchema[]; - status?: string | undefined; - statusMsg?: unknown; -} - export type SampleDatasetProvider = () => SampleDatasetSchema; diff --git a/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts b/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts index eb0b2252774b5..3c1764b2b8df1 100644 --- a/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts +++ b/src/plugins/home/server/services/sample_data/lib/sample_dataset_schema.ts @@ -5,22 +5,27 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import type { Writable } from '@kbn/utility-types'; +import { schema, TypeOf } from '@kbn/config-schema'; -import Joi from 'joi'; - -const dataIndexSchema = Joi.object({ - id: Joi.string() - .regex(/^[a-zA-Z0-9-]+$/) - .required(), +const idRegExp = /^[a-zA-Z0-9-]+$/; +const dataIndexSchema = schema.object({ + id: schema.string({ + validate(value: string) { + if (!idRegExp.test(value)) { + return `Does not satisfy regexp: ${idRegExp.toString()}`; + } + }, + }), // path to newline delimented JSON file containing data relative to KIBANA_HOME - dataPath: Joi.string().required(), + dataPath: schema.string(), // Object defining Elasticsearch field mappings (contents of index.mappings.type.properties) - fields: Joi.object().required(), + fields: schema.recordOf(schema.string(), schema.any()), // times fields that will be updated relative to now when data is installed - timeFields: Joi.array().items(Joi.string()).required(), + timeFields: schema.arrayOf(schema.string()), // Reference to now in your test data set. // When data is installed, timestamps are converted to the present time. @@ -28,37 +33,66 @@ const dataIndexSchema = Joi.object({ // For example: // sample data set: timestamp: 2018-01-01T00:00:00Z, currentTimeMarker: 2018-01-01T12:00:00Z // installed data set: timestamp: 2018-04-18T20:33:14Z, currentTimeMarker: 2018-04-19T08:33:14Z - currentTimeMarker: Joi.string().isoDate().required(), + currentTimeMarker: schema.string({ + validate(value: string) { + if (isNaN(Date.parse(value))) { + return 'Expected a valid string in iso format'; + } + }, + }), // Set to true to move timestamp to current week, preserving day of week and time of day // Relative distance from timestamp to currentTimeMarker will not remain the same - preserveDayOfWeekTimeOfDay: Joi.boolean().default(false), + preserveDayOfWeekTimeOfDay: schema.boolean({ defaultValue: false }), }); -const appLinkSchema = Joi.object({ - path: Joi.string().required(), - label: Joi.string().required(), - icon: Joi.string().required(), +export type DataIndexSchema = TypeOf; + +const appLinkSchema = schema.object({ + path: schema.string(), + label: schema.string(), + icon: schema.string(), }); +export type AppLinkSchema = TypeOf; -export const sampleDataSchema = { - id: Joi.string() - .regex(/^[a-zA-Z0-9-]+$/) - .required(), - name: Joi.string().required(), - description: Joi.string().required(), - previewImagePath: Joi.string().required(), - darkPreviewImagePath: Joi.string(), +export const sampleDataSchema = schema.object({ + id: schema.string({ + validate(value: string) { + if (!idRegExp.test(value)) { + return `Does not satisfy regexp: ${idRegExp.toString()}`; + } + }, + }), + name: schema.string(), + description: schema.string(), + previewImagePath: schema.string(), + darkPreviewImagePath: schema.maybe(schema.string()), // saved object id of main dashboard for sample data set - overviewDashboard: Joi.string().required(), - appLinks: Joi.array().items(appLinkSchema).default([]), + overviewDashboard: schema.string(), + appLinks: schema.arrayOf(appLinkSchema, { defaultValue: [] }), // saved object id of default index-pattern for sample data set - defaultIndex: Joi.string().required(), + defaultIndex: schema.string(), // Kibana saved objects (index patter, visualizations, dashboard, ...) // Should provide a nice demo of Kibana's functionality with the sample data set - savedObjects: Joi.array().items(Joi.object()).required(), - dataIndices: Joi.array().items(dataIndexSchema).required(), -}; + savedObjects: schema.arrayOf( + schema.object( + { + id: schema.string(), + type: schema.string(), + attributes: schema.any(), + references: schema.arrayOf(schema.any()), + version: schema.maybe(schema.any()), + }, + { unknowns: 'allow' } + ) + ), + dataIndices: schema.arrayOf(dataIndexSchema), + + status: schema.maybe(schema.string()), + statusMsg: schema.maybe(schema.string()), +}); + +export type SampleDatasetSchema = Writable>; diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index e5ff33d5c199d..d0457f0a6d301 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -7,7 +7,12 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter, Logger, IScopedClusterClient } from 'src/core/server'; +import type { + IRouter, + Logger, + IScopedClusterClient, + SavedObjectsBulkCreateObject, +} from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { @@ -148,8 +153,9 @@ export function createInstallRoute( const client = getClient({ includedHiddenTypes }); + const savedObjects = sampleDataset.savedObjects as SavedObjectsBulkCreateObject[]; createResults = await client.bulkCreate( - sampleDataset.savedObjects.map(({ version, ...savedObject }) => savedObject), + savedObjects.map(({ version, ...savedObject }) => savedObject), { overwrite: true } ); } catch (err) { diff --git a/src/plugins/home/server/services/sample_data/sample_data_registry.ts b/src/plugins/home/server/services/sample_data/sample_data_registry.ts index ca75d20dc1d3f..dff0d86409974 100644 --- a/src/plugins/home/server/services/sample_data/sample_data_registry.ts +++ b/src/plugins/home/server/services/sample_data/sample_data_registry.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import Joi from 'joi'; import { CoreSetup, PluginInitializerContext } from 'src/core/server'; import { SavedObject } from 'src/core/public'; import { @@ -55,11 +54,13 @@ export class SampleDataRegistry { return { registerSampleDataset: (specProvider: SampleDatasetProvider) => { - const { error, value } = Joi.validate(specProvider(), sampleDataSchema); - - if (error) { + let value: SampleDatasetSchema; + try { + value = sampleDataSchema.validate(specProvider()); + } catch (error) { throw new Error(`Unable to register sample dataset spec because it's invalid. ${error}`); } + const defaultIndexSavedObjectJson = value.savedObjects.find((savedObjectJson: any) => { return ( savedObjectJson.type === 'index-pattern' && savedObjectJson.id === value.defaultIndex diff --git a/src/plugins/home/server/services/tutorials/index.ts b/src/plugins/home/server/services/tutorials/index.ts index 92f6de716185d..f745d0190efd5 100644 --- a/src/plugins/home/server/services/tutorials/index.ts +++ b/src/plugins/home/server/services/tutorials/index.ts @@ -12,7 +12,6 @@ export type { TutorialsRegistrySetup, TutorialsRegistryStart } from './tutorials export { TutorialsCategory } from './lib/tutorials_registry_types'; export type { - ParamTypes, InstructionSetSchema, ParamsSchema, InstructionsSchema, diff --git a/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts index 0f06b6c3257c2..5efbe067f6ece 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorial_schema.ts @@ -5,121 +5,153 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { schema, TypeOf } from '@kbn/config-schema'; -import Joi from 'joi'; - -const PARAM_TYPES = { - NUMBER: 'number', - STRING: 'string', -}; - -const TUTORIAL_CATEGORY = { - LOGGING: 'logging', - SECURITY_SOLUTION: 'security solution', - METRICS: 'metrics', - OTHER: 'other', -}; - -const dashboardSchema = Joi.object({ - id: Joi.string().required(), // Dashboard saved object id - linkLabel: Joi.string().when('isOverview', { - is: true, - then: Joi.required(), - }), +const dashboardSchema = schema.object({ + // Dashboard saved object id + id: schema.string(), // Is this an Overview / Entry Point dashboard? - isOverview: Joi.boolean().required(), + isOverview: schema.boolean(), + linkLabel: schema.conditional( + schema.siblingRef('isOverview'), + true, + schema.string(), + schema.maybe(schema.string()) + ), }); +export type DashboardSchema = TypeOf; -const artifactsSchema = Joi.object({ +const artifactsSchema = schema.object({ // Fields present in Elasticsearch documents created by this product. - exportedFields: Joi.object({ - documentationUrl: Joi.string().required(), - }), + exportedFields: schema.maybe( + schema.object({ + documentationUrl: schema.string(), + }) + ), // Kibana dashboards created by this product. - dashboards: Joi.array().items(dashboardSchema).required(), - application: Joi.object({ - path: Joi.string().required(), - label: Joi.string().required(), - }), + dashboards: schema.arrayOf(dashboardSchema), + application: schema.maybe( + schema.object({ + path: schema.string(), + label: schema.string(), + }) + ), }); - -const statusCheckSchema = Joi.object({ - title: Joi.string(), - text: Joi.string(), - btnLabel: Joi.string(), - success: Joi.string(), - error: Joi.string(), - esHitsCheck: Joi.object({ - index: Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())).required(), - query: Joi.object().required(), - }).required(), +export type ArtifactsSchema = TypeOf; + +const statusCheckSchema = schema.object({ + title: schema.maybe(schema.string()), + text: schema.maybe(schema.string()), + btnLabel: schema.maybe(schema.string()), + success: schema.maybe(schema.string()), + error: schema.maybe(schema.string()), + esHitsCheck: schema.object({ + index: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]), + query: schema.recordOf(schema.string(), schema.any()), + }), }); -const instructionSchema = Joi.object({ - title: Joi.string(), - textPre: Joi.string(), - commands: Joi.array().items(Joi.string().allow('')), - textPost: Joi.string(), +const instructionSchema = schema.object({ + title: schema.maybe(schema.string()), + textPre: schema.maybe(schema.string()), + commands: schema.maybe(schema.arrayOf(schema.string())), + textPost: schema.maybe(schema.string()), }); +export type Instruction = TypeOf; -const instructionVariantSchema = Joi.object({ - id: Joi.string().required(), - instructions: Joi.array().items(instructionSchema).required(), +const instructionVariantSchema = schema.object({ + id: schema.string(), + instructions: schema.arrayOf(instructionSchema), }); -const instructionSetSchema = Joi.object({ - title: Joi.string(), - callOut: Joi.object({ - title: Joi.string().required(), - message: Joi.string(), - iconType: Joi.string(), - }), +export type InstructionVariant = TypeOf; + +const instructionSetSchema = schema.object({ + title: schema.maybe(schema.string()), + callOut: schema.maybe( + schema.object({ + title: schema.string(), + message: schema.maybe(schema.string()), + iconType: schema.maybe(schema.string()), + }) + ), // Variants (OSes, languages, etc.) for which tutorial instructions are specified. - instructionVariants: Joi.array().items(instructionVariantSchema).required(), - statusCheck: statusCheckSchema, + instructionVariants: schema.arrayOf(instructionVariantSchema), + statusCheck: schema.maybe(statusCheckSchema), }); - -const paramSchema = Joi.object({ - defaultValue: Joi.required(), - id: Joi.string() - .regex(/^[a-zA-Z_]+$/) - .required(), - label: Joi.string().required(), - type: Joi.string().valid(Object.values(PARAM_TYPES)).required(), +export type InstructionSetSchema = TypeOf; + +const idRegExp = /^[a-zA-Z_]+$/; +const paramSchema = schema.object({ + defaultValue: schema.any(), + id: schema.string({ + validate(value: string) { + if (!idRegExp.test(value)) { + return `Does not satisfy regexp ${idRegExp.toString()}`; + } + }, + }), + label: schema.string(), + type: schema.oneOf([schema.literal('number'), schema.literal('string')]), }); +export type ParamsSchema = TypeOf; -const instructionsSchema = Joi.object({ - instructionSets: Joi.array().items(instructionSetSchema).required(), - params: Joi.array().items(paramSchema), +const instructionsSchema = schema.object({ + instructionSets: schema.arrayOf(instructionSetSchema), + params: schema.maybe(schema.arrayOf(paramSchema)), }); - -export const tutorialSchema = { - id: Joi.string() - .regex(/^[a-zA-Z0-9-]+$/) - .required(), - category: Joi.string().valid(Object.values(TUTORIAL_CATEGORY)).required(), - name: Joi.string().required(), - moduleName: Joi.string(), - isBeta: Joi.boolean().default(false), - shortDescription: Joi.string().required(), - euiIconType: Joi.string(), // EUI icon type string, one of https://elastic.github.io/eui/#/icons - longDescription: Joi.string().required(), - completionTimeMinutes: Joi.number().integer(), - previewImagePath: Joi.string(), - +export type InstructionsSchema = TypeOf; + +const tutorialIdRegExp = /^[a-zA-Z0-9-]+$/; +export const tutorialSchema = schema.object({ + id: schema.string({ + validate(value: string) { + if (!tutorialIdRegExp.test(value)) { + return `Does not satisfy regexp ${tutorialIdRegExp.toString()}`; + } + }, + }), + category: schema.oneOf([ + schema.literal('logging'), + schema.literal('security'), + schema.literal('metrics'), + schema.literal('other'), + ]), + name: schema.string({ + validate(value: string) { + if (value === '') { + return 'is not allowed to be empty'; + } + }, + }), + moduleName: schema.maybe(schema.string()), + isBeta: schema.maybe(schema.boolean()), + shortDescription: schema.string(), + // EUI icon type string, one of https://elastic.github.io/eui/#/icons + euiIconType: schema.maybe(schema.string()), + longDescription: schema.string(), + completionTimeMinutes: schema.maybe( + schema.number({ + validate(value: number) { + if (!Number.isInteger(value)) { + return 'Expected to be a valid integer number'; + } + }, + }) + ), + previewImagePath: schema.maybe(schema.string()), // kibana and elastic cluster running on prem - onPrem: instructionsSchema.required(), - + onPrem: instructionsSchema, // kibana and elastic cluster running in elastic's cloud - elasticCloud: instructionsSchema, - + elasticCloud: schema.maybe(instructionsSchema), // kibana running on prem and elastic cluster running in elastic's cloud - onPremElasticCloud: instructionsSchema, - + onPremElasticCloud: schema.maybe(instructionsSchema), // Elastic stack artifacts produced by product when it is setup and run. - artifacts: artifactsSchema, + artifacts: schema.maybe(artifactsSchema), // saved objects used by data module. - savedObjects: Joi.array().items(), - savedObjectsInstallMsg: Joi.string(), -}; + savedObjects: schema.maybe(schema.arrayOf(schema.any())), + savedObjectsInstallMsg: schema.maybe(schema.string()), +}); + +export type TutorialSchema = TypeOf; diff --git a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts index b0837a99d65ad..4c80c8858a475 100644 --- a/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts +++ b/src/plugins/home/server/services/tutorials/lib/tutorials_registry_types.ts @@ -6,8 +6,18 @@ * Side Public License, v 1. */ -import { IconType } from '@elastic/eui'; -import { KibanaRequest } from 'src/core/server'; +import type { KibanaRequest } from 'src/core/server'; +import type { TutorialSchema } from './tutorial_schema'; +export type { + TutorialSchema, + ArtifactsSchema, + DashboardSchema, + InstructionsSchema, + ParamsSchema, + InstructionSetSchema, + InstructionVariant, + Instruction, +} from './tutorial_schema'; /** @public */ export enum TutorialsCategory { @@ -18,82 +28,6 @@ export enum TutorialsCategory { } export type Platform = 'WINDOWS' | 'OSX' | 'DEB' | 'RPM'; -export interface ParamTypes { - NUMBER: string; - STRING: string; -} -export interface Instruction { - title?: string; - textPre?: string; - commands?: string[]; - textPost?: string; -} -export interface InstructionVariant { - id: string; - instructions: Instruction[]; -} -export interface InstructionSetSchema { - readonly title?: string; - readonly callOut?: { - title: string; - message?: string; - iconType?: IconType; - }; - instructionVariants: InstructionVariant[]; -} -export interface ParamsSchema { - defaultValue: any; - id: string; - label: string; - type: ParamTypes; -} -export interface InstructionsSchema { - readonly instructionSets: InstructionSetSchema[]; - readonly params?: ParamsSchema[]; -} -export interface DashboardSchema { - id: string; - linkLabel?: string; - isOverview: boolean; -} -export interface ArtifactsSchema { - exportedFields?: { - documentationUrl: string; - }; - dashboards: DashboardSchema[]; - application?: { - path: string; - label: string; - }; -} -export interface TutorialSchema { - id: string; - category: TutorialsCategory; - name: string; - moduleName?: string; - isBeta?: boolean; - shortDescription: string; - euiIconType?: IconType; // EUI icon type string, one of https://elastic.github.io/eui/#/display/icons; - longDescription: string; - completionTimeMinutes?: number; - previewImagePath?: string; - - // kibana and elastic cluster running on prem - onPrem: InstructionsSchema; - - // kibana and elastic cluster running in elastic's cloud - elasticCloud?: InstructionsSchema; - - // kibana running on prem and elastic cluster running in elastic's cloud - onPremElasticCloud?: InstructionsSchema; - - // Elastic stack artifacts produced by product when it is setup and run. - artifacts?: ArtifactsSchema; - - // saved objects used by data module. - savedObjects?: any[]; - savedObjectsInstallMsg?: string; -} export interface TutorialContext { [key: string]: unknown; } diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts index 94f5d65610083..a82699c231ad4 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts @@ -92,7 +92,7 @@ describe('TutorialsRegistry', () => { const setup = new TutorialsRegistry().setup(mockCoreSetup); testProvider = ({}) => invalidTutorialProvider; expect(() => setup.registerTutorial(testProvider)).toThrowErrorMatchingInlineSnapshot( - `"Unable to register tutorial spec because its invalid. ValidationError: child \\"name\\" fails because [\\"name\\" is not allowed to be empty]"` + `"Unable to register tutorial spec because its invalid. Error: [name]: is not allowed to be empty"` ); }); diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.ts index f21f2ccd719c5..05f5600af307a 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import Joi from 'joi'; import { CoreSetup } from 'src/core/server'; import { TutorialProvider, @@ -42,10 +41,10 @@ export class TutorialsRegistry { ); return { registerTutorial: (specProvider: TutorialProvider) => { - const emptyContext = {}; - const { error } = Joi.validate(specProvider(emptyContext), tutorialSchema); - - if (error) { + try { + const emptyContext = {}; + tutorialSchema.validate(specProvider(emptyContext)); + } catch (error) { throw new Error(`Unable to register tutorial spec because its invalid. ${error}`); } diff --git a/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts b/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts index d5f8d978d5252..310486bfdfffd 100644 --- a/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts +++ b/src/plugins/vis_type_vega/server/usage_collector/get_usage_collector.ts @@ -7,7 +7,7 @@ */ import { parse } from 'hjson'; -import { ElasticsearchClient, SavedObject } from 'src/core/server'; +import type { ElasticsearchClient } from 'src/core/server'; import { VegaSavedObjectAttributes, VisTypeVegaPluginSetupDependencies } from '../types'; @@ -27,7 +27,7 @@ const getDefaultVegaVisualizations = (home: UsageCollectorDependencies['home']) const sampleDataSets = home?.sampleData.getSampleDatasets() ?? []; sampleDataSets.forEach((sampleDataSet) => - sampleDataSet.savedObjects.forEach((savedObject: SavedObject) => { + sampleDataSet.savedObjects.forEach((savedObject) => { try { if (savedObject.type === 'visualization') { const visState = JSON.parse(savedObject.attributes?.visState); diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 44334889128c4..cf5be4369f79e 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -108,6 +108,7 @@ export class APMPlugin plugins.home?.tutorials.registerTutorial(() => { const ossPart = ossTutorialProvider({}); if (this.currentConfig!['xpack.apm.ui.enabled'] && ossPart.artifacts) { + // @ts-expect-error ossPart.artifacts.application is readonly ossPart.artifacts.application = { path: '/app/apm', label: i18n.translate( diff --git a/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap index 3043de27534f1..c91a891d4825f 100644 --- a/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/feature_registry.test.ts.snap @@ -1,21 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" is not allowed to be empty]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "" 1`] = `"[catalogue.0]: Does not satisfy regexp /^[a-zA-Z0-9:_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "contains space" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "contains space" 1`] = `"[catalogue.0]: Does not satisfy regexp /^[a-zA-Z0-9:_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "contains_invalid()_chars" 1`] = `"child \\"catalogue\\" fails because [\\"catalogue\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a catalogue entry of "contains_invalid()_chars" 1`] = `"[catalogue.0]: Does not satisfy regexp /^[a-zA-Z0-9:_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" is not allowed to be empty]]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "" 1`] = `"[management.kibana.0]: Does not satisfy regexp /^[a-zA-Z0-9:_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains space" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains space\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains space" 1`] = `"[management.kibana.0]: Does not satisfy regexp /^[a-zA-Z0-9:_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains_invalid()_chars" 1`] = `"child \\"management\\" fails because [child \\"kibana\\" fails because [\\"kibana\\" at position 0 fails because [\\"0\\" with value \\"contains_invalid()_chars\\" fails to match the required pattern: /^[a-zA-Z0-9:_-]+$/]]]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with a management id of "contains_invalid()_chars" 1`] = `"[management.kibana.0]: Does not satisfy regexp /^[a-zA-Z0-9:_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "catalogue" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "catalogue" 1`] = `"[id]: [catalogue] is not allowed"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "doesn't match valid regex" 1`] = `"child \\"id\\" fails because [\\"id\\" with value \\"doesn't match valid regex\\" fails to match the required pattern: /^[a-zA-Z0-9_-]+$/]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "doesn't match valid regex" 1`] = `"[id]: Does not satisfy regexp /^[a-zA-Z0-9_-]+$/"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "management" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "management" 1`] = `"[id]: [management] is not allowed"`; -exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "navLinks" 1`] = `"child \\"id\\" fails because [\\"id\\" contains an invalid value]"`; +exports[`FeatureRegistry Kibana Features prevents features from being registered with an ID of "navLinks" 1`] = `"[id]: [navLinks] is not allowed"`; diff --git a/x-pack/plugins/features/server/feature_privilege_iterator.js b/x-pack/plugins/features/server/feature_privilege_iterator.js index f813eeebc9550..842c30d643b67 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator.js +++ b/x-pack/plugins/features/server/feature_privilege_iterator.js @@ -6,5 +6,6 @@ */ // the file created to remove TS cicular dependency between features and security pluin +// https://github.com/elastic/kibana/issues/87388 // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { featurePrivilegeIterator } from '../../security/server/authorization'; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index eb9b35cc644a7..0eb00b43d6f5d 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -158,7 +158,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerKibanaFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"category\\" fails because [\\"category\\" is required]"` + `"[category.id]: expected value of type [string] but got [undefined]"` ); }); @@ -175,7 +175,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerKibanaFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"category\\" fails because [child \\"id\\" fails because [\\"id\\" is required]]"` + `"[category.id]: expected value of type [string] but got [undefined]"` ); }); @@ -192,7 +192,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerKibanaFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"category\\" fails because [child \\"label\\" fails because [\\"label\\" is required]]"` + `"[category.label]: expected value of type [string] but got [undefined]"` ); }); }); @@ -209,7 +209,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerKibanaFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + `"[privileges]: expected at least one defined value but got [undefined]"` ); }); @@ -248,7 +248,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerKibanaFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` + `"[subFeatures]: array size is [1], but cannot be greater than [0]"` ); }); @@ -488,11 +488,12 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - expect(() => - featureRegistry.registerKibanaFeature(feature) - ).toThrowErrorMatchingInlineSnapshot( - `"child \\"privileges\\" fails because [\\"foo\\" is not allowed]"` - ); + expect(() => featureRegistry.registerKibanaFeature(feature)) + .toThrowErrorMatchingInlineSnapshot(` + "[privileges]: types that failed validation: + - [privileges.0]: expected value to equal [null] + - [privileges.1.foo]: definition for this key is missing" + `); }); it(`prevents privileges from specifying app entries that don't exist at the root level`, () => { @@ -1278,7 +1279,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerKibanaFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"reserved\\" fails because [child \\"privileges\\" fails because [\\"privileges\\" at position 0 fails because [child \\"id\\" fails because [\\"id\\" with value \\"reserved_1\\" fails to match the required pattern: /^(?!reserved_)[a-zA-Z0-9_-]+$/]]]]"` + `"[reserved.privileges.0.id]: Does not satisfy regexp /^(?!reserved_)[a-zA-Z0-9_-]+$/"` ); }); @@ -1394,9 +1395,11 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); expect(() => { featureRegistry.registerKibanaFeature(feature1); - }).toThrowErrorMatchingInlineSnapshot( - `"child \\"subFeatures\\" fails because [\\"subFeatures\\" at position 0 fails because [child \\"privilegeGroups\\" fails because [\\"privilegeGroups\\" at position 0 fails because [child \\"privileges\\" fails because [\\"privileges\\" at position 0 fails because [child \\"minimumLicense\\" fails because [\\"minimumLicense\\" is not allowed]]]]]]]"` - ); + }).toThrowErrorMatchingInlineSnapshot(` + "[subFeatures.0.privilegeGroups.0]: types that failed validation: + - [subFeatures.0.privilegeGroups.0.0.privileges.0.minimumLicense]: a value wasn't expected to be present + - [subFeatures.0.privilegeGroups.0.1.groupType]: expected value to equal [independent]" + `); }); it('cannot register feature after getAll has been called', () => { @@ -1575,7 +1578,7 @@ describe('FeatureRegistry', () => { expect(() => featureRegistry.registerElasticsearchFeature(feature) ).toThrowErrorMatchingInlineSnapshot( - `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + `"[privileges]: expected value of type [array] but got [undefined]"` ); }); diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 204c5bdfe2469..51d3331ac7da1 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -5,7 +5,7 @@ * 2.0. */ -import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { difference } from 'lodash'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; @@ -14,7 +14,11 @@ import { FeatureKibanaPrivileges, ElasticsearchFeatureConfig } from '.'; // Each feature gets its own property on the UICapabilities object, // but that object has a few built-in properties which should not be overwritten. -const prohibitedFeatureIds: Array = ['catalogue', 'management', 'navLinks']; +const prohibitedFeatureIds: Set = new Set([ + 'catalogue', + 'management', + 'navLinks', +]); const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; @@ -22,144 +26,211 @@ const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/; const reservedFeaturePrrivilegePartRegex = /^(?!reserved_)[a-zA-Z0-9_-]+$/; export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/; -const validLicenses = ['basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial']; +const validLicenseSchema = schema.oneOf([ + schema.literal('basic'), + schema.literal('standard'), + schema.literal('gold'), + schema.literal('platinum'), + schema.literal('enterprise'), + schema.literal('trial'), +]); // sub-feature privileges are only available with a `gold` license or better, so restricting sub-feature privileges // for `gold` or below doesn't make a whole lot of sense. -const validSubFeaturePrivilegeLicenses = ['platinum', 'enterprise', 'trial']; - -const managementSchema = Joi.object().pattern( - managementSectionIdRegex, - Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)) +const validSubFeaturePrivilegeLicensesSchema = schema.oneOf([ + schema.literal('platinum'), + schema.literal('enterprise'), + schema.literal('trial'), +]); + +const listOfCapabilitiesSchema = schema.arrayOf( + schema.string({ + validate(key: string) { + if (!uiCapabilitiesRegex.test(key)) { + return `Does not satisfy regexp ${uiCapabilitiesRegex.toString()}`; + } + }, + }) +); +const managementSchema = schema.recordOf( + schema.string({ + validate(key: string) { + if (!managementSectionIdRegex.test(key)) { + return `Does not satisfy regexp ${managementSectionIdRegex.toString()}`; + } + }, + }), + listOfCapabilitiesSchema ); -const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)); -const alertingSchema = Joi.array().items(Joi.string()); - -const appCategorySchema = Joi.object({ - id: Joi.string().required(), - label: Joi.string().required(), - ariaLabel: Joi.string(), - euiIconType: Joi.string(), - order: Joi.number(), -}).required(); - -const kibanaPrivilegeSchema = Joi.object({ - excludeFromBasePrivileges: Joi.boolean(), - management: managementSchema, - catalogue: catalogueSchema, - api: Joi.array().items(Joi.string()), - app: Joi.array().items(Joi.string()), - alerting: Joi.object({ - all: alertingSchema, - read: alertingSchema, +const catalogueSchema = listOfCapabilitiesSchema; +const alertingSchema = schema.arrayOf(schema.string()); + +const appCategorySchema = schema.object({ + id: schema.string(), + label: schema.string(), + ariaLabel: schema.maybe(schema.string()), + euiIconType: schema.maybe(schema.string()), + order: schema.maybe(schema.number()), +}); + +const kibanaPrivilegeSchema = schema.object({ + excludeFromBasePrivileges: schema.maybe(schema.boolean()), + management: schema.maybe(managementSchema), + catalogue: schema.maybe(catalogueSchema), + api: schema.maybe(schema.arrayOf(schema.string())), + app: schema.maybe(schema.arrayOf(schema.string())), + alerting: schema.maybe( + schema.object({ + all: schema.maybe(alertingSchema), + read: schema.maybe(alertingSchema), + }) + ), + savedObject: schema.object({ + all: schema.arrayOf(schema.string()), + read: schema.arrayOf(schema.string()), }), - savedObject: Joi.object({ - all: Joi.array().items(Joi.string()).required(), - read: Joi.array().items(Joi.string()).required(), - }).required(), - ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), + ui: listOfCapabilitiesSchema, }); -const kibanaIndependentSubFeaturePrivilegeSchema = Joi.object({ - id: Joi.string().regex(subFeaturePrivilegePartRegex).required(), - name: Joi.string().required(), - includeIn: Joi.string().allow('all', 'read', 'none').required(), - minimumLicense: Joi.string().valid(...validSubFeaturePrivilegeLicenses), - management: managementSchema, - catalogue: catalogueSchema, - alerting: Joi.object({ - all: alertingSchema, - read: alertingSchema, +const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({ + id: schema.string({ + validate(key: string) { + if (!subFeaturePrivilegePartRegex.test(key)) { + return `Does not satisfy regexp ${subFeaturePrivilegePartRegex.toString()}`; + } + }, + }), + name: schema.string(), + includeIn: schema.oneOf([schema.literal('all'), schema.literal('read'), schema.literal('none')]), + minimumLicense: schema.maybe(validSubFeaturePrivilegeLicensesSchema), + management: schema.maybe(managementSchema), + catalogue: schema.maybe(catalogueSchema), + alerting: schema.maybe( + schema.object({ + all: schema.maybe(alertingSchema), + read: schema.maybe(alertingSchema), + }) + ), + api: schema.maybe(schema.arrayOf(schema.string())), + app: schema.maybe(schema.arrayOf(schema.string())), + savedObject: schema.object({ + all: schema.arrayOf(schema.string()), + read: schema.arrayOf(schema.string()), }), - api: Joi.array().items(Joi.string()), - app: Joi.array().items(Joi.string()), - savedObject: Joi.object({ - all: Joi.array().items(Joi.string()).required(), - read: Joi.array().items(Joi.string()).required(), - }).required(), - ui: Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)).required(), + ui: listOfCapabilitiesSchema, }); -const kibanaMutuallyExclusiveSubFeaturePrivilegeSchema = kibanaIndependentSubFeaturePrivilegeSchema.keys( +const kibanaMutuallyExclusiveSubFeaturePrivilegeSchema = kibanaIndependentSubFeaturePrivilegeSchema.extends( { - minimumLicense: Joi.forbidden(), + minimumLicense: schema.never(), } ); -const kibanaSubFeatureSchema = Joi.object({ - name: Joi.string().required(), - privilegeGroups: Joi.array().items( - Joi.object({ - groupType: Joi.string().valid('mutually_exclusive', 'independent').required(), - privileges: Joi.when('groupType', { - is: 'mutually_exclusive', - then: Joi.array().items(kibanaMutuallyExclusiveSubFeaturePrivilegeSchema).min(1), - otherwise: Joi.array().items(kibanaIndependentSubFeaturePrivilegeSchema).min(1), - }), - }) +const kibanaSubFeatureSchema = schema.object({ + name: schema.string(), + privilegeGroups: schema.maybe( + schema.arrayOf( + schema.oneOf([ + schema.object({ + groupType: schema.literal('mutually_exclusive'), + privileges: schema.maybe( + schema.arrayOf(kibanaMutuallyExclusiveSubFeaturePrivilegeSchema, { minSize: 1 }) + ), + }), + schema.object({ + groupType: schema.literal('independent'), + privileges: schema.maybe( + schema.arrayOf(kibanaIndependentSubFeaturePrivilegeSchema, { minSize: 1 }) + ), + }), + ]) + ) ), }); -const kibanaFeatureSchema = Joi.object({ - id: Joi.string() - .regex(featurePrivilegePartRegex) - .invalid(...prohibitedFeatureIds) - .required(), - name: Joi.string().required(), - category: appCategorySchema, - order: Joi.number(), - excludeFromBasePrivileges: Joi.boolean(), - minimumLicense: Joi.string().valid(...validLicenses), - app: Joi.array().items(Joi.string()).required(), - management: managementSchema, - catalogue: catalogueSchema, - alerting: alertingSchema, - privileges: Joi.object({ - all: kibanaPrivilegeSchema, - read: kibanaPrivilegeSchema, - }) - .allow(null) - .required(), - subFeatures: Joi.when('privileges', { - is: null, - then: Joi.array().items(kibanaSubFeatureSchema).max(0), - otherwise: Joi.array().items(kibanaSubFeatureSchema), +const kibanaFeatureSchema = schema.object({ + id: schema.string({ + validate(value: string) { + if (!featurePrivilegePartRegex.test(value)) { + return `Does not satisfy regexp ${featurePrivilegePartRegex.toString()}`; + } + if (prohibitedFeatureIds.has(value)) { + return `[${value}] is not allowed`; + } + }, }), - privilegesTooltip: Joi.string(), - reserved: Joi.object({ - description: Joi.string().required(), - privileges: Joi.array() - .items( - Joi.object({ - id: Joi.string().regex(reservedFeaturePrrivilegePartRegex).required(), - privilege: kibanaPrivilegeSchema.required(), + name: schema.string(), + category: appCategorySchema, + order: schema.maybe(schema.number()), + excludeFromBasePrivileges: schema.maybe(schema.boolean()), + minimumLicense: schema.maybe(validLicenseSchema), + app: schema.arrayOf(schema.string()), + management: schema.maybe(managementSchema), + catalogue: schema.maybe(catalogueSchema), + alerting: schema.maybe(alertingSchema), + privileges: schema.oneOf([ + schema.literal(null), + schema.object({ + all: schema.maybe(kibanaPrivilegeSchema), + read: schema.maybe(kibanaPrivilegeSchema), + }), + ]), + subFeatures: schema.maybe( + schema.conditional( + schema.siblingRef('privileges'), + null, + // allows an empty array only + schema.arrayOf(schema.never(), { maxSize: 0 }), + schema.arrayOf(kibanaSubFeatureSchema) + ) + ), + privilegesTooltip: schema.maybe(schema.string()), + reserved: schema.maybe( + schema.object({ + description: schema.string(), + privileges: schema.arrayOf( + schema.object({ + id: schema.string({ + validate(value: string) { + if (!reservedFeaturePrrivilegePartRegex.test(value)) { + return `Does not satisfy regexp ${reservedFeaturePrrivilegePartRegex.toString()}`; + } + }, + }), + privilege: kibanaPrivilegeSchema, }) - ) - .required(), - }), + ), + }) + ), }); -const elasticsearchPrivilegeSchema = Joi.object({ - ui: Joi.array().items(Joi.string()).required(), - requiredClusterPrivileges: Joi.array().items(Joi.string()), - requiredIndexPrivileges: Joi.object().pattern(Joi.string(), Joi.array().items(Joi.string())), - requiredRoles: Joi.array().items(Joi.string()), +const elasticsearchPrivilegeSchema = schema.object({ + ui: schema.arrayOf(schema.string()), + requiredClusterPrivileges: schema.maybe(schema.arrayOf(schema.string())), + requiredIndexPrivileges: schema.maybe( + schema.recordOf(schema.string(), schema.arrayOf(schema.string())) + ), + requiredRoles: schema.maybe(schema.arrayOf(schema.string())), }); -const elasticsearchFeatureSchema = Joi.object({ - id: Joi.string() - .regex(featurePrivilegePartRegex) - .invalid(...prohibitedFeatureIds) - .required(), - management: managementSchema, - catalogue: catalogueSchema, - privileges: Joi.array().items(elasticsearchPrivilegeSchema).required(), +const elasticsearchFeatureSchema = schema.object({ + id: schema.string({ + validate(value: string) { + if (!featurePrivilegePartRegex.test(value)) { + return `Does not satisfy regexp ${featurePrivilegePartRegex.toString()}`; + } + if (prohibitedFeatureIds.has(value)) { + return `[${value}] is not allowed`; + } + }, + }), + management: schema.maybe(managementSchema), + catalogue: schema.maybe(catalogueSchema), + privileges: schema.arrayOf(elasticsearchPrivilegeSchema), }); export function validateKibanaFeature(feature: KibanaFeatureConfig) { - const validateResult = Joi.validate(feature, kibanaFeatureSchema); - if (validateResult.error) { - throw validateResult.error; - } + kibanaFeatureSchema.validate(feature); + // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. const { app = [], management = {}, catalogue = [], alerting = [] } = feature; @@ -343,10 +414,7 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { } export function validateElasticsearchFeature(feature: ElasticsearchFeatureConfig) { - const validateResult = Joi.validate(feature, elasticsearchFeatureSchema); - if (validateResult.error) { - throw validateResult.error; - } + elasticsearchFeatureSchema.validate(feature); // the following validation can't be enforced by the Joi schema without a very convoluted and verbose definition const { privileges } = feature; privileges.forEach((privilege, index) => {