Add Mock IDP login page and role switcher (#172257)

This commit is contained in:
Thom Heymann 2024-01-12 20:54:51 +00:00 committed by GitHub
parent 1865d4dab4
commit 7bee86d6eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 686 additions and 161 deletions

1
.github/CODEOWNERS vendored
View file

@ -553,6 +553,7 @@ x-pack/packages/ml/trained_models_utils @elastic/ml-ui
x-pack/packages/ml/ui_actions @elastic/ml-ui
x-pack/packages/ml/url_state @elastic/ml-ui
packages/kbn-mock-idp-plugin @elastic/kibana-security
packages/kbn-mock-idp-utils @elastic/kibana-security
packages/kbn-monaco @elastic/appex-sharedux
x-pack/plugins/monitoring_collection @elastic/obs-ux-infra_services-team
x-pack/plugins/monitoring @elastic/obs-ux-infra_services-team

View file

@ -1269,6 +1269,7 @@
"@kbn/managed-vscode-config-cli": "link:packages/kbn-managed-vscode-config-cli",
"@kbn/management-storybook-config": "link:packages/kbn-management/storybook/config",
"@kbn/mock-idp-plugin": "link:packages/kbn-mock-idp-plugin",
"@kbn/mock-idp-utils": "link:packages/kbn-mock-idp-utils",
"@kbn/openapi-bundler": "link:packages/kbn-openapi-bundler",
"@kbn/openapi-generator": "link:packages/kbn-openapi-generator",
"@kbn/optimizer": "link:packages/kbn-optimizer",

View file

@ -47,7 +47,12 @@ export type HttpResourcesResponseOptions = HttpResponseOptions;
export interface HttpResourcesServiceToolkit {
/** To respond with HTML page bootstrapping Kibana application. */
renderCoreApp: (options?: HttpResourcesRenderOptions) => Promise<IKibanaResponse>;
/** To respond with HTML page bootstrapping Kibana application without retrieving user-specific information. */
/**
* To respond with HTML page bootstrapping Kibana application without retrieving user-specific information.
* **Note:**
* - Your client-side JavaScript bundle will only be loaded on an anonymous page if `plugin.enabledOnAnonymousPages` is enabled in your plugin's `kibana.jsonc` manifest file.
* - You will also need to register the route serving your anonymous app with the `coreSetup.http.anonymousPaths` service in your plugin's client-side `setup` method.
* */
renderAnonymousCoreApp: (options?: HttpResourcesRenderOptions) => Promise<IKibanaResponse>;
/** To respond with a custom HTML page. */
renderHtml: (options: HttpResourcesResponseOptions) => IKibanaResponse;

View file

@ -41,7 +41,7 @@ import {
} from '../paths';
import * as waitClusterUtil from './wait_until_cluster_ready';
import * as waitForSecurityIndexUtil from './wait_for_security_index';
import * as mockIdpPluginUtil from '@kbn/mock-idp-plugin/common';
import * as mockIdpPluginUtil from '@kbn/mock-idp-utils';
jest.mock('execa');
const execa = jest.requireMock('execa');
@ -59,7 +59,7 @@ jest.mock('./wait_for_security_index', () => ({
waitForSecurityIndex: jest.fn(),
}));
jest.mock('@kbn/mock-idp-plugin/common');
jest.mock('@kbn/mock-idp-utils');
const log = new ToolingLog();
const logWriter = new ToolingLogCollectingWriter();

View file

@ -24,7 +24,7 @@ import {
MOCK_IDP_ATTRIBUTE_NAME,
ensureSAMLRoleMapping,
createMockIdpMetadata,
} from '@kbn/mock-idp-plugin/common';
} from '@kbn/mock-idp-utils';
import { waitForSecurityIndex } from './wait_for_security_index';
import { createCliError } from '../errors';

View file

@ -16,7 +16,7 @@
"@kbn/dev-utils",
"@kbn/dev-proc-runner",
"@kbn/ci-stats-reporter",
"@kbn/mock-idp-plugin",
"@kbn/mock-idp-utils",
"@kbn/jest-serializers",
"@kbn/repo-info"
]

View file

