Migrate from Joi to @kbn/config-schema in "home" and "features" plugins (#100201)

* add a link for issue to remove circular deps

* features: migrate from joi to config-schema

* update tests

* migrate home tutorials to config-schema

* migrate home dataset validation to config schema

* remove unnecessary type. we cannot guarantee this is a valid SO

* address Pierres comments
This commit is contained in:
Mikhail Shustov 2021-05-18 12:33:16 +02:00 committed by GitHub
parent 15abf24339
commit 574f6595ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 442 additions and 417 deletions

View file

@ -15,7 +15,6 @@ export type { TutorialsRegistrySetup, TutorialsRegistryStart } from './tutorials
export { TutorialsCategory } from './tutorials';
export type {
ParamTypes,
InstructionSetSchema,
ParamsSchema,
InstructionsSchema,

View file

@ -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<T = unknown> {
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<SavedObject<T>>;
dataIndices: DataIndexSchema[];
status?: string | undefined;
statusMsg?: unknown;
}
export type SampleDatasetProvider = () => SampleDatasetSchema;

View file

@ -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<typeof dataIndexSchema>;
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(),
const appLinkSchema = schema.object({
path: schema.string(),
label: schema.string(),
icon: schema.string(),
});
export type AppLinkSchema = TypeOf<typeof appLinkSchema>;
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<TypeOf<typeof sampleDataSchema>>;

View file

@ -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) {

View file

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

View file

@ -12,7 +12,6 @@ export type { TutorialsRegistrySetup, TutorialsRegistryStart } from './tutorials
export { TutorialsCategory } from './lib/tutorials_registry_types';
export type {
ParamTypes,
InstructionSetSchema,
ParamsSchema,
InstructionsSchema,

View file

@ -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<typeof dashboardSchema>;
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(),
})
),
});
export type ArtifactsSchema = TypeOf<typeof artifactsSchema>;
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 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(),
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<typeof instructionSchema>;
const instructionVariantSchema = schema.object({
id: schema.string(),
instructions: schema.arrayOf(instructionSchema),
});
const instructionSchema = Joi.object({
title: Joi.string(),
textPre: Joi.string(),
commands: Joi.array().items(Joi.string().allow('')),
textPost: Joi.string(),
});
export type InstructionVariant = TypeOf<typeof instructionVariantSchema>;
const instructionVariantSchema = Joi.object({
id: Joi.string().required(),
instructions: Joi.array().items(instructionSchema).required(),
});
const instructionSetSchema = Joi.object({
title: Joi.string(),
callOut: Joi.object({
title: Joi.string().required(),
message: Joi.string(),
iconType: Joi.string(),
}),
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),
});
export type InstructionSetSchema = TypeOf<typeof instructionSetSchema>;
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(),
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<typeof paramSchema>;
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 type InstructionsSchema = TypeOf<typeof instructionSchema>;
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(),
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<typeof tutorialSchema>;

View file

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

View file

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

View file

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

View file

@ -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<any>) => {
sampleDataSet.savedObjects.forEach((savedObject) => {
try {
if (savedObject.type === 'visualization') {
const visState = JSON.parse(savedObject.attributes?.visState);

View file

@ -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(

View file

@ -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"`;

View file

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

View file

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

View file

@ -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<keyof UICapabilities> = ['catalogue', 'management', 'navLinks'];
const prohibitedFeatureIds: Set<keyof UICapabilities> = 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 validSubFeaturePrivilegeLicensesSchema = schema.oneOf([
schema.literal('platinum'),
schema.literal('enterprise'),
schema.literal('trial'),
]);
const managementSchema = Joi.object().pattern(
managementSectionIdRegex,
Joi.array().items(Joi.string().regex(uiCapabilitiesRegex))
const listOfCapabilitiesSchema = schema.arrayOf(
schema.string({
validate(key: string) {
if (!uiCapabilitiesRegex.test(key)) {
return `Does not satisfy regexp ${uiCapabilitiesRegex.toString()}`;
}
},
})
);
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 managementSchema = schema.recordOf(
schema.string({
validate(key: string) {
if (!managementSectionIdRegex.test(key)) {
return `Does not satisfy regexp ${managementSectionIdRegex.toString()}`;
}
},
}),
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(),
listOfCapabilitiesSchema
);
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 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 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()),
}),
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 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()),
}),
ui: listOfCapabilitiesSchema,
});
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 = 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`;
}
},
}),
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,
})
),
})
),
});
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),
}),
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(),
})
)
.required(),
}),
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 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 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) => {