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/ui_actions @elastic/ml-ui
x-pack/packages/ml/url_state @elastic/ml-ui x-pack/packages/ml/url_state @elastic/ml-ui
packages/kbn-mock-idp-plugin @elastic/kibana-security packages/kbn-mock-idp-plugin @elastic/kibana-security
packages/kbn-mock-idp-utils @elastic/kibana-security
packages/kbn-monaco @elastic/appex-sharedux packages/kbn-monaco @elastic/appex-sharedux
x-pack/plugins/monitoring_collection @elastic/obs-ux-infra_services-team x-pack/plugins/monitoring_collection @elastic/obs-ux-infra_services-team
x-pack/plugins/monitoring @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/managed-vscode-config-cli": "link:packages/kbn-managed-vscode-config-cli",
"@kbn/management-storybook-config": "link:packages/kbn-management/storybook/config", "@kbn/management-storybook-config": "link:packages/kbn-management/storybook/config",
"@kbn/mock-idp-plugin": "link:packages/kbn-mock-idp-plugin", "@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-bundler": "link:packages/kbn-openapi-bundler",
"@kbn/openapi-generator": "link:packages/kbn-openapi-generator", "@kbn/openapi-generator": "link:packages/kbn-openapi-generator",
"@kbn/optimizer": "link:packages/kbn-optimizer", "@kbn/optimizer": "link:packages/kbn-optimizer",

View file

