[CLOUD] Add security question in onboarding (#208229)

## Summary

https://github.com/elastic/cloud/issues/133183



### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Kateryna Stukan <92258556+galaxxyz@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Xavier Mouligneau 2025-01-31 04:22:22 -05:00 committed by GitHub
parent cdcd47136f
commit ac22f58bc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 404 additions and 79 deletions

View file

@ -47,6 +47,41 @@ describe('persistTokenCloudData', () => {
);
});
it('creates a new saved object if none exists and security details are provided', async () => {
(mockSavedObjectsClient.get as jest.Mock).mockRejectedValue(
SavedObjectsErrorHelpers.createGenericNotFoundError()
);
await persistTokenCloudData(mockSavedObjectsClient, {
logger: mockLogger,
solutionType: 'security',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
});
expect(mockSavedObjectsClient.create).toHaveBeenCalledWith(
CLOUD_DATA_SAVED_OBJECT_TYPE,
{
onboardingData: {
solutionType: 'security',
token: '',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
},
},
{ id: CLOUD_DATA_SAVED_OBJECT_ID }
);
});
it('updates an existing saved object if onboardingToken is provided and different', async () => {
(mockSavedObjectsClient.get as jest.Mock).mockResolvedValue({
id: CLOUD_DATA_SAVED_OBJECT_ID,
@ -75,13 +110,67 @@ describe('persistTokenCloudData', () => {
);
});
it('does nothing if onboardingToken is the same', async () => {
it('updates an existing saved object if security details are provided and different', async () => {
(mockSavedObjectsClient.get as jest.Mock).mockResolvedValue({
id: CLOUD_DATA_SAVED_OBJECT_ID,
attributes: {
onboardingData: {
solutionType: 'security',
token: 'test_token',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
},
},
});
await persistTokenCloudData(mockSavedObjectsClient, {
logger: mockLogger,
solutionType: 'security',
security: {
useCase: 'siem',
migration: {
value: false,
},
},
});
expect(mockSavedObjectsClient.update).toHaveBeenCalledWith(
CLOUD_DATA_SAVED_OBJECT_TYPE,
CLOUD_DATA_SAVED_OBJECT_ID,
{
onboardingData: {
solutionType: 'security',
token: 'test_token',
security: {
useCase: 'siem',
migration: {
value: false,
},
},
},
}
);
});
it('does nothing if onboardingToken and security details are the same', async () => {
(mockSavedObjectsClient.get as jest.Mock).mockResolvedValue({
id: CLOUD_DATA_SAVED_OBJECT_ID,
attributes: {
onboardingData: {
token: 'same_token',
solutionType: 'test_solution',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
},
},
});
@ -89,6 +178,13 @@ describe('persistTokenCloudData', () => {
logger: mockLogger,
onboardingToken: 'same_token',
solutionType: 'test_solution',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
});
expect(mockSavedObjectsClient.update).not.toHaveBeenCalled();

View file

@ -5,8 +5,10 @@
* 2.0.
*/
import { isDeepEqual } from 'react-use/lib/util';
import { Logger, SavedObjectsClientContract, SavedObjectsErrorHelpers } from '@kbn/core/server';
import { CloudDataAttributes, SolutionType } from '../routes/types';
import { CloudDataAttributes, CloudSecurityAnswer, SolutionType } from '../routes/types';
import { CLOUD_DATA_SAVED_OBJECT_TYPE } from '../saved_objects';
import { CLOUD_DATA_SAVED_OBJECT_ID } from '../routes/constants';
@ -17,11 +19,13 @@ export const persistTokenCloudData = async (
returnError,
onboardingToken,
solutionType,
security,
}: {
logger?: Logger;
returnError?: boolean;
onboardingToken?: string;
solutionType?: string;
security?: CloudSecurityAnswer;
}
): Promise<void> => {
let cloudDataSo = null;
@ -41,23 +45,25 @@ export const persistTokenCloudData = async (
}
}
}
const securityAttributes = cloudDataSo?.attributes.onboardingData?.security;
try {
if (onboardingToken && cloudDataSo === null) {
if ((onboardingToken || security) && cloudDataSo === null) {
await savedObjectsClient.create<CloudDataAttributes>(
CLOUD_DATA_SAVED_OBJECT_TYPE,
{
onboardingData: {
solutionType: solutionType as SolutionType,
token: onboardingToken,
token: onboardingToken ?? '',
security,
},
},
{ id: CLOUD_DATA_SAVED_OBJECT_ID }
);
} else if (
onboardingToken &&
cloudDataSo?.attributes.onboardingData.token &&
cloudDataSo?.attributes.onboardingData.token !== onboardingToken
cloudDataSo &&
(cloudDataSo?.attributes.onboardingData.token !== onboardingToken ||
!isDeepEqual(securityAttributes, security))
) {
await savedObjectsClient.update<CloudDataAttributes>(
CLOUD_DATA_SAVED_OBJECT_TYPE,
@ -66,7 +72,8 @@ export const persistTokenCloudData = async (
onboardingData: {
solutionType:
(solutionType as SolutionType) ?? cloudDataSo?.attributes.onboardingData.solutionType,
token: onboardingToken,
token: onboardingToken ?? cloudDataSo?.attributes.onboardingData.token,
security: security ?? securityAttributes,
},
}
);

View file

@ -13,6 +13,7 @@ import type { SolutionId } from '@kbn/core-chrome-browser';
import { schema } from '@kbn/config-schema';
import { parseNextURL } from '@kbn/std';
import camelcaseKeys from 'camelcase-keys';
import type { CloudConfigType } from './config';
import { registerCloudDeploymentMetadataAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context';
@ -215,6 +216,24 @@ export class CloudPlugin implements Plugin<CloudSetup, CloudStart> {
{
next: schema.maybe(schema.string()),
onboarding_token: schema.maybe(schema.string()),
security: schema.maybe(
schema.object({
use_case: schema.oneOf([
schema.literal('siem'),
schema.literal('cloud'),
schema.literal('edr'),
schema.literal('other'),
]),
migration: schema.maybe(
schema.object({
value: schema.boolean(),
type: schema.maybe(
schema.oneOf([schema.literal('splunk'), schema.literal('other')])
),
})
),
})
),
},
{ unknowns: 'ignore' }
)
@ -237,9 +256,16 @@ export class CloudPlugin implements Plugin<CloudSetup, CloudStart> {
// need to get reed of ../../ to make sure we will not be out of space basePath
const normalizedRoute = new URL(route, 'https://localhost');
const queryOnboardingToken = request.url.searchParams.get('onboarding_token');
const queryOnboardingToken = request.query?.onboarding_token ?? undefined;
const queryOnboardingSecurityRaw = request.query?.security ?? undefined;
const queryOnboardingSecurity = queryOnboardingSecurityRaw
? camelcaseKeys(queryOnboardingSecurityRaw, {
deep: true,
})
: undefined;
const solutionType = this.config.onboarding?.default_solution;
if (queryOnboardingToken) {
if (queryOnboardingToken || queryOnboardingSecurity) {
core
.getStartServices()
.then(async ([coreStart]) => {
@ -251,6 +277,7 @@ export class CloudPlugin implements Plugin<CloudSetup, CloudStart> {
logger: this.logger,
onboardingToken: queryOnboardingToken,
solutionType,
security: queryOnboardingSecurity,
});
})
.catch((errorMsg) => this.logger.error(errorMsg));

View file

@ -16,5 +16,14 @@ export interface CloudDataAttributes {
onboardingData: {
solutionType?: SolutionType;
token: string;
security?: CloudSecurityAnswer;
};
}
export interface CloudSecurityAnswer {
useCase: 'siem' | 'cloud' | 'edr' | 'other';
migration?: {
value: boolean;
type?: 'splunk' | 'other';
};
}

View file

@ -32,7 +32,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const testEndpointsPlugin = resolve(__dirname, '../security_functional/plugins/test_endpoints');
return {
testFiles: [resolve(__dirname, './tests/onboarding_token.ts')],
testFiles: [resolve(__dirname, './tests/onboarding.ts')],
services,
pageObjects,

View file

@ -0,0 +1,254 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parse } from 'url';
import expect from '@kbn/expect';
import { MAIN_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import type { FtrProviderContext } from '../../security_functional/ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const es = getService('es');
const find = getService('find');
const browser = getService('browser');
const deployment = getService('deployment');
const PageObjects = getPageObjects(['common']);
const supertest = getService('supertest');
const deleteSavedObject = async () => {
await es.deleteByQuery({
index: MAIN_SAVED_OBJECT_INDEX,
q: 'type:cloud',
wait_for_completion: true,
refresh: true,
body: {},
conflicts: 'proceed',
});
};
describe('Onboarding integration', function () {
this.tags('includeFirefox');
before(async () => {
await getService('esSupertest')
.post('/_security/role_mapping/saml1')
.send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
.expect(200);
});
afterEach(async () => {
await browser.get(deployment.getHostPort() + '/logout');
await PageObjects.common.waitUntilUrlIncludes('logged_out');
await deleteSavedObject();
});
it('Redirect and save token', async () => {
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?onboarding_token=vector&next=${encodeURIComponent(
'/app/elasticsearch/vector_search'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
// We need to make sure that both path and hash are respected.
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.pathname).to.eql('/app/elasticsearch/vector_search');
expect(currentURL.hash).to.eql('#some=hash-value');
const {
body: { onboardingData },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingData).to.eql({ token: 'vector' });
});
it('Redirect and save security details at creation time', async () => {
const securityDetails = '{"use_case":"siem","migration":{"value":true,"type":"splunk"}}';
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?onboarding_token=security&security=${securityDetails}&next=${encodeURIComponent(
'/app/security/get_started'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
// We need to make sure that both path and hash are respected.
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.pathname).to.eql('/app/security/get_started');
expect(currentURL.hash).to.eql('#some=hash-value');
const {
body: { onboardingData },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingData).to.eql({
token: 'security',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
});
});
it('Redirect and update security details', async () => {
const securityDetails = '{"use_case":"siem","migration":{"value":true,"type":"splunk"}}';
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?onboarding_token=security&security=${securityDetails}&next=${encodeURIComponent(
'/app/security/get_started'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
// We need to make sure that both path and hash are respected.
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.pathname).to.eql('/app/security/get_started');
expect(currentURL.hash).to.eql('#some=hash-value');
const {
body: { onboardingData },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingData).to.eql({
token: 'security',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
});
const securityDetailsUpdated =
'{"use_case":"cloud","migration":{"value":true,"type":"other"}}';
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?onboarding_token=security&security=${securityDetailsUpdated}&next=${encodeURIComponent(
'/app/security/get_started'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
const {
body: { onboardingData: onboardingDataUpdated },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingDataUpdated).to.eql({
token: 'security',
security: {
useCase: 'cloud',
migration: {
value: true,
type: 'other',
},
},
});
});
it(`Redirect and keep initial onboarding token when it's not provided on update`, async () => {
const securityDetails = '{"use_case":"siem","migration":{"value":true,"type":"splunk"}}';
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?onboarding_token=security&security=${securityDetails}&next=${encodeURIComponent(
'/app/security/get_started'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
// We need to make sure that both path and hash are respected.
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.pathname).to.eql('/app/security/get_started');
expect(currentURL.hash).to.eql('#some=hash-value');
const {
body: { onboardingData },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingData).to.eql({
token: 'security',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
});
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?security=${securityDetails}&next=${encodeURIComponent(
'/app/security/get_started'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
const {
body: { onboardingData: onboardingDataUpdated },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingDataUpdated).to.eql({
token: 'security',
security: {
useCase: 'siem',
migration: {
value: true,
type: 'splunk',
},
},
});
});
});
}

View file

@ -1,68 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parse } from 'url';
import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../security_functional/ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const find = getService('find');
const browser = getService('browser');
const deployment = getService('deployment');
const PageObjects = getPageObjects(['common']);
const supertest = getService('supertest');
describe('onboarding token', function () {
this.tags('includeFirefox');
before(async () => {
await getService('esSupertest')
.post('/_security/role_mapping/saml1')
.send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
.expect(200);
});
afterEach(async () => {
await browser.get(deployment.getHostPort() + '/logout');
await PageObjects.common.waitUntilUrlIncludes('logged_out');
});
it('Redirect and save token', async () => {
await browser.get(
deployment.getHostPort() +
`/app/cloud/onboarding?onboarding_token=vector&next=${encodeURIComponent(
'/app/elasticsearch/vector_search'
)}#some=hash-value`
);
await find.byCssSelector('[data-test-subj="userMenuButton"]', 20000);
// We need to make sure that both path and hash are respected.
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.pathname).to.eql('/app/elasticsearch/vector_search');
expect(currentURL.hash).to.eql('#some=hash-value');
const {
body: { onboardingData },
} = await supertest
.get('/internal/cloud/solution')
.set('kbn-xsrf', 'xxx')
.set('x-elastic-internal-origin', 'cloud')
.set('elastic-api-version', '1')
.expect(200);
expect(onboardingData).to.eql({ token: 'vector' });
});
});
}