@ -6,22 +6,4 @@
* Side Public License, v 1.
*/
export {
MOCK_IDP_PLUGIN_PATH,
MOCK_IDP_METADATA_PATH,
MOCK_IDP_LOGIN_PATH,
MOCK_IDP_LOGOUT_PATH,
MOCK_IDP_REALM_NAME,
MOCK_IDP_ENTITY_ID,
MOCK_IDP_ROLE_MAPPING_NAME,
MOCK_IDP_ATTRIBUTE_PRINCIPAL,
MOCK_IDP_ATTRIBUTE_ROLES,
MOCK_IDP_ATTRIBUTE_EMAIL,
MOCK_IDP_ATTRIBUTE_NAME,
} from './constants';
export {
createMockIdpMetadata,
createSAMLResponse,
ensureSAMLRoleMapping,
parseSAMLAuthnRequest,
} from './utils';
export { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils';

View file

@ -6,6 +6,13 @@
"plugin": {
"id": "mockIdpPlugin",
"server": true,
"browser": false
"browser": true,
"enabledOnAnonymousPages": true,
"requiredPlugins": [
"cloud"
],
"requiredBundles": [
"kibanaReact"
]
}
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { plugin } from './plugin';

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiButton,
EuiPageTemplate,
EuiEmptyPrompt,
EuiComboBox,
EuiInlineEditTitle,
EuiFormRow,
EuiSpacer,
EuiComboBoxOptionOption,
EuiButtonEmpty,
} from '@elastic/eui';
import React, { ChangeEvent, FunctionComponent } from 'react';
import { FormikProvider, useFormik, Field, Form } from 'formik';
import {
MOCK_IDP_SECURITY_ROLE_NAMES,
MOCK_IDP_OBSERVABILITY_ROLE_NAMES,
MOCK_IDP_SEARCH_ROLE_NAMES,
} from '@kbn/mock-idp-utils/src/constants';
import { useAuthenticator } from './role_switcher';
export interface LoginPageProps {
projectType?: string;
}
export const LoginPage: FunctionComponent<LoginPageProps> = ({ projectType }) => {
const roles =
projectType === 'security'
? MOCK_IDP_SECURITY_ROLE_NAMES
: projectType === 'observability'
? MOCK_IDP_OBSERVABILITY_ROLE_NAMES
: MOCK_IDP_SEARCH_ROLE_NAMES;
const [, switchCurrentUser] = useAuthenticator(true);
const formik = useFormik({
initialValues: {
full_name: 'Test User',
role: roles[0],
},
async onSubmit(values) {
await switchCurrentUser({
username: sanitizeUsername(values.full_name),
full_name: values.full_name,
email: sanitizeEmail(values.full_name),
roles: [values.role],
});
},
});
return (
<FormikProvider value={formik}>
<EuiPageTemplate panelled={false}>
<EuiPageTemplate.Section alignment="center">
<Form>
<EuiEmptyPrompt
iconType="user"
layout="vertical"
color="plain"
body={
<>
<Field
as={EuiInlineEditTitle}
name="full_name"
heading="h2"
inputAriaLabel="Edit name inline"
value={formik.values.full_name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
formik.setFieldValue('full_name', event.target.value);
}}
onCancel={(previousValue: string) => {
formik.setFieldValue('full_name', previousValue);
}}
isReadOnly={formik.isSubmitting}
editModeProps={{
formRowProps: {
error: formik.errors.full_name,
},
}}
validate={(value: string) => {
if (value.trim().length === 0) {
return 'Name cannot be empty';
}
}}
isInvalid={!!formik.errors.full_name}
placeholder="Enter your name"
css={{ width: 350 }}
/>
<EuiSpacer size="m" />
<EuiFormRow error={formik.errors.role} isInvalid={!!formik.errors.role}>
<Field
as={EuiComboBox}
name="role"
placeholder="Select your role"
singleSelection={{ asPlainText: true }}
options={roles.map((role) => ({ label: role }))}
selectedOptions={
formik.values.role ? [{ label: formik.values.role }] : undefined
}
onCreateOption={(value: string) => {
formik.setFieldValue('role', value);
}}
onChange={(selectedOptions: EuiComboBoxOptionOption[]) => {
formik.setFieldValue(
'role',
selectedOptions.length === 0 ? '' : selectedOptions[0].label
);
}}
validate={(value: string) => {
if (value.trim().length === 0) {
return 'Role cannot be empty';
}
}}
isInvalid={!!formik.errors.role}
isClearable={false}
fullWidth
/>
</EuiFormRow>
</>
}
actions={[
<EuiButton
type="submit"
disabled={!formik.isValid}
isLoading={formik.isSubmitting}
fill
>
Log in
</EuiButton>,
<EuiButtonEmpty size="xs" href="/">
More login options
</EuiButtonEmpty>,
]}
/>
</Form>
</EuiPageTemplate.Section>
</EuiPageTemplate>
</FormikProvider>
);
};
const sanitizeUsername = (username: string) =>
username.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
const sanitizeEmail = (email: string) => `${sanitizeUsername(email)}@elastic.co`;

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { PluginInitializer } from '@kbn/core-plugins-browser';
import { AppNavLinkStatus } from '@kbn/core-application-browser';
import React from 'react';
import ReactDOM from 'react-dom';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { MOCK_IDP_LOGIN_PATH } from '@kbn/mock-idp-utils/src/constants';
import type { CloudStart, CloudSetup } from '@kbn/cloud-plugin/public';
import { RoleSwitcher } from './role_switcher';
export interface PluginSetupDependencies {
cloud?: CloudSetup;
}
export interface PluginStartDependencies {
cloud?: CloudStart;
}
export const plugin: PluginInitializer<
void,
void,
PluginSetupDependencies,
PluginStartDependencies
> = () => ({
setup(coreSetup, plugins) {
// Register Mock IDP login page
coreSetup.http.anonymousPaths.register(MOCK_IDP_LOGIN_PATH);
coreSetup.application.register({
id: 'mock_idp',
title: 'Mock IDP',
chromeless: true,
appRoute: MOCK_IDP_LOGIN_PATH,
navLinkStatus: AppNavLinkStatus.hidden,
mount: async (params) => {
const [[coreStart], { LoginPage }] = await Promise.all([
coreSetup.getStartServices(),
import('./login_page'),
]);
ReactDOM.render(
<KibanaThemeProvider theme={coreStart.theme}>
<KibanaContextProvider services={coreStart}>
<I18nProvider>
<LoginPage projectType={plugins.cloud?.serverless.projectType} />
</I18nProvider>
</KibanaContextProvider>
</KibanaThemeProvider>,
params.element
);
return () => ReactDOM.unmountComponentAtNode(params.element);
},
});
},
start(coreStart, plugins) {
// Register role switcher dropdown menu in the top right navigation of the Kibana UI
coreStart.chrome.navControls.registerRight({
order: 4000 + 1, // Make sure it comes after the user menu
mount: (element: HTMLElement) => {
ReactDOM.render(
<KibanaThemeProvider theme={coreStart.theme}>
<KibanaContextProvider services={coreStart}>
<I18nProvider>
<RoleSwitcher projectType={plugins.cloud?.serverless.projectType} />
</I18nProvider>
</KibanaContextProvider>
</KibanaThemeProvider>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
},
});
},
stop() {},
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { ToastInput } from '@kbn/core-notifications-browser';
import type { I18nStart } from '@kbn/core-i18n-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { AuthenticatedUser } from '@kbn/security-plugin-types-common';
export const DATA_TEST_SUBJ_PAGE_RELOAD_BUTTON = 'pageReloadButton';
/**
* Utility function for returning a {@link ToastInput} for displaying a prompt for reloading the page.
* @param theme The {@link ThemeServiceStart} contract.
* @param i18nStart The {@link I18nStart} contract.
* @returns A toast.
*/
export const createReloadPageToast = (options: {
user: Pick<AuthenticatedUser, 'roles'>;
theme: ThemeServiceStart;
i18n: I18nStart;
}): ToastInput => {
return {
title: `Your role has been set to '${options.user.roles.join(`', '`)}'.`,
text: toMountPoint(
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={() => window.location.reload()}
data-test-subj={DATA_TEST_SUBJ_PAGE_RELOAD_BUTTON}
>
Reload page
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>,
{ i18n: options.i18n, theme: options.theme }
),
color: 'success',
toastLifeTimeMs: 0x7fffffff, // Do not auto-hide toast since page is in an unknown state
};
};

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FunctionComponent, useEffect, useState } from 'react';
import { EuiButton, EuiPopover, EuiContextMenu } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { CoreStart } from '@kbn/core-lifecycle-browser';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import type { AuthenticatedUser } from '@kbn/security-plugin-types-common';
import {
MOCK_IDP_REALM_NAME,
MOCK_IDP_REALM_TYPE,
MOCK_IDP_SECURITY_ROLE_NAMES,
MOCK_IDP_OBSERVABILITY_ROLE_NAMES,
MOCK_IDP_SEARCH_ROLE_NAMES,
} from '@kbn/mock-idp-utils/src/constants';
import { createReloadPageToast } from './reload_page_toast';
import type { CreateSAMLResponseParams } from '../server';
const useCurrentUser = () => {
const { services } = useKibana<CoreStart>();
return useAsyncFn(() => services.http.get<AuthenticatedUser>('/internal/security/me'));
};
export const useAuthenticator = (reloadPage = false) => {
const { services } = useKibana<CoreStart>();
return useAsyncFn(async (params: CreateSAMLResponseParams) => {
// Create SAML Response using Mock IDP
const response = await services.http.post<Record<string, string>>('/mock_idp/saml_response', {
body: JSON.stringify(params),
});
// Authenticate user with SAML response
if (reloadPage) {
const form = createForm('/api/security/saml/callback', response);
form.submit();
await new Promise(() => {}); // Never resolve
} else {
await services.http.post('/api/security/saml/callback', {
body: JSON.stringify(response),
asResponse: true,
rawResponse: true,
});
}
return params;
});
};
export interface RoleSwitcherProps {
projectType?: string;
}
export const RoleSwitcher: FunctionComponent<RoleSwitcherProps> = ({ projectType }) => {
const [isOpen, setIsOpen] = useState(false);
const { services } = useKibana<CoreStart>();
const [currentUserState, getCurrentUser] = useCurrentUser();
const [authenticateUserState, authenticateUser] = useAuthenticator();
useEffect(() => {
getCurrentUser();
}, [getCurrentUser, authenticateUserState.value]);
useEffect(() => {
if (authenticateUserState.value) {
services.notifications.toasts.add(
createReloadPageToast({
user: authenticateUserState.value,
theme: services.theme,
i18n: services.i18n,
})
);
}
}, [authenticateUserState.value]); // eslint-disable-line react-hooks/exhaustive-deps
if (!currentUserState.value || !isAuthenticatedWithMockIDP(currentUserState.value)) {
return null;
}
const [currentRole] = currentUserState.value.roles;
const roles =
projectType === 'security'
? MOCK_IDP_SECURITY_ROLE_NAMES
: projectType === 'observability'
? MOCK_IDP_OBSERVABILITY_ROLE_NAMES
: MOCK_IDP_SEARCH_ROLE_NAMES;
return (
<EuiPopover
button={
<EuiButton
color="text"
size="s"
iconType="arrowDown"
iconSide="right"
minWidth={false}
onClick={() => setIsOpen((toggle) => !toggle)}
isLoading={currentUserState.loading || authenticateUserState.loading}
>
{currentRole}
</EuiButton>
}
panelPaddingSize="none"
offset={4}
anchorPosition="downRight"
repositionOnScroll
repositionToCrossAxis={false}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
>
<EuiContextMenu
initialPanelId={0}
panels={[
{
id: 0,
title: 'Switch role',
items: roles.map((role) => ({
name: role,
icon: currentUserState.value!.roles.includes(role) ? 'check' : 'empty',
onClick: () => {
authenticateUser({
username: currentUserState.value!.username,
full_name: currentUserState.value!.full_name,
email: currentUserState.value!.email,
roles: [role],
});
setIsOpen(false);
},
})),
},
]}
/>
</EuiPopover>
);
};
function isAuthenticatedWithMockIDP(user: AuthenticatedUser) {
return (
user.authentication_provider.type === MOCK_IDP_REALM_TYPE &&
user.authentication_provider.name === MOCK_IDP_REALM_NAME
);
}
const createForm = (url: string, fields: Record<string, string>) => {
const form = document.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', url);
for (const key in fields) {
if (!fields.hasOwnProperty(key)) {
continue;
}
const input = document.createElement('input');
input.setAttribute('type', 'hidden');
input.setAttribute('name', key);
input.setAttribute('value', fields[key]);
form.appendChild(input);
}
return document.body.appendChild(form);
};

View file

@ -6,4 +6,5 @@
* Side Public License, v 1.
*/
export type { CreateSAMLResponseParams } from './plugin';
export { plugin } from './plugin';

View file

@ -8,102 +8,59 @@
import type { PluginInitializer, Plugin } from '@kbn/core-plugins-server';
import { schema } from '@kbn/config-schema';
import type { TypeOf } from '@kbn/config-schema';
import { MOCK_IDP_LOGIN_PATH, MOCK_IDP_LOGOUT_PATH, createSAMLResponse } from '@kbn/mock-idp-utils';
import {
MOCK_IDP_LOGIN_PATH,
MOCK_IDP_LOGOUT_PATH,
createSAMLResponse,
parseSAMLAuthnRequest,
} from '../common';
const createSAMLResponseSchema = schema.object({
username: schema.string(),
full_name: schema.maybe(schema.nullable(schema.string())),
email: schema.maybe(schema.nullable(schema.string())),
roles: schema.arrayOf(schema.string()),
});
export type CreateSAMLResponseParams = TypeOf<typeof createSAMLResponseSchema>;
export const plugin: PluginInitializer<void, void> = async (): Promise<Plugin> => ({
setup(core) {
const router = core.http.createRouter();
core.http.resources.register(
{
path: MOCK_IDP_LOGIN_PATH,
validate: false,
options: { authRequired: false },
},
async (context, request, response) => {
return response.renderAnonymousCoreApp();
}
);
router.post(
{
path: '/mock_idp/saml_response',
validate: {
query: schema.object({
SAMLRequest: schema.string(),
}),
body: createSAMLResponseSchema,
},
options: { authRequired: false },
},
async (context, request, response) => {
let samlRequest: Awaited<ReturnType<typeof parseSAMLAuthnRequest>>;
try {
samlRequest = await parseSAMLAuthnRequest(request.query.SAMLRequest);
} catch (error) {
return response.badRequest({
body: '[request query.SAMLRequest]: value is not valid SAMLRequest.',
});
}
const { protocol, hostname, port } = core.http.getServerInfo();
const pathname = core.http.basePath.prepend('/api/security/saml/callback');
const userRoles: Array<[string, string]> = [
['system_indices_superuser', 'system_indices_superuser'],
['t1_analyst', 't1_analyst'],
['t2_analyst', 't2_analyst'],
['t3_analyst', 't3_analyst'],
['threat_intelligence_analyst', 'threat_intelligence_analyst'],
['rule_author', 'rule_author'],
['soc_manager', 'soc_manager'],
['detections_admin', 'detections_admin'],
['platform_engineer', 'platform_engineer'],
['endpoint_operations_analyst', 'endpoint_operations_analyst'],
['endpoint_policy_manager', 'endpoint_policy_manager'],
];
const samlResponses = await Promise.all(
userRoles.map(([username, role]) =>
createSAMLResponse({
authnRequestId: samlRequest.ID,
kibanaUrl: samlRequest.AssertionConsumerServiceURL,
username,
roles: [role],
})
)
);
return response.renderHtml({
body: `
<!DOCTYPE html>
<title>Mock Identity Provider</title>
<link rel="icon" href="data:,">
<body>
<h2>Mock Identity Provider</h2>
<form id="loginForm" method="post" action="${
samlRequest.AssertionConsumerServiceURL
}">
<h3>Pick a role:<h3>
<ul>
${userRoles
.map(
([username], i) =>
`
<li>
<button name="SAMLResponse" value="${samlResponses[i]}">${username}</button>
</li>
`
)
.join('')}
</ul>
</form>
</body>
`,
return response.ok({
body: {
SAMLResponse: await createSAMLResponse({
kibanaUrl: `${protocol}://${hostname}:${port}${pathname}`,
username: request.body.username,
full_name: request.body.full_name ?? undefined,
email: request.body.email ?? undefined,
roles: request.body.roles,
}),
},
});
}
);
core.http.resources.register(
{
path: `${MOCK_IDP_LOGIN_PATH}/submit.js`,
validate: false,
options: { authRequired: false },
},
(context, request, response) => {
return response.renderJs({ body: 'document.getElementById("loginForm").submit();' });
}
);
core.http.resources.register(
{
path: MOCK_IDP_LOGOUT_PATH,

View file

@ -11,8 +11,20 @@
"target/**/*"
],
"kbn_references": [
"@kbn/core-plugins-server",
"@kbn/config-schema",
"@kbn/dev-utils"
"@kbn/core-application-browser",
"@kbn/core-i18n-browser",
"@kbn/core-lifecycle-browser",
"@kbn/core-notifications-browser",
"@kbn/core-plugins-browser",
"@kbn/core-plugins-server",
"@kbn/core-theme-browser",
"@kbn/i18n-react",
"@kbn/kibana-react-plugin",
"@kbn/react-kibana-context-theme",
"@kbn/react-kibana-mount",
"@kbn/security-plugin-types-common",
"@kbn/mock-idp-utils",
"@kbn/cloud-plugin",
]
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './src';

View file

@ -0,0 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/mock-idp-utils",
"owner": "@elastic/kibana-security",
"devOnly": true,
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/mock-idp-utils",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -6,15 +6,11 @@
* Side Public License, v 1.
*/
import { resolve } from 'path';
export const MOCK_IDP_PLUGIN_PATH = resolve(__dirname, '..');
export const MOCK_IDP_METADATA_PATH = resolve(MOCK_IDP_PLUGIN_PATH, 'metadata.xml');
export const MOCK_IDP_LOGIN_PATH = '/mock_idp/login';
export const MOCK_IDP_LOGOUT_PATH = '/mock_idp/logout';
export const MOCK_IDP_REALM_NAME = 'mock-idp';
export const MOCK_IDP_REALM_TYPE = 'saml';
export const MOCK_IDP_ENTITY_ID = 'urn:mock-idp'; // Must match `entityID` in `metadata.xml`
export const MOCK_IDP_ROLE_MAPPING_NAME = 'mock-idp-mapping';
@ -22,3 +18,20 @@ export const MOCK_IDP_ATTRIBUTE_PRINCIPAL = 'http://saml.elastic-cloud.com/attri
export const MOCK_IDP_ATTRIBUTE_ROLES = 'http://saml.elastic-cloud.com/attributes/roles';
export const MOCK_IDP_ATTRIBUTE_EMAIL = 'http://saml.elastic-cloud.com/attributes/email';
export const MOCK_IDP_ATTRIBUTE_NAME = 'http://saml.elastic-cloud.com/attributes/name';
/** List of roles from `packages/kbn-es/src/serverless_resources/roles.yml` */
export const MOCK_IDP_SEARCH_ROLE_NAMES = ['viewer', 'editor', 'system_indices_superuser'];
export const MOCK_IDP_OBSERVABILITY_ROLE_NAMES = ['viewer', 'editor', 'system_indices_superuser'];
export const MOCK_IDP_SECURITY_ROLE_NAMES = [
't1_analyst',
't2_analyst',
't3_analyst',
'threat_intelligence_analyst',
'rule_author',
'soc_manager',
'detections_admin',
'platform_engineer',
'endpoint_operations_analyst',
'endpoint_policy_manager',
'system_indices_superuser',
];

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export {
MOCK_IDP_LOGIN_PATH,
MOCK_IDP_LOGOUT_PATH,
MOCK_IDP_REALM_NAME,
MOCK_IDP_REALM_TYPE,
MOCK_IDP_ENTITY_ID,
MOCK_IDP_ROLE_MAPPING_NAME,
MOCK_IDP_ATTRIBUTE_PRINCIPAL,
MOCK_IDP_ATTRIBUTE_ROLES,
MOCK_IDP_ATTRIBUTE_EMAIL,
MOCK_IDP_ATTRIBUTE_NAME,
MOCK_IDP_SEARCH_ROLE_NAMES,
MOCK_IDP_OBSERVABILITY_ROLE_NAMES,
MOCK_IDP_SECURITY_ROLE_NAMES,
} from './constants';
export { createMockIdpMetadata, createSAMLResponse, ensureSAMLRoleMapping } from './utils';

View file

@ -7,12 +7,10 @@
*/
import { Client } from '@elastic/elasticsearch';
import { SignedXml } from 'xml-crypto';
import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils';
import { readFile } from 'fs/promises';
import zlib from 'zlib';
import { promisify } from 'util';
import { parseString } from 'xml2js';
import { X509Certificate } from 'crypto';
import {
@ -27,9 +25,6 @@ import {
MOCK_IDP_LOGOUT_PATH,
} from './constants';
const inflateRawAsync = promisify(zlib.inflateRaw);
const parseStringAsync = promisify(parseString);
/**
* Creates XML metadata for our mock identity provider.
*
@ -76,7 +71,7 @@ export async function createMockIdpMetadata(kibanaUrl: string) {
* const samlResponse = await createSAMLResponse({
* username: '1234567890',
* email: 'mail@elastic.co',
* fullname: 'Test User',
* full_nname: 'Test User',
* roles: ['t1_analyst', 'editor'],
* })
* ```
@ -87,7 +82,6 @@ export async function createMockIdpMetadata(kibanaUrl: string) {
* fetch('/api/security/saml/callback', {
* method: 'POST',
* body: JSON.stringify({ SAMLResponse: samlResponse }),
* redirect: 'manual'
* })
* ```
*/
@ -97,7 +91,7 @@ export async function createSAMLResponse(options: {
/** ID from SAML authentication request */
authnRequestId?: string;
username: string;
fullname?: string;
full_name?: string;
email?: string;
roles: string[];
}) {
@ -139,9 +133,9 @@ export async function createSAMLResponse(options: {
: ''
}
${
options.fullname
options.full_name
? `<saml:Attribute Name="${MOCK_IDP_ATTRIBUTE_NAME}" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
<saml:AttributeValue xsi:type="xs:string">${options.fullname}</saml:AttributeValue>
<saml:AttributeValue xsi:type="xs:string">${options.full_name}</saml:AttributeValue>
</saml:Attribute>`
: ''
}
@ -210,22 +204,3 @@ export async function ensureSAMLRoleMapping(client: Client) {
},
});
}
interface SAMLAuthnRequest {
'saml2p:AuthnRequest': {
$: {
AssertionConsumerServiceURL: string;
Destination: string;
ID: string;
IssueInstant: string;
};
};
}
export async function parseSAMLAuthnRequest(samlRequest: string) {
const inflatedSAMLRequest = (await inflateRawAsync(Buffer.from(samlRequest, 'base64'))) as Buffer;
const parsedSAMLRequest = (await parseStringAsync(
inflatedSAMLRequest.toString()
)) as SAMLAuthnRequest;
return parsedSAMLRequest['saml2p:AuthnRequest'].$;
}

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/dev-utils",
]
}

View file

@ -98,6 +98,7 @@ pageLoadAssetSize:
mapsEms: 26072
metricsDataAccess: 73287
ml: 82187
mockIdpPlugin: 30000
monitoring: 80000
navigation: 37269
newsfeed: 42228

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { createSAMLResponse as createMockedSAMLResponse } from '@kbn/mock-idp-plugin/common';
import { createSAMLResponse as createMockedSAMLResponse } from '@kbn/mock-idp-utils';
import { ToolingLog } from '@kbn/tooling-log';
import axios, { AxiosResponse } from 'axios';
import * as cheerio from 'cheerio';
@ -217,7 +217,7 @@ export const createLocalSAMLSession = async (params: LocalSamlSessionParams) =>
const samlResponse = await createMockedSAMLResponse({
kibanaUrl: kbnHost + '/api/security/saml/callback',
username,
fullname,
full_name: fullname,
email,
roles: [role],
});

View file

@ -3,7 +3,10 @@
"compilerOptions": {
"outDir": "target/types",
"stripInternal": true,
"types": ["jest", "node"]
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
@ -32,7 +35,7 @@
"@kbn/babel-register",
"@kbn/repo-packages",
"@kbn/core-saved-objects-api-server",
"@kbn/mock-idp-plugin",
"@kbn/mock-idp-utils",
"@kbn/code-owners",
]
}

View file

@ -18,6 +18,8 @@ import { getConfigFromFiles } from '@kbn/config';
const DEV_MODE_PATH = '@kbn/cli-dev-mode';
const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH);
const MOCK_IDP_PLUGIN_PATH = '@kbn/mock-idp-plugin/common';
const MOCK_IDP_PLUGIN_SUPPORTED = canRequire(MOCK_IDP_PLUGIN_PATH);
function canRequire(path) {
try {
@ -111,12 +113,11 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
setServerlessKibanaDevServiceAccountIfPossible(get, set, opts);
// Load mock identity provider plugin and configure realm if supported (ES only supports SAML when run with SSL)
if (opts.ssl && canRequire('@kbn/mock-idp-plugin/common')) {
if (opts.ssl && MOCK_IDP_PLUGIN_SUPPORTED) {
// Ensure the plugin is loaded in dynamically to exclude from production build
const {
MOCK_IDP_PLUGIN_PATH,
MOCK_IDP_REALM_NAME,
} = require('@kbn/mock-idp-plugin/common');
// eslint-disable-next-line import/no-dynamic-require
const { MOCK_IDP_REALM_NAME } = require(MOCK_IDP_PLUGIN_PATH);
const pluginPath = resolve(require.resolve(MOCK_IDP_PLUGIN_PATH), '..');
if (has('server.basePath')) {
console.log(
@ -125,7 +126,7 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
_.unset(rawConfig, 'server.basePath');
}
set('plugins.paths', _.compact([].concat(get('plugins.paths'), MOCK_IDP_PLUGIN_PATH)));
set('plugins.paths', _.compact([].concat(get('plugins.paths'), pluginPath)));
set(`xpack.security.authc.providers.saml.${MOCK_IDP_REALM_NAME}`, {
order: Number.MAX_SAFE_INTEGER,
realm: MOCK_IDP_REALM_NAME,

View file

@ -17,7 +17,6 @@
"@kbn/config",
"@kbn/dev-utils",
"@kbn/apm-config-loader",
"@kbn/mock-idp-plugin",
],
"exclude": [
"target/**/*",

View file

@ -246,7 +246,7 @@ export class Config {
return getPackages(this.repoRoot).filter(
(p) =>
(this.pluginSelector.testPlugins || !p.isDevOnly()) &&
(!p.isPlugin() || this.pluginFilter(p))
(!p.isPlugin() || (this.pluginFilter(p) && !p.isDevOnly()))
);
}

View file

@ -1100,6 +1100,8 @@
"@kbn/ml-url-state/*": ["x-pack/packages/ml/url_state/*"],
"@kbn/mock-idp-plugin": ["packages/kbn-mock-idp-plugin"],
"@kbn/mock-idp-plugin/*": ["packages/kbn-mock-idp-plugin/*"],
"@kbn/mock-idp-utils": ["packages/kbn-mock-idp-utils"],
"@kbn/mock-idp-utils/*": ["packages/kbn-mock-idp-utils/*"],
"@kbn/monaco": ["packages/kbn-monaco"],
"@kbn/monaco/*": ["packages/kbn-monaco/*"],
"@kbn/monitoring-collection-plugin": ["x-pack/plugins/monitoring_collection"],

View file

@ -5,6 +5,9 @@
"plugin": {
"id": "samlProviderPlugin",
"server": true,
"browser": false
"browser": false,
"optionalPlugins": [
"cloud"
]
}
}

View file

@ -6,10 +6,17 @@
*/
import type { PluginInitializer, Plugin } from '@kbn/core/server';
import { CloudSetup } from '@kbn/cloud-plugin/server';
import { initRoutes } from './init_routes';
export const plugin: PluginInitializer<void, void> = async (): Promise<Plugin> => ({
setup: (core) => initRoutes(core),
export interface PluginSetupDependencies {
cloud?: CloudSetup;
}
export const plugin: PluginInitializer<void, void, PluginSetupDependencies> = async (
context
): Promise<Plugin> => ({
setup: (core, plugins: PluginSetupDependencies) => initRoutes(context, core, plugins),
start: () => {},
stop: () => {},
});

View file

@ -5,13 +5,18 @@
* 2.0.
*/
import { CoreSetup } from '@kbn/core/server';
import { CoreSetup, PluginInitializerContext } from '@kbn/core/server';
import {
getSAMLResponse,
getSAMLRequestId,
} from '@kbn/security-api-integration-helpers/saml/saml_tools';
import { PluginSetupDependencies } from '.';
export function initRoutes(core: CoreSetup) {
export function initRoutes(
pluginContext: PluginInitializerContext,
core: CoreSetup,
plugins: PluginSetupDependencies
) {
const serverInfo = core.http.getServerInfo();
core.http.resources.register(
{
@ -59,6 +64,26 @@ export function initRoutes(core: CoreSetup) {
}
);
// [HACK]: On CI, Kibana runs Serverless functional tests against the production Kibana build but still relies on Mock
// IdP for SAML authentication in tests. The Mock IdP SAML realm, in turn, is linked to a Mock IDP plugin in Kibana
// that's only included in development mode and not available in the production Kibana build. Until our testing
// framework can properly support all SAML flows, we should forward all relevant Mock IDP plugin endpoints to a logout
// destination normally used in the Serverless setup.
if (pluginContext.env.mode.prod) {
core.http.resources.register(
{
path: '/mock_idp/login',
validate: false,
options: { authRequired: false },
},
async (context, request, response) => {
return response.redirected({
headers: { location: plugins.cloud?.projectsUrl ?? '/login' },
});
}
);
}
let attemptsCounter = 0;
core.http.resources.register(
{

View file

@ -11,6 +11,7 @@
"target/**/*",
],
"kbn_references": [
"@kbn/cloud-plugin",
"@kbn/core",
"@kbn/security-api-integration-helpers",
]

View file

@ -9,7 +9,7 @@ import expect from '@kbn/expect';
import { parse as parseCookie } from 'tough-cookie';
import Url from 'url';
import { createSAMLResponse } from '@kbn/mock-idp-plugin/common';
import { createSAMLResponse } from '@kbn/mock-idp-utils';
import { FtrProviderContext } from '../ftr_provider_context';
export function SamlToolsProvider({ getService }: FtrProviderContext) {

View file

@ -18,7 +18,7 @@ import {
} from '@kbn/test';
import { CA_CERT_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils';
import { commonFunctionalServices } from '@kbn/ftr-common-functional-services';
import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-plugin/common';
import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils';
import { services } from './services';
export default async () => {

View file

@ -79,7 +79,7 @@
"@kbn/apm-synthtrace",
"@kbn/apm-synthtrace-client",
"@kbn/reporting-export-types-csv-common",
"@kbn/mock-idp-plugin",
"@kbn/mock-idp-utils",
"@kbn/io-ts-utils",
"@kbn/log-explorer-plugin",
"@kbn/index-management-plugin",

View file

@ -5260,6 +5260,10 @@
version "0.0.0"
uid ""
"@kbn/mock-idp-utils@link:packages/kbn-mock-idp-utils":
version "0.0.0"
uid ""
"@kbn/monaco@link:packages/kbn-monaco":
version "0.0.0"
uid ""