Warn users when security is not configured (#78545)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Larry Gregory 2020-10-05 19:47:17 -04:00 committed by GitHub
parent 4d4e53f7c7
commit 49c669ca61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1867 additions and 7 deletions

2
.github/CODEOWNERS vendored
View file

@ -255,6 +255,8 @@
# Security
/src/core/server/csp/ @elastic/kibana-security @elastic/kibana-platform
/src/plugins/security_oss/ @elastic/kibana-security
/test/security_functional/ @elastic/kibana-security
/x-pack/plugins/spaces/ @elastic/kibana-security
/x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security
/x-pack/plugins/security/ @elastic/kibana-security

View file

@ -37,6 +37,7 @@
"regionMap": "src/plugins/region_map",
"savedObjects": "src/plugins/saved_objects",
"savedObjectsManagement": "src/plugins/saved_objects_management",
"security": "src/plugins/security_oss",
"server": "src/legacy/server",
"statusPage": "src/legacy/core_plugins/status_page",
"telemetry": [

View file

@ -155,6 +155,11 @@ It also provides a stateful version of it on the start contract.
|WARNING: Missing README.
|{kib-repo}blob/{branch}/src/plugins/security_oss/README.md[securityOss]
|securityOss is responsible for educating users about Elastic's free security features,
so they can properly protect the data within their clusters.
|{kib-repo}blob/{branch}/src/plugins/share/README.md[share]
|Replaces the legacy ui/share module for registering share context menus.

View file

@ -23,6 +23,7 @@ const alwaysImportedTests = [
require.resolve('../test/plugin_functional/config.ts'),
require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'),
require.resolve('../test/new_visualize_flow/config.js'),
require.resolve('../test/security_functional/config.ts'),
];
// eslint-disable-next-line no-restricted-syntax
const onlyNotInCoverageTests = [

View file

@ -93,6 +93,7 @@ kibana_vars=(
path.data
pid.file
regionmap
security.showInsecureClusterWarning
server.basePath
server.customResponseHeaders
server.compression.enabled

View file

@ -0,0 +1,4 @@
# `securityOss` plugin
`securityOss` is responsible for educating users about Elastic's free security features,
so they can properly protect the data within their clusters.

View file

@ -0,0 +1,10 @@
{
"id": "securityOss",
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["security"],
"ui": true,
"server": true,
"requiredPlugins": [],
"requiredBundles": []
}

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export interface ConfigType {
showInsecureClusterWarning: boolean;
}

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginInitializerContext } from 'kibana/public';
import { SecurityOssPlugin } from './plugin';
export { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin';
export const plugin = (initializerContext: PluginInitializerContext) =>
new SecurityOssPlugin(initializerContext);

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { defaultAlertText } from './default_alert';
describe('defaultAlertText', () => {
it('creates a valid MountPoint that can cleanup correctly', () => {
const mountPoint = defaultAlertText(jest.fn());
const el = document.createElement('div');
const unmount = mountPoint(el);
expect(el.querySelectorAll('[data-test-subj="insecureClusterDefaultAlertText"]')).toHaveLength(
1
);
unmount();
expect(el).toMatchInlineSnapshot(`<div />`);
});
});

View file

@ -0,0 +1,97 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
EuiButton,
EuiCheckbox,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { MountPoint } from 'kibana/public';
import React, { useState } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
export const defaultAlertTitle = i18n.translate('security.checkup.insecureClusterTitle', {
defaultMessage: 'Please secure your installation',
});
export const defaultAlertText: (onDismiss: (persist: boolean) => void) => MountPoint = (
onDismiss
) => (e) => {
const AlertText = () => {
const [persist, setPersist] = useState(false);
return (
<I18nProvider>
<div data-test-subj="insecureClusterDefaultAlertText">
<EuiText size="s">
<FormattedMessage
id="security.checkup.insecureClusterMessage"
defaultMessage="Our free security features can protect against unauthorized access."
/>
</EuiText>
<EuiSpacer />
<EuiCheckbox
id="persistDismissedAlertPreference"
checked={persist}
onChange={(changeEvent) => setPersist(changeEvent.target.checked)}
label={i18n.translate('security.checkup.dontShowAgain', {
defaultMessage: `Don't show again`,
})}
/>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
color="primary"
fill
href="https://www.elastic.co/what-is/elastic-stack-security"
target="_blank"
>
{i18n.translate('security.checkup.learnMoreButtonText', {
defaultMessage: `Learn more`,
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={() => onDismiss(persist)}
data-test-subj="defaultDismissAlertButton"
>
{i18n.translate('security.checkup.dismissButtonText', {
defaultMessage: `Dismiss`,
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</I18nProvider>
);
};
render(<AlertText />, e);
return () => unmountComponentAtNode(e);
};

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { defaultAlertTitle, defaultAlertText } from './default_alert';

View file

@ -0,0 +1,24 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export {
InsecureClusterService,
InsecureClusterServiceSetup,
InsecureClusterServiceStart,
} from './insecure_cluster_service';

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
InsecureClusterServiceSetup,
InsecureClusterServiceStart,
} from './insecure_cluster_service';
export const mockInsecureClusterService = {
createSetup: () => {
return {
setAlertTitle: jest.fn(),
setAlertText: jest.fn(),
} as InsecureClusterServiceSetup;
},
createStart: () => {
return {
hideAlert: jest.fn(),
} as InsecureClusterServiceStart;
},
};

View file

@ -0,0 +1,336 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { InsecureClusterService } from './insecure_cluster_service';
import { ConfigType } from '../config';
import { coreMock } from '../../../../core/public/mocks';
import { nextTick } from 'test_utils/enzyme_helpers';
let mockOnDismissCallback: (persist: boolean) => void = jest.fn().mockImplementation(() => {
throw new Error('expected callback to be replaced!');
});
jest.mock('./components', () => {
return {
defaultAlertTitle: 'mocked default alert title',
defaultAlertText: (onDismiss: any) => {
mockOnDismissCallback = onDismiss;
return 'mocked default alert text';
},
};
});
interface InitOpts {
displayAlert?: boolean;
isAnonymousPath?: boolean;
tenant?: string;
}
function initCore({
displayAlert = true,
isAnonymousPath = false,
tenant = '/server-base-path',
}: InitOpts = {}) {
const coreSetup = coreMock.createSetup();
(coreSetup.http.basePath.serverBasePath as string) = tenant;
const coreStart = coreMock.createStart();
coreStart.http.get.mockImplementation(async (url: unknown) => {
if (url === '/internal/security_oss/display_insecure_cluster_alert') {
return { displayAlert };
}
throw new Error(`unexpected call to http.get: ${url}`);
});
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(isAnonymousPath);
coreStart.notifications.toasts.addWarning.mockReturnValue({ id: 'mock_alert_id' });
return { coreSetup, coreStart };
}
describe('InsecureClusterService', () => {
describe('display scenarios', () => {
it('does not display an alert when the warning is explicitly disabled via config', async () => {
const config: ConfigType = { showInsecureClusterWarning: false };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
it('does not display an alert when the endpoint check returns false', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: false });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
it('does not display an alert when on an anonymous path', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true, isAnonymousPath: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
it('only reads storage information from the current tenant', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({
displayAlert: true,
tenant: '/my-specific-tenant',
});
const storage = coreMock.createStorage();
storage.getItem.mockReturnValue(JSON.stringify({ show: false }));
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(storage.getItem).toHaveBeenCalledTimes(1);
expect(storage.getItem).toHaveBeenCalledWith(
'insecureClusterWarningVisibility/my-specific-tenant'
);
});
it('does not display an alert when hidden via storage', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
storage.getItem.mockReturnValue(JSON.stringify({ show: false }));
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.addWarning).not.toHaveBeenCalled();
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
it('displays an alert when persisted preference is corrupted', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
storage.getItem.mockReturnValue('{ this is a string of invalid JSON');
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
it('displays an alert when enabled via config and endpoint checks', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"iconType": "alert",
"text": "mocked default alert text",
"title": "mocked default alert title",
},
Object {
"toastLifeTimeMs": 864000000,
},
]
`);
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
it('dismisses the alert when requested, and remembers this preference', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
mockOnDismissCallback(true);
expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1);
expect(storage.setItem).toHaveBeenCalledWith(
'insecureClusterWarningVisibility/server-base-path',
JSON.stringify({ show: false })
);
});
});
describe('#setup', () => {
it('allows the alert title and text to be replaced exactly once', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const storage = coreMock.createStorage();
const { coreSetup } = initCore();
const service = new InsecureClusterService(config, storage);
const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup });
setAlertTitle('some new title');
setAlertText('some new alert text');
expect(() => setAlertTitle('')).toThrowErrorMatchingInlineSnapshot(
`"alert title has already been set"`
);
expect(() => setAlertText('')).toThrowErrorMatchingInlineSnapshot(
`"alert text has already been set"`
);
});
it('allows the alert title and text to be replaced', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
const { setAlertTitle, setAlertText } = service.setup({ core: coreSetup });
setAlertTitle('some new title');
setAlertText('some new alert text');
service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"iconType": "alert",
"text": "some new alert text",
"title": "some new title",
},
Object {
"toastLifeTimeMs": 864000000,
},
]
`);
expect(coreStart.notifications.toasts.remove).not.toHaveBeenCalled();
expect(storage.setItem).not.toHaveBeenCalled();
});
});
describe('#start', () => {
it('allows the alert to be hidden via start contract, and remembers this preference', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
const { hideAlert } = service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
hideAlert(true);
expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1);
expect(storage.setItem).toHaveBeenCalledWith(
'insecureClusterWarningVisibility/server-base-path',
JSON.stringify({ show: false })
);
});
it('allows the alert to be hidden via start contract, and does not remember the preference', async () => {
const config: ConfigType = { showInsecureClusterWarning: true };
const { coreSetup, coreStart } = initCore({ displayAlert: true });
const storage = coreMock.createStorage();
const service = new InsecureClusterService(config, storage);
service.setup({ core: coreSetup });
const { hideAlert } = service.start({ core: coreStart });
await nextTick();
expect(coreStart.http.get).toHaveBeenCalledTimes(1);
expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledTimes(1);
hideAlert(false);
expect(coreStart.notifications.toasts.remove).toHaveBeenCalledTimes(1);
expect(storage.setItem).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,164 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CoreSetup, CoreStart, MountPoint, Toast } from 'kibana/public';
import { BehaviorSubject, combineLatest, from } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { ConfigType } from '../config';
import { defaultAlertText, defaultAlertTitle } from './components';
interface SetupDeps {
core: Pick<CoreSetup, 'http'>;
}
interface StartDeps {
core: Pick<CoreStart, 'notifications' | 'http' | 'application'>;
}
export interface InsecureClusterServiceSetup {
setAlertTitle: (alertTitle: string | MountPoint) => void;
setAlertText: (alertText: string | MountPoint) => void;
}
export interface InsecureClusterServiceStart {
hideAlert: (persist: boolean) => void;
}
export class InsecureClusterService {
private enabled: boolean;
private alertVisibility$: BehaviorSubject<boolean>;
private storage: Storage;
private alertToast?: Toast;
private alertTitle?: string | MountPoint;
private alertText?: string | MountPoint;
private storageKey?: string;
constructor(config: Pick<ConfigType, 'showInsecureClusterWarning'>, storage: Storage) {
this.storage = storage;
this.enabled = config.showInsecureClusterWarning;
this.alertVisibility$ = new BehaviorSubject(this.enabled);
}
public setup({ core }: SetupDeps): InsecureClusterServiceSetup {
const tenant = core.http.basePath.serverBasePath;
this.storageKey = `insecureClusterWarningVisibility${tenant}`;
this.enabled = this.enabled && this.getPersistedVisibilityPreference();
this.alertVisibility$.next(this.enabled);
return {
setAlertTitle: (alertTitle: string | MountPoint) => {
if (this.alertTitle) {
throw new Error('alert title has already been set');
}
this.alertTitle = alertTitle;
},
setAlertText: (alertText: string | MountPoint) => {
if (this.alertText) {
throw new Error('alert text has already been set');
}
this.alertText = alertText;
},
};
}
public start({ core }: StartDeps): InsecureClusterServiceStart {
const shouldInitialize =
this.enabled && !core.http.anonymousPaths.isAnonymous(window.location.pathname);
if (shouldInitialize) {
this.initializeAlert(core);
}
return {
hideAlert: (persist: boolean) => this.setAlertVisibility(false, persist),
};
}
private initializeAlert(core: StartDeps['core']) {
const displayAlert$ = from(
core.http
.get<{ displayAlert: boolean }>('/internal/security_oss/display_insecure_cluster_alert')
.catch((e) => {
// in the event we can't make this call, assume we shouldn't display this alert.
return { displayAlert: false };
})
);
// 10 days is reasonably long enough to call "forever" for a page load.
// Can't go too much longer than this. See https://github.com/elastic/kibana/issues/64264#issuecomment-618400354
const oneMinute = 60000;
const tenDays = oneMinute * 60 * 24 * 10;
combineLatest([displayAlert$, this.alertVisibility$])
.pipe(
map(([{ displayAlert }, isAlertVisible]) => displayAlert && isAlertVisible),
distinctUntilChanged()
)
.subscribe((showAlert) => {
if (showAlert && !this.alertToast) {
this.alertToast = core.notifications.toasts.addWarning(
{
title: this.alertTitle ?? defaultAlertTitle,
text:
this.alertText ??
defaultAlertText((persist: boolean) => this.setAlertVisibility(false, persist)),
iconType: 'alert',
},
{
toastLifeTimeMs: tenDays,
}
);
} else if (!showAlert && this.alertToast) {
core.notifications.toasts.remove(this.alertToast);
this.alertToast = undefined;
}
});
}
private setAlertVisibility(show: boolean, persist: boolean) {
if (!this.enabled) {
return;
}
this.alertVisibility$.next(show);
if (persist) {
this.setPersistedVisibilityPreference(show);
}
}
private getPersistedVisibilityPreference() {
const entry = this.storage.getItem(this.storageKey!) ?? '{}';
try {
const { show = true } = JSON.parse(entry);
return show;
} catch (e) {
return true;
}
}
private setPersistedVisibilityPreference(show: boolean) {
this.storage.setItem(this.storageKey!, JSON.stringify({ show }));
}
}

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { mockSecurityOssPlugin } from './plugin.mock';

View file

@ -0,0 +1,34 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { mockInsecureClusterService } from './insecure_cluster_service/insecure_cluster_service.mock';
import { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin';
export const mockSecurityOssPlugin = {
createSetup: () => {
return {
insecureCluster: mockInsecureClusterService.createSetup(),
} as DeeplyMockedKeys<SecurityOssPluginSetup>;
},
createStart: () => {
return {
insecureCluster: mockInsecureClusterService.createStart(),
} as DeeplyMockedKeys<SecurityOssPluginStart>;
},
};

View file

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { ConfigType } from './config';
import {
InsecureClusterService,
InsecureClusterServiceSetup,
InsecureClusterServiceStart,
} from './insecure_cluster_service';
export interface SecurityOssPluginSetup {
insecureCluster: InsecureClusterServiceSetup;
}
export interface SecurityOssPluginStart {
insecureCluster: InsecureClusterServiceStart;
}
export class SecurityOssPlugin
implements Plugin<SecurityOssPluginSetup, SecurityOssPluginStart, {}, {}> {
private readonly config: ConfigType;
private insecureClusterService: InsecureClusterService;
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<ConfigType>();
this.insecureClusterService = new InsecureClusterService(this.config, localStorage);
}
public setup(core: CoreSetup) {
return {
insecureCluster: this.insecureClusterService.setup({ core }),
};
}
public start(core: CoreStart) {
return {
insecureCluster: this.insecureClusterService.start({ core }),
};
}
}

View file

@ -0,0 +1,148 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { elasticsearchServiceMock, loggingSystemMock } from '../../../core/server/mocks';
import { createClusterDataCheck } from './check_cluster_data';
describe('checkClusterForUserData', () => {
it('returns false if no data is found', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
esClient.cat.indices.mockResolvedValue(
elasticsearchServiceMock.createApiResponse({ body: [] })
);
const log = loggingSystemMock.createLogger();
const response = await createClusterDataCheck()(esClient, log);
expect(response).toEqual(false);
expect(esClient.cat.indices).toHaveBeenCalledTimes(1);
});
it('returns false if data only exists in system indices', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
esClient.cat.indices.mockResolvedValue(
elasticsearchServiceMock.createApiResponse({
body: [
{
index: '.kibana',
'docs.count': 500,
},
{
index: 'kibana_sample_ecommerce_data',
'docs.count': 20,
},
{
index: '.somethingElse',
'docs.count': 20,
},
],
})
);
const log = loggingSystemMock.createLogger();
const response = await createClusterDataCheck()(esClient, log);
expect(response).toEqual(false);
expect(esClient.cat.indices).toHaveBeenCalledTimes(1);
});
it('returns true if data exists in non-system indices', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
esClient.cat.indices.mockResolvedValue(
elasticsearchServiceMock.createApiResponse({
body: [
{
index: '.kibana',
'docs.count': 500,
},
{
index: 'some_real_index',
'docs.count': 20,
},
],
})
);
const log = loggingSystemMock.createLogger();
const response = await createClusterDataCheck()(esClient, log);
expect(response).toEqual(true);
});
it('checks each time until the first true response is returned, then stops checking', async () => {
const esClient = elasticsearchServiceMock.createElasticsearchClient();
esClient.cat.indices
.mockResolvedValueOnce(
elasticsearchServiceMock.createApiResponse({
body: [],
})
)
.mockRejectedValueOnce(new Error('something terrible happened'))
.mockResolvedValueOnce(
elasticsearchServiceMock.createApiResponse({
body: [
{
index: '.kibana',
'docs.count': 500,
},
],
})
)
.mockResolvedValueOnce(
elasticsearchServiceMock.createApiResponse({
body: [
{
index: 'some_real_index',
'docs.count': 20,
},
],
})
);
const log = loggingSystemMock.createLogger();
const doesClusterHaveUserData = createClusterDataCheck();
let response = await doesClusterHaveUserData(esClient, log);
expect(response).toEqual(false);
response = await doesClusterHaveUserData(esClient, log);
expect(response).toEqual(false);
response = await doesClusterHaveUserData(esClient, log);
expect(response).toEqual(false);
response = await doesClusterHaveUserData(esClient, log);
expect(response).toEqual(true);
expect(esClient.cat.indices).toHaveBeenCalledTimes(4);
expect(log.warn.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"Error encountered while checking cluster for user data: Error: something terrible happened",
],
]
`);
response = await doesClusterHaveUserData(esClient, log);
expect(response).toEqual(true);
// Same number of calls as above. We should not have to interrogate again.
expect(esClient.cat.indices).toHaveBeenCalledTimes(4);
});
});

View file

@ -0,0 +1,47 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ElasticsearchClient, Logger } from 'kibana/server';
export const createClusterDataCheck = () => {
let clusterHasUserData = false;
return async function doesClusterHaveUserData(esClient: ElasticsearchClient, log: Logger) {
if (!clusterHasUserData) {
try {
const indices = await esClient.cat.indices<
Array<{ index: string; ['docs.count']: string }>
>({
format: 'json',
h: ['index', 'docs.count'],
});
clusterHasUserData = indices.body.some((indexCount) => {
const isInternalIndex =
indexCount.index.startsWith('.') || indexCount.index.startsWith('kibana_sample_');
return !isInternalIndex && parseInt(indexCount['docs.count'], 10) > 0;
});
} catch (e) {
log.warn(`Error encountered while checking cluster for user data: ${e}`);
clusterHasUserData = false;
}
}
return clusterHasUserData;
};
};

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { schema, TypeOf } from '@kbn/config-schema';
export type ConfigType = TypeOf<typeof ConfigSchema>;
export const ConfigSchema = schema.object({
showInsecureClusterWarning: schema.boolean({ defaultValue: true }),
});

View file

@ -0,0 +1,35 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server';
import { ConfigSchema } from './config';
import { SecurityOssPlugin } from './plugin';
export { SecurityOssPluginSetup } from './plugin';
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
schema: ConfigSchema,
exposeToBrowser: {
showInsecureClusterWarning: true,
},
};
export const plugin = (context: PluginInitializerContext) => new SecurityOssPlugin(context);

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { coreMock } from '../../../core/server/mocks';
import { SecurityOssPlugin } from './plugin';
describe('SecurityOss Plugin', () => {
describe('#setup', () => {
it('exposes the proper contract', async () => {
const context = coreMock.createPluginInitializerContext();
const plugin = new SecurityOssPlugin(context);
const core = coreMock.createSetup();
const contract = plugin.setup(core);
expect(Object.keys(contract)).toMatchInlineSnapshot(`
Array [
"showInsecureClusterWarning$",
]
`);
});
});
});

View file

@ -0,0 +1,62 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server';
import { BehaviorSubject, Observable } from 'rxjs';
import { createClusterDataCheck } from './check_cluster_data';
import { ConfigType } from './config';
import { setupDisplayInsecureClusterAlertRoute } from './routes';
export interface SecurityOssPluginSetup {
/**
* Allows consumers to show/hide the insecure cluster warning.
*/
showInsecureClusterWarning$: BehaviorSubject<boolean>;
}
export class SecurityOssPlugin implements Plugin<SecurityOssPluginSetup, void, {}, {}> {
private readonly config$: Observable<ConfigType>;
private readonly logger: Logger;
constructor(initializerContext: PluginInitializerContext<ConfigType>) {
this.config$ = initializerContext.config.create();
this.logger = initializerContext.logger.get();
}
public setup(core: CoreSetup) {
const router = core.http.createRouter();
const showInsecureClusterWarning$ = new BehaviorSubject<boolean>(true);
setupDisplayInsecureClusterAlertRoute({
router,
log: this.logger,
config$: this.config$,
displayModifier$: showInsecureClusterWarning$,
doesClusterHaveUserData: createClusterDataCheck(),
});
return {
showInsecureClusterWarning$,
};
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { IRouter, Logger } from 'kibana/server';
import { combineLatest, Observable } from 'rxjs';
import { createClusterDataCheck } from '../check_cluster_data';
import { ConfigType } from '../config';
interface Deps {
router: IRouter;
log: Logger;
config$: Observable<ConfigType>;
displayModifier$: Observable<boolean>;
doesClusterHaveUserData: ReturnType<typeof createClusterDataCheck>;
}
export const setupDisplayInsecureClusterAlertRoute = ({
router,
log,
config$,
displayModifier$,
doesClusterHaveUserData,
}: Deps) => {
let showInsecureClusterWarning = false;
combineLatest([config$, displayModifier$]).subscribe(([config, displayModifier]) => {
showInsecureClusterWarning = config.showInsecureClusterWarning && displayModifier;
});
router.get(
{
path: '/internal/security_oss/display_insecure_cluster_alert',
validate: false,
},
async (context, request, response) => {
if (!showInsecureClusterWarning) {
return response.ok({ body: { displayAlert: false } });
}
const hasData = await doesClusterHaveUserData(
context.core.elasticsearch.client.asInternalUser,
log
);
return response.ok({ body: { displayAlert: hasData } });
}
);
};

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { setupDisplayInsecureClusterAlertRoute } from './display_insecure_cluster_alert';

View file

@ -0,0 +1,134 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { loggingSystemMock } from '../../../../../core/server/mocks';
import { setupServer } from '../../../../../core/server/test_utils';
import { setupDisplayInsecureClusterAlertRoute } from '../display_insecure_cluster_alert';
import { ConfigType } from '../../config';
import { BehaviorSubject, of } from 'rxjs';
import { UnwrapPromise } from '@kbn/utility-types';
import { createClusterDataCheck } from '../../check_cluster_data';
import supertest from 'supertest';
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
const pluginId = Symbol('securityOss');
interface SetupOpts {
config?: ConfigType;
displayModifier$?: BehaviorSubject<boolean>;
doesClusterHaveUserData?: ReturnType<typeof createClusterDataCheck>;
}
describe('GET /internal/security_oss/display_insecure_cluster_alert', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
const setupTestServer = async ({
config = { showInsecureClusterWarning: true },
displayModifier$ = new BehaviorSubject<boolean>(true),
doesClusterHaveUserData = jest.fn().mockResolvedValue(true),
}: SetupOpts) => {
({ server, httpSetup } = await setupServer(pluginId));
const router = httpSetup.createRouter('/');
const log = loggingSystemMock.createLogger();
setupDisplayInsecureClusterAlertRoute({
router,
log,
config$: of(config),
displayModifier$,
doesClusterHaveUserData,
});
await server.start();
return {
log,
};
};
afterEach(async () => {
await server.stop();
});
it('responds `false` if plugin is not configured to display alerts', async () => {
await setupTestServer({
config: { showInsecureClusterWarning: false },
});
await supertest(httpSetup.server.listener)
.get('/internal/security_oss/display_insecure_cluster_alert')
.expect(200, { displayAlert: false });
});
it('responds `false` if cluster does not contain user data', async () => {
await setupTestServer({
config: { showInsecureClusterWarning: true },
doesClusterHaveUserData: jest.fn().mockResolvedValue(false),
});
await supertest(httpSetup.server.listener)
.get('/internal/security_oss/display_insecure_cluster_alert')
.expect(200, { displayAlert: false });
});
it('responds `false` if displayModifier$ is set to false', async () => {
await setupTestServer({
config: { showInsecureClusterWarning: true },
doesClusterHaveUserData: jest.fn().mockResolvedValue(true),
displayModifier$: new BehaviorSubject<boolean>(false),
});
await supertest(httpSetup.server.listener)
.get('/internal/security_oss/display_insecure_cluster_alert')
.expect(200, { displayAlert: false });
});
it('responds `true` if cluster contains user data', async () => {
await setupTestServer({
config: { showInsecureClusterWarning: true },
doesClusterHaveUserData: jest.fn().mockResolvedValue(true),
});
await supertest(httpSetup.server.listener)
.get('/internal/security_oss/display_insecure_cluster_alert')
.expect(200, { displayAlert: true });
});
it('responds to changing displayModifier$ values', async () => {
const displayModifier$ = new BehaviorSubject<boolean>(true);
await setupTestServer({
config: { showInsecureClusterWarning: true },
doesClusterHaveUserData: jest.fn().mockResolvedValue(true),
displayModifier$,
});
await supertest(httpSetup.server.listener)
.get('/internal/security_oss/display_insecure_cluster_alert')
.expect(200, { displayAlert: true });
displayModifier$.next(false);
await supertest(httpSetup.server.listener)
.get('/internal/security_oss/display_insecure_cluster_alert')
.expect(200, { displayAlert: false });
});
});

View file

@ -43,6 +43,8 @@ const getDefaultArgs = (tag) => {
'--debug',
'--config',
'test/new_visualize_flow/config.js',
'--config',
'test/security_functional/config.ts',
];
};

View file

@ -48,6 +48,7 @@ export default function () {
`--elasticsearch.username=${kibanaServerTestUser.username}`,
`--elasticsearch.password=${kibanaServerTestUser.password}`,
`--home.disableWelcomeScreen=true`,
`--security.showInsecureClusterWarning=false`,
'--telemetry.banner=false',
'--telemetry.optIn=false',
// These are *very* important to have them pointing to staging

View file

@ -0,0 +1,54 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import path from 'path';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const functionalConfig = await readConfigFile(require.resolve('../functional/config'));
return {
testFiles: [require.resolve('./index.ts')],
services: functionalConfig.get('services'),
pageObjects: functionalConfig.get('pageObjects'),
servers: functionalConfig.get('servers'),
esTestCluster: functionalConfig.get('esTestCluster'),
apps: {},
esArchiver: {
directory: path.resolve(__dirname, '../functional/fixtures/es_archiver'),
},
snapshots: {
directory: path.resolve(__dirname, 'snapshots'),
},
junit: {
reportName: 'Security OSS Functional Tests',
},
kbnTestServer: {
...functionalConfig.get('kbnTestServer'),
serverArgs: [
...functionalConfig
.get('kbnTestServer.serverArgs')
.filter((arg: string) => !arg.startsWith('--security.showInsecureClusterWarning')),
'--security.showInsecureClusterWarning=true',
// Required to load new platform plugins via `--plugin-path` flag.
'--env.name=development',
],
},
};
}

View file

@ -0,0 +1,27 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FtrProviderContext } from '../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Security OSS', function () {
this.tags(['skipCloud', 'ciGroup2']);
loadTestFile(require.resolve('./insecure_cluster_warning'));
});
}

View file

@ -0,0 +1,87 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FtrProviderContext } from 'test/functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const pageObjects = getPageObjects(['common']);
const testSubjects = getService('testSubjects');
const browser = getService('browser');
const esArchiver = getService('esArchiver');
describe('Insecure Cluster Warning', () => {
before(async () => {
await pageObjects.common.navigateToApp('home');
await browser.setLocalStorageItem('insecureClusterWarningVisibility', '');
// starting without user data
await esArchiver.unload('hamlet');
});
after(async () => {
await esArchiver.unload('hamlet');
});
describe('without user data', () => {
before(async () => {
await browser.setLocalStorageItem('insecureClusterWarningVisibility', '');
await esArchiver.unload('hamlet');
});
it('should not warn when the cluster contains no user data', async () => {
await browser.setLocalStorageItem(
'insecureClusterWarningVisibility',
JSON.stringify({ show: false })
);
await pageObjects.common.navigateToApp('home');
await testSubjects.missingOrFail('insecureClusterDefaultAlertText');
});
});
describe('with user data', () => {
before(async () => {
await pageObjects.common.navigateToApp('home');
await browser.setLocalStorageItem('insecureClusterWarningVisibility', '');
await esArchiver.load('hamlet');
});
after(async () => {
await esArchiver.unload('hamlet');
});
it('should warn about an insecure cluster, and hide when dismissed', async () => {
await pageObjects.common.navigateToApp('home');
await testSubjects.existOrFail('insecureClusterDefaultAlertText');
await testSubjects.click('defaultDismissAlertButton');
await testSubjects.missingOrFail('insecureClusterDefaultAlertText');
});
it('should not warn when local storage is configured to hide', async () => {
await browser.setLocalStorageItem(
'insecureClusterWarningVisibility',
JSON.stringify({ show: false })
);
await pageObjects.common.navigateToApp('home');
await testSubjects.missingOrFail('insecureClusterDefaultAlertText');
});
});
});
}

View file

@ -3,7 +3,7 @@
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "security"],
"requiredPlugins": ["data", "features", "licensing", "taskManager"],
"requiredPlugins": ["data", "features", "licensing", "taskManager", "securityOss"],
"optionalPlugins": ["home", "management", "usageCollection"],
"server": true,
"ui": true,

View file

@ -8,6 +8,7 @@ import { Observable } from 'rxjs';
import BroadcastChannel from 'broadcast-channel';
import { CoreSetup } from 'src/core/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { mockSecurityOssPlugin } from '../../../../src/plugins/security_oss/public/mocks';
import { SessionTimeout } from './session';
import { PluginStartDependencies, SecurityPlugin } from './plugin';
@ -35,6 +36,7 @@ describe('Security Plugin', () => {
>,
{
licensing: licensingMock.createSetup(),
securityOss: mockSecurityOssPlugin.createSetup(),
}
)
).toEqual({
@ -61,6 +63,7 @@ describe('Security Plugin', () => {
plugin.setup(coreSetupMock as CoreSetup<PluginStartDependencies>, {
licensing: licensingMock.createSetup(),
securityOss: mockSecurityOssPlugin.createSetup(),
management: managementSetupMock,
});
@ -85,11 +88,12 @@ describe('Security Plugin', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
{ licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() }
);
expect(
plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), {
securityOss: mockSecurityOssPlugin.createStart(),
data: {} as DataPublicPluginStart,
features: {} as FeaturesPluginStart,
})
@ -110,12 +114,14 @@ describe('Security Plugin', () => {
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{
licensing: licensingMock.createSetup(),
securityOss: mockSecurityOssPlugin.createSetup(),
management: managementSetupMock,
}
);
const coreStart = coreMock.createStart({ basePath: '/some-base-path' });
plugin.start(coreStart, {
securityOss: mockSecurityOssPlugin.createStart(),
data: {} as DataPublicPluginStart,
features: {} as FeaturesPluginStart,
management: managementStartMock,
@ -130,7 +136,7 @@ describe('Security Plugin', () => {
const plugin = new SecurityPlugin(coreMock.createPluginInitializerContext());
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
{ licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() }
);
expect(() => plugin.stop()).not.toThrow();
@ -141,10 +147,11 @@ describe('Security Plugin', () => {
plugin.setup(
coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup<PluginStartDependencies>,
{ licensing: licensingMock.createSetup() }
{ licensing: licensingMock.createSetup(), securityOss: mockSecurityOssPlugin.createSetup() }
);
plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), {
securityOss: mockSecurityOssPlugin.createStart(),
data: {} as DataPublicPluginStart,
features: {} as FeaturesPluginStart,
});

View file

@ -5,6 +5,7 @@
*/
import { i18n } from '@kbn/i18n';
import { SecurityOssPluginSetup, SecurityOssPluginStart } from 'src/plugins/security_oss/public';
import {
CoreSetup,
CoreStart,
@ -32,9 +33,11 @@ import { AuthenticationService, AuthenticationServiceSetup } from './authenticat
import { ConfigType } from './config';
import { ManagementService } from './management';
import { accountManagementApp } from './account_management';
import { SecurityCheckupService } from './security_checkup';
export interface PluginSetupDependencies {
licensing: LicensingPluginSetup;
securityOss: SecurityOssPluginSetup;
home?: HomePublicPluginSetup;
management?: ManagementSetup;
}
@ -42,6 +45,7 @@ export interface PluginSetupDependencies {
export interface PluginStartDependencies {
data: DataPublicPluginStart;
features: FeaturesPluginStart;
securityOss: SecurityOssPluginStart;
management?: ManagementStart;
}
@ -58,6 +62,7 @@ export class SecurityPlugin
private readonly navControlService = new SecurityNavControlService();
private readonly securityLicenseService = new SecurityLicenseService();
private readonly managementService = new ManagementService();
private readonly securityCheckupService = new SecurityCheckupService();
private authc!: AuthenticationServiceSetup;
private readonly config: ConfigType;
@ -67,7 +72,7 @@ export class SecurityPlugin
public setup(
core: CoreSetup<PluginStartDependencies>,
{ home, licensing, management }: PluginSetupDependencies
{ home, licensing, management, securityOss }: PluginSetupDependencies
) {
const { http, notifications } = core;
const { anonymousPaths } = http;
@ -82,6 +87,8 @@ export class SecurityPlugin
const { license } = this.securityLicenseService.setup({ license$: licensing.license$ });
this.securityCheckupService.setup({ securityOssSetup: securityOss });
this.authc = this.authenticationService.setup({
application: core.application,
fatalErrors: core.fatalErrors,
@ -137,9 +144,10 @@ export class SecurityPlugin
};
}
public start(core: CoreStart, { management }: PluginStartDependencies) {
public start(core: CoreStart, { management, securityOss }: PluginStartDependencies) {
this.sessionTimeout.start();
this.navControlService.start({ core });
this.securityCheckupService.start({ securityOssStart: securityOss });
if (management) {
this.managementService.start({ capabilities: core.application.capabilities });
}
@ -150,6 +158,7 @@ export class SecurityPlugin
this.navControlService.stop();
this.securityLicenseService.stop();
this.managementService.stop();
this.securityCheckupService.stop();
}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { insecureClusterAlertTitle, insecureClusterAlertText } from './insecure_cluster_alert';

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { render, unmountComponentAtNode } from 'react-dom';
import { MountPoint } from 'kibana/public';
import {
EuiCheckbox,
EuiText,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
} from '@elastic/eui';
export const insecureClusterAlertTitle = i18n.translate(
'xpack.security.checkup.insecureClusterTitle',
{ defaultMessage: 'Please secure your installation' }
);
export const insecureClusterAlertText = (onDismiss: (persist: boolean) => void) =>
((e) => {
const AlertText = () => {
const [persist, setPersist] = useState(false);
return (
<I18nProvider>
<div data-test-subj="insecureClusterAlertText">
<EuiText size="s">
<FormattedMessage
id="xpack.security.checkup.insecureClusterMessage"
defaultMessage="Our free security features can protect against unauthorized access."
/>
</EuiText>
<EuiSpacer />
<EuiCheckbox
id="persistDismissedAlertPreference"
checked={persist}
onChange={(changeEvent) => setPersist(changeEvent.target.checked)}
label={i18n.translate('xpack.security.checkup.dontShowAgain', {
defaultMessage: `Don't show again`,
})}
/>
<EuiSpacer />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
color="primary"
fill
href="https://www.elastic.co/what-is/elastic-stack-security"
target="_blank"
>
{i18n.translate('xpack.security.checkup.enableButtonText', {
defaultMessage: `Enable security`,
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={() => onDismiss(persist)}
data-test-subj="dismissAlertButton"
>
{i18n.translate('xpack.security.checkup.dismissButtonText', {
defaultMessage: `Dismiss`,
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</I18nProvider>
);
};
render(<AlertText />, e);
return () => unmountComponentAtNode(e);
}) as MountPoint;

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SecurityCheckupService } from './security_checkup_service';

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockSecurityOssPlugin } from '../../../../../src/plugins/security_oss/public/mocks';
import { insecureClusterAlertTitle } from './components';
import { SecurityCheckupService } from './security_checkup_service';
let mockOnDismiss = jest.fn();
jest.mock('./components', () => {
return {
insecureClusterAlertTitle: 'mock insecure cluster title',
insecureClusterAlertText: (onDismiss: any) => {
mockOnDismiss = onDismiss;
return 'mock insecure cluster text';
},
};
});
describe('SecurityCheckupService', () => {
describe('#setup', () => {
it('configures the alert title and text for the default distribution', async () => {
const securityOssSetup = mockSecurityOssPlugin.createSetup();
const service = new SecurityCheckupService();
service.setup({ securityOssSetup });
expect(securityOssSetup.insecureCluster.setAlertTitle).toHaveBeenCalledWith(
insecureClusterAlertTitle
);
expect(securityOssSetup.insecureCluster.setAlertText).toHaveBeenCalledWith(
'mock insecure cluster text'
);
});
});
describe('#start', () => {
it('onDismiss triggers hiding of the alert', async () => {
const securityOssSetup = mockSecurityOssPlugin.createSetup();
const securityOssStart = mockSecurityOssPlugin.createStart();
const service = new SecurityCheckupService();
service.setup({ securityOssSetup });
service.start({ securityOssStart });
expect(securityOssStart.insecureCluster.hideAlert).toHaveBeenCalledTimes(0);
mockOnDismiss();
expect(securityOssStart.insecureCluster.hideAlert).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
SecurityOssPluginSetup,
SecurityOssPluginStart,
} from '../../../../../src/plugins/security_oss/public';
import { insecureClusterAlertTitle, insecureClusterAlertText } from './components';
interface SetupDeps {
securityOssSetup: SecurityOssPluginSetup;
}
interface StartDeps {
securityOssStart: SecurityOssPluginStart;
}
export class SecurityCheckupService {
private securityOssStart?: SecurityOssPluginStart;
public setup({ securityOssSetup }: SetupDeps) {
securityOssSetup.insecureCluster.setAlertTitle(insecureClusterAlertTitle);
securityOssSetup.insecureCluster.setAlertText(
insecureClusterAlertText((persist: boolean) => this.onDismiss(persist))
);
}
public start({ securityOssStart }: StartDeps) {
this.securityOssStart = securityOssStart;
}
private onDismiss(persist: boolean) {
if (this.securityOssStart) {
this.securityOssStart.insecureCluster.hideAlert(persist);
}
}
public stop() {}
}

View file

@ -9,6 +9,7 @@ import { first, map } from 'rxjs/operators';
import { TypeOf } from '@kbn/config-schema';
import { deepFreeze } from '@kbn/std';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { SecurityOssPluginSetup } from 'src/plugins/security_oss/server';
import {
CoreSetup,
CoreStart,
@ -84,6 +85,7 @@ export interface PluginSetupDependencies {
licensing: LicensingPluginSetup;
taskManager: TaskManagerSetupContract;
usageCollection?: UsageCollectionSetup;
securityOss?: SecurityOssPluginSetup;
}
export interface PluginStartDependencies {
@ -133,7 +135,7 @@ export class Plugin {
public async setup(
core: CoreSetup<PluginStartDependencies>,
{ features, licensing, taskManager, usageCollection }: PluginSetupDependencies
{ features, licensing, taskManager, usageCollection, securityOss }: PluginSetupDependencies
) {
const [config, legacyConfig] = await combineLatest([
this.initializerContext.config.create<TypeOf<typeof ConfigSchema>>().pipe(
@ -153,6 +155,13 @@ export class Plugin {
license$: licensing.license$,
});
if (securityOss) {
license.features$.subscribe(({ allowRbac }) => {
const showInsecureClusterWarning = !allowRbac;
securityOss.showInsecureClusterWarning$.next(showInsecureClusterWarning);
});
}
securityFeatures.forEach((securityFeature) =>
features.registerElasticsearchFeature(securityFeature)
);