@ -47,7 +47,12 @@ export type HttpResourcesResponseOptions = HttpResponseOptions;
export interface HttpResourcesServiceToolkit { export interface HttpResourcesServiceToolkit {
/** To respond with HTML page bootstrapping Kibana application. */ /** To respond with HTML page bootstrapping Kibana application. */
renderCoreApp: (options?: HttpResourcesRenderOptions) => Promise<IKibanaResponse>; 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>; renderAnonymousCoreApp: (options?: HttpResourcesRenderOptions) => Promise<IKibanaResponse>;
/** To respond with a custom HTML page. */ /** To respond with a custom HTML page. */
renderHtml: (options: HttpResourcesResponseOptions) => IKibanaResponse; renderHtml: (options: HttpResourcesResponseOptions) => IKibanaResponse;

View file

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

View file

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

View file

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

View file

@ -6,22 +6,4 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
export { export { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils';
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';

View file

@ -6,6 +6,13 @@
"plugin": { "plugin": {
"id": "mockIdpPlugin", "id": "mockIdpPlugin",
"server": true, "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. * Side Public License, v 1.
*/ */
export type { CreateSAMLResponseParams } from './plugin';
export { plugin } from './plugin'; export { plugin } from './plugin';

View file

@ -8,102 +8,59 @@
import type { PluginInitializer, Plugin } from '@kbn/core-plugins-server'; import type { PluginInitializer, Plugin } from '@kbn/core-plugins-server';
import { schema } from '@kbn/config-schema'; 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 { const createSAMLResponseSchema = schema.object({
MOCK_IDP_LOGIN_PATH, username: schema.string(),
MOCK_IDP_LOGOUT_PATH, full_name: schema.maybe(schema.nullable(schema.string())),
createSAMLResponse, email: schema.maybe(schema.nullable(schema.string())),
parseSAMLAuthnRequest, roles: schema.arrayOf(schema.string()),
} from '../common'; });
export type CreateSAMLResponseParams = TypeOf<typeof createSAMLResponseSchema>;
export const plugin: PluginInitializer<void, void> = async (): Promise<Plugin> => ({ export const plugin: PluginInitializer<void, void> = async (): Promise<Plugin> => ({
setup(core) { setup(core) {
const router = core.http.createRouter();
core.http.resources.register( core.http.resources.register(
{ {
path: MOCK_IDP_LOGIN_PATH, 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: { validate: {
query: schema.object({ body: createSAMLResponseSchema,
SAMLRequest: schema.string(),
}),
}, },
options: { authRequired: false }, options: { authRequired: false },
}, },
async (context, request, response) => { async (context, request, response) => {
let samlRequest: Awaited<ReturnType<typeof parseSAMLAuthnRequest>>; const { protocol, hostname, port } = core.http.getServerInfo();
try { const pathname = core.http.basePath.prepend('/api/security/saml/callback');
samlRequest = await parseSAMLAuthnRequest(request.query.SAMLRequest);
} catch (error) {
return response.badRequest({
body: '[request query.SAMLRequest]: value is not valid SAMLRequest.',
});
}
const userRoles: Array<[string, string]> = [ return response.ok({
['system_indices_superuser', 'system_indices_superuser'], body: {
['t1_analyst', 't1_analyst'], SAMLResponse: await createSAMLResponse({
['t2_analyst', 't2_analyst'], kibanaUrl: `${protocol}://${hostname}:${port}${pathname}`,
['t3_analyst', 't3_analyst'], username: request.body.username,
['threat_intelligence_analyst', 'threat_intelligence_analyst'], full_name: request.body.full_name ?? undefined,
['rule_author', 'rule_author'], email: request.body.email ?? undefined,
['soc_manager', 'soc_manager'], roles: request.body.roles,
['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>
`,
}); });
} }
); );
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( core.http.resources.register(
{ {
path: MOCK_IDP_LOGOUT_PATH, path: MOCK_IDP_LOGOUT_PATH,

View file

@ -11,8 +11,20 @@
"target/**/*" "target/**/*"
], ],
"kbn_references": [ "kbn_references": [
"@kbn/core-plugins-server",
"@kbn/config-schema", "@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. * 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_LOGIN_PATH = '/mock_idp/login';
export const MOCK_IDP_LOGOUT_PATH = '/mock_idp/logout'; export const MOCK_IDP_LOGOUT_PATH = '/mock_idp/logout';
export const MOCK_IDP_REALM_NAME = 'mock-idp'; 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_ENTITY_ID = 'urn:mock-idp'; // Must match `entityID` in `metadata.xml`
export const MOCK_IDP_ROLE_MAPPING_NAME = 'mock-idp-mapping'; 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_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_EMAIL = 'http://saml.elastic-cloud.com/attributes/email';
export const MOCK_IDP_ATTRIBUTE_NAME = 'http://saml.elastic-cloud.com/attributes/name'; 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 { Client } from '@elastic/elasticsearch';
import { SignedXml } from 'xml-crypto'; import { SignedXml } from 'xml-crypto';
import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import zlib from 'zlib';
import { promisify } from 'util';
import { parseString } from 'xml2js';
import { X509Certificate } from 'crypto'; import { X509Certificate } from 'crypto';
import { import {
@ -27,9 +25,6 @@ import {
MOCK_IDP_LOGOUT_PATH, MOCK_IDP_LOGOUT_PATH,
} from './constants'; } from './constants';
const inflateRawAsync = promisify(zlib.inflateRaw);
const parseStringAsync = promisify(parseString);
/** /**
* Creates XML metadata for our mock identity provider. * Creates XML metadata for our mock identity provider.
* *
@ -76,7 +71,7 @@ export async function createMockIdpMetadata(kibanaUrl: string) {
* const samlResponse = await createSAMLResponse({ * const samlResponse = await createSAMLResponse({
* username: '1234567890', * username: '1234567890',
* email: 'mail@elastic.co', * email: 'mail@elastic.co',
* fullname: 'Test User', * full_nname: 'Test User',
* roles: ['t1_analyst', 'editor'], * roles: ['t1_analyst', 'editor'],
* }) * })
* ``` * ```
@ -87,7 +82,6 @@ export async function createMockIdpMetadata(kibanaUrl: string) {
* fetch('/api/security/saml/callback', { * fetch('/api/security/saml/callback', {
* method: 'POST', * method: 'POST',
* body: JSON.stringify({ SAMLResponse: samlResponse }), * body: JSON.stringify({ SAMLResponse: samlResponse }),
* redirect: 'manual'
* }) * })
* ``` * ```
*/ */
@ -97,7 +91,7 @@ export async function createSAMLResponse(options: {
/** ID from SAML authentication request */ /** ID from SAML authentication request */
authnRequestId?: string; authnRequestId?: string;
username: string; username: string;
fullname?: string; full_name?: string;
email?: string; email?: string;
roles: 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: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>` </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 mapsEms: 26072
metricsDataAccess: 73287 metricsDataAccess: 73287
ml: 82187 ml: 82187
mockIdpPlugin: 30000
monitoring: 80000 monitoring: 80000
navigation: 37269 navigation: 37269
newsfeed: 42228 newsfeed: 42228

View file

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

View file

@ -3,7 +3,10 @@
"compilerOptions": { "compilerOptions": {
"outDir": "target/types", "outDir": "target/types",
"stripInternal": true, "stripInternal": true,
"types": ["jest", "node"] "types": [
"jest",
"node"
]
}, },
"include": [ "include": [
"**/*.ts", "**/*.ts",
@ -32,7 +35,7 @@
"@kbn/babel-register", "@kbn/babel-register",
"@kbn/repo-packages", "@kbn/repo-packages",
"@kbn/core-saved-objects-api-server", "@kbn/core-saved-objects-api-server",
"@kbn/mock-idp-plugin", "@kbn/mock-idp-utils",
"@kbn/code-owners", "@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_PATH = '@kbn/cli-dev-mode';
const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH); 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) { function canRequire(path) {
try { try {
@ -111,12 +113,11 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
setServerlessKibanaDevServiceAccountIfPossible(get, set, opts); setServerlessKibanaDevServiceAccountIfPossible(get, set, opts);
// Load mock identity provider plugin and configure realm if supported (ES only supports SAML when run with SSL) // 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 // Ensure the plugin is loaded in dynamically to exclude from production build
const { // eslint-disable-next-line import/no-dynamic-require
MOCK_IDP_PLUGIN_PATH, const { MOCK_IDP_REALM_NAME } = require(MOCK_IDP_PLUGIN_PATH);
MOCK_IDP_REALM_NAME, const pluginPath = resolve(require.resolve(MOCK_IDP_PLUGIN_PATH), '..');
} = require('@kbn/mock-idp-plugin/common');
if (has('server.basePath')) { if (has('server.basePath')) {
console.log( console.log(
@ -125,7 +126,7 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
_.unset(rawConfig, 'server.basePath'); _.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}`, { set(`xpack.security.authc.providers.saml.${MOCK_IDP_REALM_NAME}`, {
order: Number.MAX_SAFE_INTEGER, order: Number.MAX_SAFE_INTEGER,
realm: MOCK_IDP_REALM_NAME, realm: MOCK_IDP_REALM_NAME,

View file

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

View file

@ -246,7 +246,7 @@ export class Config {
return getPackages(this.repoRoot).filter( return getPackages(this.repoRoot).filter(
(p) => (p) =>
(this.pluginSelector.testPlugins || !p.isDevOnly()) && (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/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-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/monaco/*": ["packages/kbn-monaco/*"], "@kbn/monaco/*": ["packages/kbn-monaco/*"],
"@kbn/monitoring-collection-plugin": ["x-pack/plugins/monitoring_collection"], "@kbn/monitoring-collection-plugin": ["x-pack/plugins/monitoring_collection"],

View file

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

View file

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

View file

@ -5,13 +5,18 @@
* 2.0. * 2.0.
*/ */
import { CoreSetup } from '@kbn/core/server'; import { CoreSetup, PluginInitializerContext } from '@kbn/core/server';
import { import {
getSAMLResponse, getSAMLResponse,
getSAMLRequestId, getSAMLRequestId,
} from '@kbn/security-api-integration-helpers/saml/saml_tools'; } 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(); const serverInfo = core.http.getServerInfo();
core.http.resources.register( 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; let attemptsCounter = 0;
core.http.resources.register( core.http.resources.register(
{ {

View file

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

View file

@ -9,7 +9,7 @@ import expect from '@kbn/expect';
import { parse as parseCookie } from 'tough-cookie'; import { parse as parseCookie } from 'tough-cookie';
import Url from 'url'; 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'; import { FtrProviderContext } from '../ftr_provider_context';
export function SamlToolsProvider({ getService }: FtrProviderContext) { export function SamlToolsProvider({ getService }: FtrProviderContext) {

View file

@ -18,7 +18,7 @@ import {
} from '@kbn/test'; } from '@kbn/test';
import { CA_CERT_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils'; import { CA_CERT_PATH, kibanaDevServiceAccount } from '@kbn/dev-utils';
import { commonFunctionalServices } from '@kbn/ftr-common-functional-services'; 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'; import { services } from './services';
export default async () => { export default async () => {

View file

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

View file

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