From 1fb0313a5262caf2881af3d81992f914461cdfb2 Mon Sep 17 00:00:00 2001
From: Thom Heymann <190132+thomheymann@users.noreply.github.com>
Date: Wed, 15 Nov 2023 08:39:57 +0000
Subject: [PATCH] Add mock identity provider for serverless (#170852)
Related to [#166340](https://github.com/elastic/kibana/issues/166340)
## Summary
Add mock identity provider and utils to test serverless user roles.
## Screenshot
### 1. Login selector
### 2. Single sign on screen
### 3. User profile page
## Testing
SAML is only supported by ES when running in SSL mode.
1. To test the mock identity provider run a serverless project in SSL
mode using:
```bash
yarn es serverless --ssl
yarn start --serverless=es --ssl
```
2. Then access Kibana and login in using "Continue as Test User".
---------
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin
Co-authored-by: Dzmitry Lemechko
---
.github/CODEOWNERS | 1 +
package.json | 1 +
.../kbn-es/src/cli_commands/serverless.ts | 3 +-
packages/kbn-es/src/paths.ts | 3 +
packages/kbn-es/src/utils/docker.test.ts | 106 +++++++-
packages/kbn-es/src/utils/docker.ts | 148 ++++++++---
packages/kbn-es/tsconfig.json | 11 +-
.../kbn-mock-idp-plugin/common/constants.ts | 24 ++
packages/kbn-mock-idp-plugin/common/index.ts | 27 ++
packages/kbn-mock-idp-plugin/common/utils.ts | 231 ++++++++++++++++++
packages/kbn-mock-idp-plugin/kibana.jsonc | 11 +
packages/kbn-mock-idp-plugin/package.json | 6 +
packages/kbn-mock-idp-plugin/server/index.ts | 9 +
packages/kbn-mock-idp-plugin/server/plugin.ts | 120 +++++++++
packages/kbn-mock-idp-plugin/tsconfig.json | 18 ++
scripts/es.js | 1 +
src/cli/serve/serve.js | 24 +-
src/cli/tsconfig.json | 1 +
tsconfig.base.json | 2 +
yarn.lock | 4 +
20 files changed, 710 insertions(+), 41 deletions(-)
create mode 100644 packages/kbn-mock-idp-plugin/common/constants.ts
create mode 100644 packages/kbn-mock-idp-plugin/common/index.ts
create mode 100644 packages/kbn-mock-idp-plugin/common/utils.ts
create mode 100644 packages/kbn-mock-idp-plugin/kibana.jsonc
create mode 100644 packages/kbn-mock-idp-plugin/package.json
create mode 100644 packages/kbn-mock-idp-plugin/server/index.ts
create mode 100644 packages/kbn-mock-idp-plugin/server/plugin.ts
create mode 100644 packages/kbn-mock-idp-plugin/tsconfig.json
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 97560f4dfa64e..02dfc1de9d425 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -535,6 +535,7 @@ x-pack/packages/ml/runtime_field_utils @elastic/ml-ui
x-pack/packages/ml/string_hash @elastic/ml-ui
x-pack/packages/ml/trained_models_utils @elastic/ml-ui
x-pack/packages/ml/url_state @elastic/ml-ui
+packages/kbn-mock-idp-plugin @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
diff --git a/package.json b/package.json
index 37c915ba3d20c..9b34f3e887d44 100644
--- a/package.json
+++ b/package.json
@@ -1224,6 +1224,7 @@
"@kbn/managed-vscode-config": "link:packages/kbn-managed-vscode-config",
"@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/openapi-generator": "link:packages/kbn-openapi-generator",
"@kbn/optimizer": "link:packages/kbn-optimizer",
"@kbn/optimizer-webpack-helpers": "link:packages/kbn-optimizer-webpack-helpers",
diff --git a/packages/kbn-es/src/cli_commands/serverless.ts b/packages/kbn-es/src/cli_commands/serverless.ts
index 87a573a8810dd..0743cf2e7b5b6 100644
--- a/packages/kbn-es/src/cli_commands/serverless.ts
+++ b/packages/kbn-es/src/cli_commands/serverless.ts
@@ -38,6 +38,7 @@ export const serverless: Command = {
--host Publish ES docker container on additional host IP
--port The port to bind to on 127.0.0.1 [default: ${DEFAULT_PORT}]
--ssl Enable HTTP SSL on the ES cluster
+ --kibanaUrl Fully qualified URL where Kibana is hosted (including base path). [default: https://localhost:5601/]
--skipTeardown If this process exits, leave the ES cluster running in the background
--waitForReady Wait for the ES cluster to be ready to serve requests
--resources Overrides resources under ES 'config/' directory, which are by default
@@ -73,7 +74,7 @@ export const serverless: Command = {
files: 'F',
},
- string: ['tag', 'image', 'basePath', 'resources', 'host'],
+ string: ['tag', 'image', 'basePath', 'resources', 'host', 'kibanaUrl'],
boolean: ['clean', 'ssl', 'kill', 'background', 'skipTeardown', 'waitForReady'],
default: defaults,
diff --git a/packages/kbn-es/src/paths.ts b/packages/kbn-es/src/paths.ts
index d9b4be41aa15b..4da4448573384 100644
--- a/packages/kbn-es/src/paths.ts
+++ b/packages/kbn-es/src/paths.ts
@@ -8,6 +8,7 @@
import Os from 'os';
import { resolve } from 'path';
+import { REPO_ROOT } from '@kbn/repo-info';
function maybeUseBat(bin: string) {
return Os.platform().startsWith('win') ? `${bin}.bat` : bin;
@@ -51,6 +52,8 @@ export const SERVERLESS_SECRETS_SSL_PATH = resolve(
export const SERVERLESS_JWKS_PATH = resolve(__dirname, './serverless_resources/jwks.json');
+export const SERVERLESS_IDP_METADATA_PATH = resolve(REPO_ROOT, '.es', 'idp_metadata.xml');
+
export const SERVERLESS_RESOURCES_PATHS = [
SERVERLESS_OPERATOR_USERS_PATH,
SERVERLESS_ROLE_MAPPING_PATH,
diff --git a/packages/kbn-es/src/utils/docker.test.ts b/packages/kbn-es/src/utils/docker.test.ts
index 2d71a4e628e11..b574447a20508 100644
--- a/packages/kbn-es/src/utils/docker.test.ts
+++ b/packages/kbn-es/src/utils/docker.test.ts
@@ -32,15 +32,17 @@ import {
ServerlessOptions,
} from './docker';
import { ToolingLog, ToolingLogCollectingWriter } from '@kbn/tooling-log';
-import { ES_P12_PATH } from '@kbn/dev-utils';
+import { CA_CERT_PATH, ES_P12_PATH } from '@kbn/dev-utils';
import {
SERVERLESS_CONFIG_PATH,
SERVERLESS_RESOURCES_PATHS,
SERVERLESS_SECRETS_PATH,
SERVERLESS_JWKS_PATH,
+ SERVERLESS_IDP_METADATA_PATH,
} 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';
jest.mock('execa');
const execa = jest.requireMock('execa');
@@ -58,6 +60,8 @@ jest.mock('./wait_for_security_index', () => ({
waitForSecurityIndex: jest.fn(),
}));
+jest.mock('@kbn/mock-idp-plugin/common');
+
const log = new ToolingLog();
const logWriter = new ToolingLogCollectingWriter();
log.setWriters([logWriter]);
@@ -69,6 +73,8 @@ const serverlessObjectStorePath = `${baseEsPath}/${serverlessDir}`;
const waitUntilClusterReadyMock = jest.spyOn(waitClusterUtil, 'waitUntilClusterReady');
const waitForSecurityIndexMock = jest.spyOn(waitForSecurityIndexUtil, 'waitForSecurityIndex');
+const ensureSAMLRoleMappingMock = jest.spyOn(mockIdpPluginUtil, 'ensureSAMLRoleMapping');
+const createMockIdpMetadataMock = jest.spyOn(mockIdpPluginUtil, 'createMockIdpMetadata');
beforeEach(() => {
jest.resetAllMocks();
@@ -423,6 +429,66 @@ describe('resolveEsArgs()', () => {
]
`);
});
+
+ test('should add SAML realm args when kibanaUrl and SSL are passed', () => {
+ const esArgs = resolveEsArgs([], {
+ ssl: true,
+ kibanaUrl: 'https://localhost:5601/',
+ });
+
+ expect(esArgs).toHaveLength(26);
+ expect(esArgs).toMatchInlineSnapshot(`
+ Array [
+ "--env",
+ "xpack.security.http.ssl.enabled=true",
+ "--env",
+ "xpack.security.http.ssl.keystore.path=/usr/share/elasticsearch/config/certs/elasticsearch.p12",
+ "--env",
+ "xpack.security.http.ssl.verification_mode=certificate",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.order=0",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.idp.metadata.path=/usr/share/elasticsearch/config/secrets/idp_metadata.xml",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.idp.entity_id=urn:mock-idp",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.sp.entity_id=https://localhost:5601",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.sp.acs=https://localhost:5601/api/security/saml/callback",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.sp.logout=https://localhost:5601/logout",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.attributes.principal=http://saml.elastic-cloud.com/attributes/principal",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.attributes.groups=http://saml.elastic-cloud.com/attributes/roles",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.attributes.name=http://saml.elastic-cloud.com/attributes/email",
+ "--env",
+ "xpack.security.authc.realms.saml.mock-idp.attributes.mail=http://saml.elastic-cloud.com/attributes/name",
+ ]
+ `);
+ });
+
+ test('should not add SAML realm args when security is disabled', () => {
+ const esArgs = resolveEsArgs([['xpack.security.enabled', 'false']], {
+ ssl: true,
+ kibanaUrl: 'https://localhost:5601/',
+ });
+
+ expect(esArgs).toHaveLength(8);
+ expect(esArgs).toMatchInlineSnapshot(`
+ Array [
+ "--env",
+ "xpack.security.enabled=false",
+ "--env",
+ "xpack.security.http.ssl.enabled=true",
+ "--env",
+ "xpack.security.http.ssl.keystore.path=/usr/share/elasticsearch/config/certs/elasticsearch.p12",
+ "--env",
+ "xpack.security.http.ssl.verification_mode=certificate",
+ ]
+ `);
+ });
});
describe('setupServerlessVolumes()', () => {
@@ -463,21 +529,29 @@ describe('setupServerlessVolumes()', () => {
expect(existsSync(`${serverlessObjectStorePath}/cluster_state/lease`)).toBe(false);
});
- test('should add SSL volumes when ssl is passed', async () => {
+ test('should add SSL and IDP metadata volumes when ssl and kibanaUrl are passed', async () => {
mockFs(existingObjectStore);
+ createMockIdpMetadataMock.mockResolvedValue('');
- const volumeCmd = await setupServerlessVolumes(log, { basePath: baseEsPath, ssl: true });
+ const volumeCmd = await setupServerlessVolumes(log, {
+ basePath: baseEsPath,
+ ssl: true,
+ kibanaUrl: 'https://localhost:5603/',
+ });
+
+ expect(createMockIdpMetadataMock).toHaveBeenCalledTimes(1);
+ expect(createMockIdpMetadataMock).toHaveBeenCalledWith('https://localhost:5603/');
const requiredPaths = [
`${baseEsPath}:/objectstore:z`,
+ SERVERLESS_IDP_METADATA_PATH,
ES_P12_PATH,
...SERVERLESS_RESOURCES_PATHS,
];
const pathsNotIncludedInCmd = requiredPaths.filter(
(path) => !volumeCmd.some((cmd) => cmd.includes(path))
);
-
- expect(volumeCmd).toHaveLength(20);
+ expect(volumeCmd).toHaveLength(22);
expect(pathsNotIncludedInCmd).toEqual([]);
});
@@ -543,6 +617,7 @@ describe('runServerlessEsNode()', () => {
describe('runServerlessCluster()', () => {
test('should start 3 serverless nodes', async () => {
+ waitUntilClusterReadyMock.mockResolvedValue();
mockFs({
[baseEsPath]: {},
});
@@ -567,7 +642,27 @@ describe('runServerlessCluster()', () => {
expect(waitUntilClusterReadyMock.mock.calls[0][0].readyTimeout).toEqual(undefined);
});
+ test(`should create SAML role mapping when ssl and kibanaUrl are passed`, async () => {
+ waitUntilClusterReadyMock.mockResolvedValue();
+ mockFs({
+ [CA_CERT_PATH]: '',
+ [baseEsPath]: {},
+ });
+ execa.mockImplementation(() => Promise.resolve({ stdout: '' }));
+ createMockIdpMetadataMock.mockResolvedValue('');
+
+ await runServerlessCluster(log, {
+ basePath: baseEsPath,
+ waitForReady: true,
+ ssl: true,
+ kibanaUrl: 'https://localhost:5601/',
+ });
+
+ expect(ensureSAMLRoleMappingMock).toHaveBeenCalledTimes(1);
+ });
+
test(`should wait for the security index`, async () => {
+ waitUntilClusterReadyMock.mockResolvedValue();
waitForSecurityIndexMock.mockResolvedValue();
mockFs({
[baseEsPath]: {},
@@ -580,6 +675,7 @@ describe('runServerlessCluster()', () => {
});
test(`should not wait for the security index when security is disabled`, async () => {
+ waitUntilClusterReadyMock.mockResolvedValue();
mockFs({
[baseEsPath]: {},
});
diff --git a/packages/kbn-es/src/utils/docker.ts b/packages/kbn-es/src/utils/docker.ts
index 1c89339e1a567..73e5e1fc77288 100644
--- a/packages/kbn-es/src/utils/docker.ts
+++ b/packages/kbn-es/src/utils/docker.ts
@@ -14,12 +14,17 @@ import { Client, ClientOptions, HttpConnection } from '@elastic/elasticsearch';
import { ToolingLog } from '@kbn/tooling-log';
import { kibanaPackageJson as pkg, REPO_ROOT } from '@kbn/repo-info';
+import { CA_CERT_PATH, ES_P12_PASSWORD, ES_P12_PATH } from '@kbn/dev-utils';
import {
- CA_CERT_PATH,
- ES_P12_PASSWORD,
- ES_P12_PATH,
- kibanaDevServiceAccount,
-} from '@kbn/dev-utils';
+ MOCK_IDP_REALM_NAME,
+ MOCK_IDP_ENTITY_ID,
+ MOCK_IDP_ATTRIBUTE_PRINCIPAL,
+ MOCK_IDP_ATTRIBUTE_ROLES,
+ MOCK_IDP_ATTRIBUTE_EMAIL,
+ MOCK_IDP_ATTRIBUTE_NAME,
+ ensureSAMLRoleMapping,
+ createMockIdpMetadata,
+} from '@kbn/mock-idp-plugin/common';
import { waitForSecurityIndex } from './wait_for_security_index';
import { createCliError } from '../errors';
@@ -28,6 +33,7 @@ import {
SERVERLESS_RESOURCES_PATHS,
SERVERLESS_SECRETS_PATH,
SERVERLESS_JWKS_PATH,
+ SERVERLESS_IDP_METADATA_PATH,
SERVERLESS_CONFIG_PATH,
SERVERLESS_FILES_PATH,
SERVERLESS_SECRETS_SSL_PATH,
@@ -69,6 +75,8 @@ export interface ServerlessOptions extends EsClusterExecOptions, BaseOptions {
background?: boolean;
/** Wait for the ES cluster to be ready to serve requests */
waitForReady?: boolean;
+ /** Fully qualified URL where Kibana is hosted (including base path) */
+ kibanaUrl?: string;
/**
* Resource file(s) to overwrite
* (see list of files that can be overwritten under `packages/kbn-es/src/serverless_resources/users`)
@@ -460,6 +468,54 @@ export function resolveEsArgs(
esArgs.set('ELASTIC_PASSWORD', password);
}
+ // Configure mock identify provider (ES only supports SAML when running in SSL mode)
+ if (
+ ssl &&
+ 'kibanaUrl' in options &&
+ options.kibanaUrl &&
+ esArgs.get('xpack.security.enabled') !== 'false'
+ ) {
+ const trimTrailingSlash = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url);
+
+ esArgs.set(`xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.order`, '0');
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.idp.metadata.path`,
+ `${SERVERLESS_CONFIG_PATH}secrets/idp_metadata.xml`
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.idp.entity_id`,
+ MOCK_IDP_ENTITY_ID
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.sp.entity_id`,
+ trimTrailingSlash(options.kibanaUrl)
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.sp.acs`,
+ `${trimTrailingSlash(options.kibanaUrl)}/api/security/saml/callback`
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.sp.logout`,
+ `${trimTrailingSlash(options.kibanaUrl)}/logout`
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.principal`,
+ MOCK_IDP_ATTRIBUTE_PRINCIPAL
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.groups`,
+ MOCK_IDP_ATTRIBUTE_ROLES
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.name`,
+ MOCK_IDP_ATTRIBUTE_EMAIL
+ );
+ esArgs.set(
+ `xpack.security.authc.realms.saml.${MOCK_IDP_REALM_NAME}.attributes.mail`,
+ MOCK_IDP_ATTRIBUTE_NAME
+ );
+ }
+
return Array.from(esArgs).flatMap((e) => ['--env', e.join('=')]);
}
@@ -480,7 +536,7 @@ export function getDockerFileMountPath(hostPath: string) {
* Setup local volumes for Serverless ES
*/
export async function setupServerlessVolumes(log: ToolingLog, options: ServerlessOptions) {
- const { basePath, clean, ssl, files, resources } = options;
+ const { basePath, clean, ssl, kibanaUrl, files, resources } = options;
const objectStorePath = resolve(basePath, 'stateless');
log.info(chalk.bold(`Checking for local serverless ES object store at ${objectStorePath}`));
@@ -551,6 +607,16 @@ export async function setupServerlessVolumes(log: ToolingLog, options: Serverles
);
}
+ // Create and add meta data for mock identity provider
+ if (ssl && kibanaUrl) {
+ const metadata = await createMockIdpMetadata(kibanaUrl);
+ await Fsp.writeFile(SERVERLESS_IDP_METADATA_PATH, metadata);
+ volumeCmds.push(
+ '--volume',
+ `${SERVERLESS_IDP_METADATA_PATH}:${SERVERLESS_CONFIG_PATH}secrets/idp_metadata.xml:z`
+ );
+ }
+
volumeCmds.push(
...getESp12Volume(),
...serverlessResources,
@@ -559,7 +625,6 @@ export async function setupServerlessVolumes(log: ToolingLog, options: Serverles
`${
ssl ? SERVERLESS_SECRETS_SSL_PATH : SERVERLESS_SECRETS_PATH
}:${SERVERLESS_CONFIG_PATH}secrets/secrets.json:z`,
-
'--volume',
`${SERVERLESS_JWKS_PATH}:${SERVERLESS_CONFIG_PATH}secrets/jwks.json:z`
);
@@ -661,33 +726,52 @@ export async function runServerlessCluster(log: ToolingLog, options: ServerlessO
process.on('SIGINT', () => teardownServerlessClusterSync(log, options));
}
+ const esNodeUrl = `${options.ssl ? 'https' : 'http'}://${portCmd[1].substring(
+ 0,
+ portCmd[1].lastIndexOf(':')
+ )}`;
+
+ const client = getESClient({
+ node: esNodeUrl,
+ auth: {
+ username: ELASTIC_SERVERLESS_SUPERUSER,
+ password: ELASTIC_SERVERLESS_SUPERUSER_PASSWORD,
+ },
+ ...(options.ssl
+ ? {
+ tls: {
+ ca: [fs.readFileSync(CA_CERT_PATH)],
+ // NOTE: Even though we've added ca into the tls options, we are using 127.0.0.1 instead of localhost
+ // for the ip which is not validated. As such we are getting the error
+ // Hostname/IP does not match certificate's altnames: IP: 127.0.0.1 is not in the cert's list:
+ // To work around that we are overriding the function checkServerIdentity too
+ checkServerIdentity: () => {
+ return undefined;
+ },
+ },
+ }
+ : {}),
+ });
+
+ const readyPromise = waitUntilClusterReady({ client, expectedStatus: 'green', log }).then(
+ async () => {
+ if (!options.ssl || !options.kibanaUrl) {
+ return;
+ }
+
+ await ensureSAMLRoleMapping(client);
+
+ log.success(
+ `Created role mapping for mock identity provider. You can now login using ${chalk.bold.cyan(
+ MOCK_IDP_REALM_NAME
+ )} realm`
+ );
+ }
+ );
+
if (options.waitForReady) {
log.info('Waiting until ES is ready to serve requests...');
-
- const esNodeUrl = `${options.ssl ? 'https' : 'http'}://${portCmd[1].substring(
- 0,
- portCmd[1].lastIndexOf(':')
- )}`;
-
- const client = getESClient({
- node: esNodeUrl,
- auth: { bearer: kibanaDevServiceAccount.token },
- ...(options.ssl
- ? {
- tls: {
- ca: [fs.readFileSync(CA_CERT_PATH)],
- // NOTE: Even though we've added ca into the tls options, we are using 127.0.0.1 instead of localhost
- // for the ip which is not validated. As such we are getting the error
- // Hostname/IP does not match certificate's altnames: IP: 127.0.0.1 is not in the cert's list:
- // To work around that we are overriding the function checkServerIdentity too
- checkServerIdentity: () => {
- return undefined;
- },
- },
- }
- : {}),
- });
- await waitUntilClusterReady({ client, expectedStatus: 'green', log });
+ await readyPromise;
if (!options.esArgs || !options.esArgs.includes('xpack.security.enabled=false')) {
// If security is not disabled, make sure the security index exists before running the test to avoid flakiness
await waitForSecurityIndex({ client, log });
diff --git a/packages/kbn-es/tsconfig.json b/packages/kbn-es/tsconfig.json
index 75059c2ef69cd..b40ca33825562 100644
--- a/packages/kbn-es/tsconfig.json
+++ b/packages/kbn-es/tsconfig.json
@@ -3,13 +3,20 @@
"compilerOptions": {
"outDir": "target/types"
},
- "include": ["**/*.ts", "**/*.js", "**/*.json"],
- "exclude": ["target/**/*"],
+ "include": [
+ "**/*.ts",
+ "**/*.js",
+ "**/*.json"
+ ],
+ "exclude": [
+ "target/**/*"
+ ],
"kbn_references": [
"@kbn/tooling-log",
"@kbn/dev-utils",
"@kbn/dev-proc-runner",
"@kbn/ci-stats-reporter",
+ "@kbn/mock-idp-plugin",
"@kbn/jest-serializers",
"@kbn/repo-info"
]
diff --git a/packages/kbn-mock-idp-plugin/common/constants.ts b/packages/kbn-mock-idp-plugin/common/constants.ts
new file mode 100644
index 0000000000000..6fb5475587574
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/common/constants.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { 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_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_ATTRIBUTE_PRINCIPAL = 'http://saml.elastic-cloud.com/attributes/principal';
+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';
diff --git a/packages/kbn-mock-idp-plugin/common/index.ts b/packages/kbn-mock-idp-plugin/common/index.ts
new file mode 100644
index 0000000000000..aaaffc15f10f8
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/common/index.ts
@@ -0,0 +1,27 @@
+/*
+ * 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_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';
diff --git a/packages/kbn-mock-idp-plugin/common/utils.ts b/packages/kbn-mock-idp-plugin/common/utils.ts
new file mode 100644
index 0000000000000..5d55fbc565685
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/common/utils.ts
@@ -0,0 +1,231 @@
+/*
+ * 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 { 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 {
+ 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,
+ MOCK_IDP_LOGIN_PATH,
+ MOCK_IDP_LOGOUT_PATH,
+} from './constants';
+
+const inflateRawAsync = promisify(zlib.inflateRaw);
+const parseStringAsync = promisify(parseString);
+
+/**
+ * Creates XML metadata for our mock identity provider.
+ *
+ * This can be saved to file and used to configure Elasticsearch SAML realm.
+ *
+ * @param kibanaUrl Fully qualified URL where Kibana is hosted (including base path)
+ */
+export async function createMockIdpMetadata(kibanaUrl: string) {
+ const signingKey = await readFile(KBN_CERT_PATH);
+ const cert = new X509Certificate(signingKey);
+ const trimTrailingSlash = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url);
+
+ return `
+
+
+
+
+
+ ${cert.raw.toString('base64')}
+
+
+
+
+
+
+
+
+
+ `;
+}
+
+/**
+ * Creates a SAML response that can be passed directly to the Kibana ACS endpoint to authenticate a user.
+ *
+ * @example Create a SAML response.
+ *
+ * ```ts
+ * const samlResponse = await createSAMLResponse({
+ * username: '1234567890',
+ * email: 'mail@elastic.co',
+ * fullname: 'Test User',
+ * roles: ['t1_analyst', 'editor'],
+ * })
+ * ```
+ *
+ * @example Authenticate user with SAML response.
+ *
+ * ```ts
+ * fetch('/api/security/saml/callback', {
+ * method: 'POST',
+ * body: JSON.stringify({ SAMLResponse: samlResponse }),
+ * redirect: 'manual'
+ * })
+ * ```
+ */
+export async function createSAMLResponse(options: {
+ /** Fully qualified URL where Kibana is hosted (including base path) */
+ kibanaUrl: string;
+ /** ID from SAML authentication request */
+ authnRequestId?: string;
+ username: string;
+ fullname?: string;
+ email?: string;
+ roles: string[];
+}) {
+ const issueInstant = new Date().toISOString();
+ const notOnOrAfter = new Date(Date.now() + 3600 * 1000).toISOString();
+
+ const samlAssertionTemplateXML = `
+
+ ${MOCK_IDP_ENTITY_ID}
+
+ _643ec1b3f5673583b9f9a1e9e73a36daa2a3748f
+
+
+
+
+
+
+ urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified
+
+
+
+
+ ${options.username}
+
+
+ ${options.roles
+ .map(
+ (role) => `${role}`
+ )
+ .join('')}
+
+ ${
+ options.email
+ ? `
+ ${options.email}
+ `
+ : ''
+ }
+ ${
+ options.fullname
+ ? `
+ ${options.fullname}
+ `
+ : ''
+ }
+
+
+ `;
+
+ const signature = new SignedXml();
+ signature.signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
+ signature.signingKey = await readFile(KBN_KEY_PATH);
+
+ // Adds a reference to a `Assertion` xml element and an array of transform algorithms to be used during signing.
+ signature.addReference(
+ `//*[local-name(.)='Assertion']`,
+ [
+ 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
+ 'http://www.w3.org/2001/10/xml-exc-c14n#',
+ ],
+ 'http://www.w3.org/2001/04/xmlenc#sha256'
+ );
+
+ signature.computeSignature(samlAssertionTemplateXML, {
+ location: { reference: `//*[local-name(.)='Issuer']`, action: 'after' },
+ });
+
+ const value = await Buffer.from(
+ `
+
+ ${MOCK_IDP_ENTITY_ID}
+
+
+ ${signature.getSignedXml()}
+
+ `
+ ).toString('base64');
+
+ return value;
+}
+
+/**
+ * Creates the role mapping required for developers to authenticate using SAML.
+ */
+export async function ensureSAMLRoleMapping(client: Client) {
+ return client.transport.request({
+ method: 'PUT',
+ path: `/_security/role_mapping/${MOCK_IDP_ROLE_MAPPING_NAME}`,
+ body: {
+ enabled: true,
+ role_templates: [
+ {
+ template: '{"source":"{{#tojson}}groups{{/tojson}}"}',
+ format: 'json',
+ },
+ ],
+ rules: {
+ all: [
+ {
+ field: {
+ 'realm.name': MOCK_IDP_REALM_NAME,
+ },
+ },
+ ],
+ },
+ },
+ });
+}
+
+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'].$;
+}
diff --git a/packages/kbn-mock-idp-plugin/kibana.jsonc b/packages/kbn-mock-idp-plugin/kibana.jsonc
new file mode 100644
index 0000000000000..929d7b9b990db
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/kibana.jsonc
@@ -0,0 +1,11 @@
+{
+ "type": "plugin",
+ "id": "@kbn/mock-idp-plugin",
+ "owner": "@elastic/kibana-security",
+ "devOnly": true,
+ "plugin": {
+ "id": "mockIdpPlugin",
+ "server": true,
+ "browser": false
+ }
+}
\ No newline at end of file
diff --git a/packages/kbn-mock-idp-plugin/package.json b/packages/kbn-mock-idp-plugin/package.json
new file mode 100644
index 0000000000000..456a2cfa5ce32
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@kbn/mock-idp-plugin",
+ "private": true,
+ "version": "1.0.0",
+ "license": "SSPL-1.0 OR Elastic License 2.0"
+}
\ No newline at end of file
diff --git a/packages/kbn-mock-idp-plugin/server/index.ts b/packages/kbn-mock-idp-plugin/server/index.ts
new file mode 100644
index 0000000000000..db807851d4564
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/server/index.ts
@@ -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';
diff --git a/packages/kbn-mock-idp-plugin/server/plugin.ts b/packages/kbn-mock-idp-plugin/server/plugin.ts
new file mode 100644
index 0000000000000..e24e7418cce62
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/server/plugin.ts
@@ -0,0 +1,120 @@
+/*
+ * 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, Plugin } from '@kbn/core-plugins-server';
+import { schema } from '@kbn/config-schema';
+
+import {
+ MOCK_IDP_LOGIN_PATH,
+ MOCK_IDP_LOGOUT_PATH,
+ createSAMLResponse,
+ parseSAMLAuthnRequest,
+} from '../common';
+
+export const plugin: PluginInitializer = (): Plugin => ({
+ setup(core) {
+ core.http.resources.register(
+ {
+ path: MOCK_IDP_LOGIN_PATH,
+ validate: {
+ query: schema.object({
+ SAMLRequest: schema.string(),
+ }),
+ },
+ options: { authRequired: false },
+ },
+ async (context, request, response) => {
+ let samlRequest: Awaited>;
+ try {
+ 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]> = [
+ ['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: `
+
+ Mock Identity Provider
+
+
+ Mock Identity Provider
+
+
+ `,
+ });
+ }
+ );
+
+ 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,
+ validate: false,
+ options: { authRequired: false },
+ },
+ async (context, request, response) => {
+ return response.redirected({ headers: { location: '/' } });
+ }
+ );
+ },
+ start() {},
+ stop() {},
+});
diff --git a/packages/kbn-mock-idp-plugin/tsconfig.json b/packages/kbn-mock-idp-plugin/tsconfig.json
new file mode 100644
index 0000000000000..1420a34208f13
--- /dev/null
+++ b/packages/kbn-mock-idp-plugin/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "target/types"
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx"
+ ],
+ "exclude": [
+ "target/**/*"
+ ],
+ "kbn_references": [
+ "@kbn/core-plugins-server",
+ "@kbn/config-schema",
+ "@kbn/dev-utils"
+ ]
+}
diff --git a/scripts/es.js b/scripts/es.js
index 1fcd221c97904..1cee27b7685b5 100644
--- a/scripts/es.js
+++ b/scripts/es.js
@@ -20,6 +20,7 @@ kbnEs
'source-path': resolve(__dirname, '../../elasticsearch'),
'base-path': resolve(__dirname, '../.es'),
ssl: false,
+ kibanaUrl: 'https://localhost:5601/',
})
.catch(function (e) {
console.error(e);
diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js
index 911eecd45a9fb..adb951def861a 100644
--- a/src/cli/serve/serve.js
+++ b/src/cli/serve/serve.js
@@ -15,6 +15,7 @@ import { isKibanaDistributable } from '@kbn/repo-info';
import { readKeystore } from '../keystore/read_keystore';
import { compileConfigStack } from './compile_config_stack';
import { getConfigFromFiles } from '@kbn/config';
+import { MOCK_IDP_PLUGIN_PATH, MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-plugin/common';
const DEV_MODE_PATH = '@kbn/cli-dev-mode';
const DEV_MODE_SUPPORTED = canRequire(DEV_MODE_PATH);
@@ -109,6 +110,25 @@ export function applyConfigOverrides(rawConfig, opts, extraCliOptions) {
if (opts.dev) {
if (opts.serverless) {
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) {
+ set('plugins.paths', _.compact([].concat(get('plugins.paths'), MOCK_IDP_PLUGIN_PATH)));
+ set(`xpack.security.authc.providers.saml.${MOCK_IDP_REALM_NAME}`, {
+ order: Number.MAX_SAFE_INTEGER,
+ realm: MOCK_IDP_REALM_NAME,
+ icon: 'user',
+ description: 'Continue as Test User',
+ hint: 'Allows testing serverless user roles',
+ });
+ // Add basic realm since defaults won't be applied when a provider has been configured
+ if (!has('xpack.security.authc.providers.basic')) {
+ set('xpack.security.authc.providers.basic.basic', {
+ order: 0,
+ enabled: true,
+ });
+ }
+ }
}
if (!has('elasticsearch.serviceAccountToken') && opts.devCredentials !== false) {
@@ -274,7 +294,9 @@ export default function (program) {
// We can tell users they only have to run with `yarn start --run-examples` to get those
// local links to work. Similar to what we do for "View in Console" links in our
// elastic.co links.
- basePath: opts.runExamples ? false : !!opts.basePath,
+ // We also want to run without base path when running in serverless mode so that Elasticsearch can
+ // connect to Kibana's mock identity provider.
+ basePath: opts.runExamples || isServerlessMode ? false : !!opts.basePath,
optimize: !!opts.optimize,
disableOptimizer: !opts.optimizer,
oss: !!opts.oss,
diff --git a/src/cli/tsconfig.json b/src/cli/tsconfig.json
index ebbbc19f75c79..3b3c0854975d3 100644
--- a/src/cli/tsconfig.json
+++ b/src/cli/tsconfig.json
@@ -17,6 +17,7 @@
"@kbn/config",
"@kbn/dev-utils",
"@kbn/apm-config-loader",
+ "@kbn/mock-idp-plugin",
],
"exclude": [
"target/**/*",
diff --git a/tsconfig.base.json b/tsconfig.base.json
index f833b70622d32..36f09e1605d47 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -1064,6 +1064,8 @@
"@kbn/ml-trained-models-utils/*": ["x-pack/packages/ml/trained_models_utils/*"],
"@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/monaco": ["packages/kbn-monaco"],
"@kbn/monaco/*": ["packages/kbn-monaco/*"],
"@kbn/monitoring-collection-plugin": ["x-pack/plugins/monitoring_collection"],
diff --git a/yarn.lock b/yarn.lock
index 2bde2182e3bd0..c6869b045e59a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5032,6 +5032,10 @@
version "0.0.0"
uid ""
+"@kbn/mock-idp-plugin@link:packages/kbn-mock-idp-plugin":
+ version "0.0.0"
+ uid ""
+
"@kbn/monaco@link:packages/kbn-monaco":
version "0.0.0"
uid ""