Drilldown allow list (#85779)

* feat: 🎸 add ROW_CLICK_TRIGGER

* feat: 🎸 wire row click event to UI Actions trigger in Lens

* feat: 🎸 add row click trigger to url drilldown

* feat: 🎸 add datatable to row click context

* feat: 🎸 pass in row index in row click trigger context

* feat: 🎸 add columns to row click trigger context

* feat: 🎸 fill values and keys event scope array

* feat: 🎸 generate correct row scope variables

* fix: 🐛 report triggers from lens embeddable

* feat: 🎸 add sample preview for row click trigger

* feat: 🎸 remove url drilldown preview box

* chore: 🤖 remove mock variable generation functions

* feat: 🎸 generate context and global variable lists

* feat: 🎸 preview event variable list

* feat: 🎸 show empty url error on blur

* feat: 🎸 add ability to always show popup for executed actions

* refactor: 💡 rename multiple action execution method

* fix: 🐛 don't add separator befor group on no main items

* feat: 🎸 wire in uiActions service into datatable renderer

* feat: 🎸 check each row if it has compatible row click actions

* feat: 🎸 allow passing data to expression renderer

* feat: 🎸 add isEmbeddable helper

* feat: 🎸 pass embeddable to lens table renderer

* feat: 🎸 hide lens table row actions which are empty

* feat: 🎸 re-render lens embeddable when dynamic actions chagne

* feat: 🎸 hide actions column if there are no row actions

* feat: 🎸 re-render lens embeddable on view mode chagne

* fix: 🐛 fix TypeScript errors

* chore: 🤖 fix TypeScript errors

* docs: ✏️ update auto-generated docs

* feat: 🎸 add hasCompatibleActions to expression layer

* feat: 🎸 remove "data" from expression renderer handlers

* fix: 🐛 fix TypeScript errors

* test: 💍 fix Jest tests

* docs: ✏️ update autogenerated docs

* fix: 🐛 wrap event payload into data

* test: 💍 add "alwaysShowPopup" test

* chore: 🤖 add comment requested in review

https://github.com/elastic/kibana/pull/83167#discussion_r537340216

* test: 💍 add hasCompatibleActions test

* test: 💍 add datatable renderer test

* test: 💍 add Lens embeddable input change tests

* test: 💍 add embeddable row click test

* fix: 🐛 add url validation

* test: 💍 add url drilldown tests

* docs: ✏️ remove url drilldown preview from docs

* docs: ✏️ remove preview from url templating

* docs: ✏️ add row click description

* chore: 🤖 move 36.5 KB bundle balance to url_drilldown

* test: 💍 simplify test case

* feat: 🎸 check if external URL is valid before redirecting user

* test: 💍 check for external URL validity

* feat: 🎸 check if external URL is allowed in exec and getHref

* test: 💍 fix test import
This commit is contained in:
Vadim Dalecky 2020-12-15 12:17:19 +01:00 committed by GitHub
parent edb3bb732e
commit a57cba4978
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 106 additions and 8 deletions

View file

@ -4,9 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { IExternalUrl } from 'src/core/public';
import { UrlDrilldown, ActionContext, Config } from './url_drilldown';
import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables';
import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common';
import { of } from '../../../../../../src/plugins/kibana_utils';
import { createPoint, rowClickData, TestEmbeddable } from './test/data';
import {
VALUE_CLICK_TRIGGER,
@ -59,13 +61,27 @@ const mockEmbeddable = ({
const mockNavigateToUrl = jest.fn(() => Promise.resolve());
describe('UrlDrilldown', () => {
const urlDrilldown = new UrlDrilldown({
class TextExternalUrl implements IExternalUrl {
constructor(private readonly isCorrect: boolean = true) {}
public validateUrl(url: string): URL | null {
return this.isCorrect ? new URL(url) : null;
}
}
const createDrilldown = (isExternalUrlValid: boolean = true) => {
const drilldown = new UrlDrilldown({
externalUrl: new TextExternalUrl(isExternalUrlValid),
getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }),
getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs',
getVariablesHelpDocsLink: () => 'http://localhost:5601/docs',
navigateToUrl: mockNavigateToUrl,
});
return drilldown;
};
describe('UrlDrilldown', () => {
const urlDrilldown = createDrilldown();
test('license', () => {
expect(urlDrilldown.minimalLicense).toBe('gold');
@ -125,6 +141,30 @@ describe('UrlDrilldown', () => {
await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false);
});
test('not compatible if external URL is denied', async () => {
const drilldown1 = createDrilldown(true);
const drilldown2 = createDrilldown(false);
const config: Config = {
url: {
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
},
openInNewTab: false,
};
const context: ActionContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
};
const result1 = await drilldown1.isCompatible(config, context);
const result2 = await drilldown2.isCompatible(config, context);
expect(result1).toBe(true);
expect(result2).toBe(false);
});
});
describe('getHref & execute', () => {
@ -173,6 +213,42 @@ describe('UrlDrilldown', () => {
await expect(urlDrilldown.execute(config, context)).rejects.toThrowError();
expect(mockNavigateToUrl).not.toBeCalled();
});
test('should throw on denied external URL', async () => {
const drilldown1 = createDrilldown(true);
const drilldown2 = createDrilldown(false);
const config: Config = {
url: {
template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`,
},
openInNewTab: false,
};
const context: ActionContext = {
data: {
data: mockDataPoints,
},
embeddable: mockEmbeddable,
};
const url = await drilldown1.getHref(config, context);
await drilldown1.execute(config, context);
expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`);
expect(mockNavigateToUrl).toBeCalledWith(url);
const [, error1] = await of(drilldown2.getHref(config, context));
const [, error2] = await of(drilldown2.execute(config, context));
expect(error1).toBeInstanceOf(Error);
expect(error1.message).toMatchInlineSnapshot(
`"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."`
);
expect(error2).toBeInstanceOf(Error);
expect(error2.message).toMatchInlineSnapshot(
`"External URL [https://elasti.co/?test&(language:kuery,query:test)] was denied by ExternalUrl service. You can configure external URL policies using \\"externalUrl.policy\\" setting in kibana.yml."`
);
});
});
describe('variables', () => {

View file

@ -6,6 +6,7 @@
import React from 'react';
import { getFlattenedObject } from '@kbn/std';
import { IExternalUrl } from 'src/core/public';
import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public';
import {
ChartActionContext,
@ -31,6 +32,7 @@ import { getPanelVariables, getEventScope, getEventVariableList } from './url_dr
import { txtUrlDrilldownDisplayName } from './i18n';
interface UrlDrilldownDeps {
externalUrl: IExternalUrl;
getGlobalScope: () => UrlDrilldownGlobalScope;
navigateToUrl: (url: string) => Promise<void>;
getSyntaxHelpDocsLink: () => string;
@ -55,7 +57,7 @@ const URL_DRILLDOWN = 'URL_DRILLDOWN';
export class UrlDrilldown implements Drilldown<Config, UrlTrigger, ActionFactoryContext> {
public readonly id = URL_DRILLDOWN;
constructor(private deps: UrlDrilldownDeps) {}
constructor(private readonly deps: UrlDrilldownDeps) {}
public readonly order = 8;
@ -109,18 +111,37 @@ export class UrlDrilldown implements Drilldown<Config, UrlTrigger, ActionFactory
console.warn(
`UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.`
);
return false;
}
return Promise.resolve(isValid);
const url = this.buildUrl(config, context);
const validUrl = this.deps.externalUrl.validateUrl(url);
if (!validUrl) {
return false;
}
return true;
};
public readonly getHref = async (config: Config, context: ActionContext) => {
const scope = this.getRuntimeVariables(context);
return urlDrilldownCompileUrl(config.url.template, scope);
private buildUrl(config: Config, context: ActionContext): string {
const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context));
return url;
}
public readonly getHref = async (config: Config, context: ActionContext): Promise<string> => {
const url = this.buildUrl(config, context);
const validUrl = this.deps.externalUrl.validateUrl(url);
if (!validUrl) {
throw new Error(
`External URL [${url}] was denied by ExternalUrl service. ` +
`You can configure external URL policies using "externalUrl.policy" setting in kibana.yml.`
);
}
return url;
};
public readonly execute = async (config: Config, context: ActionContext) => {
const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context));
const url = await this.getHref(config, context);
if (config.openInNewTab) {
window.open(url, '_blank', 'noopener');
} else {

View file

@ -38,6 +38,7 @@ export class UrlDrilldownPlugin
const startServices = createStartServicesGetter(core.getStartServices);
plugins.uiActionsEnhanced.registerDrilldown(
new UrlDrilldown({
externalUrl: core.http.externalUrl,
getGlobalScope: urlDrilldownGlobalScopeProvider({ core }),
navigateToUrl: (url: string) =>
core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)),