[Cases] User profile hooks (#137830)

* Refactoring services, auth

* Adding suggest api and tests

* Working integration tests

* Switching suggest api tags

* Adding tests for size and owner

* Adding api tag tests

* Addressing feedback

* Create suggest query

* Add tests

* Add security as dependency and fix types

* Add bulk get profiles query

* Rename folder

* Revert security solution optional

* PR feedback

* Reduce size

* Make security required

* Fix tests

* Do not retry suggestions

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2022-08-04 15:22:09 +03:00 committed by GitHub
parent 025f6f3ccd
commit c3a55d1aa8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 468 additions and 11 deletions

View file

@ -157,3 +157,9 @@ export const READ_CASES_CAPABILITY = 'read_cases' as const;
export const UPDATE_CASES_CAPABILITY = 'update_cases' as const;
export const DELETE_CASES_CAPABILITY = 'delete_cases' as const;
export const PUSH_CASES_CAPABILITY = 'push_cases' as const;
/**
* User profiles
*/
export const DEFAULT_USER_SIZE = 10;

View file

@ -11,7 +11,6 @@
"kibanaVersion":"kibana",
"optionalPlugins":[
"home",
"security",
"taskManager",
"usageCollection"
],
@ -30,7 +29,8 @@
"kibanaUtils",
"triggersActionsUi",
"management",
"spaces"
"spaces",
"security"
],
"requiredBundles": [
"savedObjects"

View file

@ -16,3 +16,11 @@ export const CASE_LIST_CACHE_KEY = 'case-list';
export const CASE_CONNECTORS_CACHE_KEY = 'case-connectors';
export const CASE_LICENSE_CACHE_KEY = 'case-license-action';
export const CASE_TAGS_CACHE_KEY = 'case-tags';
/**
* User profiles
*/
export const USER_PROFILES_CACHE_KEY = 'user-profiles';
export const USER_PROFILES_SUGGEST_CACHE_KEY = 'suggest';
export const USER_PROFILES_BULK_GET_CACHE_KEY = 'bulk-get';

View file

@ -0,0 +1,15 @@
/*
* 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 { UserProfile } from '@kbn/security-plugin/common';
import { userProfiles } from '../api.mock';
export const suggestUserProfiles = async (): Promise<UserProfile[]> =>
Promise.resolve(userProfiles);
export const bulkGetUserProfiles = async (): Promise<UserProfile[]> =>
Promise.resolve(userProfiles);

View file

@ -0,0 +1,43 @@
/*
* 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 { UserProfile } from '@kbn/security-plugin/common';
export const userProfiles: UserProfile[] = [
{
uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
data: {},
user: {
username: 'damaged_raccoon',
email: 'damaged_raccoon@elastic.co',
full_name: 'Damaged Raccoon',
},
enabled: true,
},
{
uid: 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
data: {},
user: {
username: 'physical_dinosaur',
email: 'physical_dinosaur@elastic.co',
full_name: 'Physical Dinosaur',
},
enabled: true,
},
{
uid: 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
data: {},
user: {
username: 'wet_dingo',
email: 'wet_dingo@elastic.co',
full_name: 'Wet Dingo',
},
enabled: true,
},
];
export const userProfilesIds = userProfiles.map((profile) => profile.uid);

View file

@ -0,0 +1,82 @@
/*
* 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 { GENERAL_CASES_OWNER } from '../../../common/constants';
import { createStartServicesMock } from '../../common/lib/kibana/kibana_react.mock';
import { bulkGetUserProfiles, suggestUserProfiles } from './api';
import { userProfiles, userProfilesIds } from './api.mock';
describe('User profiles API', () => {
describe('suggestUserProfiles', () => {
const abortCtrl = new AbortController();
const { http } = createStartServicesMock();
beforeEach(() => {
jest.clearAllMocks();
http.post = jest.fn().mockResolvedValue(userProfiles);
});
it('returns the user profiles correctly', async () => {
const res = await suggestUserProfiles({
http,
name: 'elastic',
owner: [GENERAL_CASES_OWNER],
signal: abortCtrl.signal,
});
expect(res).toEqual(userProfiles);
});
it('calls http.post correctly', async () => {
await suggestUserProfiles({
http,
name: 'elastic',
owner: [GENERAL_CASES_OWNER],
signal: abortCtrl.signal,
});
expect(http.post).toHaveBeenCalledWith('/internal/cases/_suggest_user_profiles', {
body: '{"name":"elastic","size":10,"owner":["cases"]}',
signal: abortCtrl.signal,
});
});
});
describe('bulkGetUserProfiles', () => {
const { security } = createStartServicesMock();
beforeEach(() => {
jest.clearAllMocks();
security.userProfiles.bulkGet = jest.fn().mockResolvedValue(userProfiles);
});
it('returns the user profiles correctly', async () => {
const res = await bulkGetUserProfiles({
security,
uids: userProfilesIds,
});
expect(res).toEqual(userProfiles);
});
it('calls http.post correctly', async () => {
await bulkGetUserProfiles({
security,
uids: userProfilesIds,
});
expect(security.userProfiles.bulkGet).toHaveBeenCalledWith({
dataPath: 'avatar',
uids: new Set([
'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
]),
});
});
});
});

View file

@ -0,0 +1,46 @@
/*
* 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 { HttpStart } from '@kbn/core/public';
import { UserProfile } from '@kbn/security-plugin/common';
import { SecurityPluginStart } from '@kbn/security-plugin/public';
import { INTERNAL_SUGGEST_USER_PROFILES_URL, DEFAULT_USER_SIZE } from '../../../common/constants';
export interface SuggestUserProfilesArgs {
http: HttpStart;
name: string;
owner: string[];
signal: AbortSignal;
size?: number;
}
export const suggestUserProfiles = async ({
http,
name,
size = DEFAULT_USER_SIZE,
owner,
signal,
}: SuggestUserProfilesArgs): Promise<UserProfile[]> => {
const response = await http.post<UserProfile[]>(INTERNAL_SUGGEST_USER_PROFILES_URL, {
body: JSON.stringify({ name, size, owner }),
signal,
});
return response;
};
export interface BulkGetUserProfilesArgs {
security: SecurityPluginStart;
uids: string[];
}
export const bulkGetUserProfiles = async ({
security,
uids,
}: BulkGetUserProfilesArgs): Promise<UserProfile[]> => {
return security.userProfiles.bulkGet({ uids: new Set(uids), dataPath: 'avatar' });
};

View file

@ -0,0 +1,66 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import { useToasts } from '../../common/lib/kibana';
import { AppMockRenderer, createAppMockRenderer } from '../../common/mock';
import * as api from './api';
import { useBulkGetUserProfiles } from './use_bulk_get_user_profiles';
import { userProfilesIds } from './api.mock';
jest.mock('../../common/lib/kibana');
jest.mock('./api');
describe('useBulkGetUserProfiles', () => {
const props = {
uids: userProfilesIds,
};
const addSuccess = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError: jest.fn() });
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
it('calls bulkGetUserProfiles with correct arguments', async () => {
const spyOnSuggestUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles');
const { result, waitFor } = renderHook(() => useBulkGetUserProfiles(props), {
wrapper: appMockRender.AppWrapper,
});
await waitFor(() => result.current.isSuccess);
expect(spyOnSuggestUserProfiles).toBeCalledWith({
...props,
security: expect.anything(),
});
});
it('shows a toast error message when an error occurs in the response', async () => {
const spyOnSuggestUserProfiles = jest.spyOn(api, 'bulkGetUserProfiles');
spyOnSuggestUserProfiles.mockImplementation(() => {
throw new Error('Something went wrong');
});
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
const { result, waitFor } = renderHook(() => useBulkGetUserProfiles(props), {
wrapper: appMockRender.AppWrapper,
});
await waitFor(() => result.current.isError);
expect(addError).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,41 @@
/*
* 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 { useQuery, UseQueryResult } from 'react-query';
import { UserProfile } from '@kbn/security-plugin/common';
import * as i18n from '../translations';
import { useKibana, useToasts } from '../../common/lib/kibana';
import { ServerError } from '../../types';
import { USER_PROFILES_CACHE_KEY, USER_PROFILES_BULK_GET_CACHE_KEY } from '../constants';
import { bulkGetUserProfiles } from './api';
export const useBulkGetUserProfiles = ({ uids }: { uids: string[] }) => {
const { security } = useKibana().services;
const toasts = useToasts();
return useQuery<UserProfile[], ServerError>(
[USER_PROFILES_CACHE_KEY, USER_PROFILES_BULK_GET_CACHE_KEY, uids],
() => {
return bulkGetUserProfiles({ security, uids });
},
{
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.ERROR_TITLE,
}
);
}
},
}
);
};
export type UseSuggestUserProfiles = UseQueryResult<UserProfile[], ServerError>;

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { GENERAL_CASES_OWNER } from '../../../common/constants';
import { renderHook } from '@testing-library/react-hooks';
import { useToasts } from '../../common/lib/kibana';
import { AppMockRenderer, createAppMockRenderer } from '../../common/mock';
import * as api from './api';
import { useSuggestUserProfiles } from './use_suggest_user_profiles';
jest.mock('../../common/lib/kibana');
jest.mock('./api');
describe('useSuggestUserProfiles', () => {
const props = {
name: 'elastic',
owner: [GENERAL_CASES_OWNER],
};
const addSuccess = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError: jest.fn() });
let appMockRender: AppMockRenderer;
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it('calls suggestUserProfiles with correct arguments', async () => {
const spyOnSuggestUserProfiles = jest.spyOn(api, 'suggestUserProfiles');
const { result, waitFor } = renderHook(() => useSuggestUserProfiles(props), {
wrapper: appMockRender.AppWrapper,
});
jest.advanceTimersByTime(500);
await waitFor(() => result.current.isSuccess);
expect(spyOnSuggestUserProfiles).toBeCalledWith({
...props,
size: 10,
http: expect.anything(),
signal: expect.anything(),
});
});
it('shows a toast error message when an error occurs in the response', async () => {
const spyOnSuggestUserProfiles = jest.spyOn(api, 'suggestUserProfiles');
spyOnSuggestUserProfiles.mockImplementation(() => {
throw new Error('Something went wrong');
});
const addError = jest.fn();
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
const { result, waitFor } = renderHook(() => useSuggestUserProfiles(props), {
wrapper: appMockRender.AppWrapper,
});
jest.advanceTimersByTime(500);
await waitFor(() => result.current.isError);
expect(addError).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { useState } from 'react';
import { useQuery, UseQueryResult } from 'react-query';
import useDebounce from 'react-use/lib/useDebounce';
import { UserProfile } from '@kbn/security-plugin/common';
import { DEFAULT_USER_SIZE } from '../../../common/constants';
import * as i18n from '../translations';
import { useKibana, useToasts } from '../../common/lib/kibana';
import { ServerError } from '../../types';
import { USER_PROFILES_CACHE_KEY, USER_PROFILES_SUGGEST_CACHE_KEY } from '../constants';
import { suggestUserProfiles, SuggestUserProfilesArgs } from './api';
const DEBOUNCE_MS = 500;
export const useSuggestUserProfiles = ({
name,
owner,
size = DEFAULT_USER_SIZE,
}: Omit<SuggestUserProfilesArgs, 'signal' | 'http'>) => {
const { http } = useKibana().services;
const [debouncedName, setDebouncedName] = useState(name);
useDebounce(() => setDebouncedName(name), DEBOUNCE_MS, [name]);
const toasts = useToasts();
return useQuery<UserProfile[], ServerError>(
[
USER_PROFILES_CACHE_KEY,
USER_PROFILES_SUGGEST_CACHE_KEY,
{ name: debouncedName, owner, size },
],
() => {
const abortCtrlRef = new AbortController();
return suggestUserProfiles({
http,
name: debouncedName,
owner,
size,
signal: abortCtrlRef.signal,
});
},
{
retry: false,
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{
title: i18n.ERROR_TITLE,
}
);
}
},
}
);
};
export type UseSuggestUserProfiles = UseQueryResult<UserProfile[], ServerError>;

View file

@ -15,7 +15,7 @@ import type { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import type { ManagementSetup, ManagementAppMountParams } from '@kbn/management-plugin/public';
import type { FeaturesPluginStart } from '@kbn/features-plugin/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { SecurityPluginSetup } from '@kbn/security-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public';
import type { DistributiveOmit } from '@elastic/eui';
@ -57,6 +57,7 @@ export interface CasesPluginStart {
storage: Storage;
triggersActionsUi: TriggersActionsStart;
features: FeaturesPluginStart;
security: SecurityPluginStart;
spaces?: SpacesPluginStart;
}
@ -66,10 +67,7 @@ export interface CasesPluginStart {
* Leaving it out currently in lieu of RBAC changes
*/
export type StartServices = CoreStart &
CasesPluginStart & {
security: SecurityPluginSetup;
};
export type StartServices = CoreStart & CasesPluginStart;
export interface RenderAppProps {
mountParams: ManagementAppMountParams;

View file

@ -31,7 +31,8 @@
"triggersActionsUi",
"inspector",
"unifiedSearch",
"sharedUX"
"sharedUX",
"security"
],
"ui": true,
"server": true,

View file

@ -37,6 +37,7 @@ import {
ActionTypeRegistryContract,
RuleTypeRegistryContract,
} from '@kbn/triggers-actions-ui-plugin/public';
import { SecurityPluginStart } from '@kbn/security-plugin/public';
import { observabilityAppId, observabilityFeatureId, casesPath } from '../common';
import { createLazyObservabilityPageTemplate } from './components/shared';
import { registerDataHandler } from './data_handler';
@ -51,6 +52,7 @@ import { getExploratoryViewEmbeddable } from './components/shared/exploratory_vi
import { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/exploratory_view_url';
import { createUseRulesLink } from './hooks/create_use_rules_link';
import getAppDataView from './utils/observability_data_views/get_app_data_view';
export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>;
export interface ObservabilityPublicPluginsSetup {
@ -73,6 +75,7 @@ export interface ObservabilityPublicPluginsStart {
sharedUX: SharedUXPluginStart;
ruleTypeRegistry: RuleTypeRegistryContract;
actionTypeRegistry: ActionTypeRegistryContract;
security: SecurityPluginStart;
}
export type ObservabilityPublicStart = ReturnType<Plugin['start']>;

View file

@ -137,7 +137,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
apm,
savedObjectsTagging: savedObjectsTaggingOss.getTaggingApi(),
storage: this.storage,
security: plugins.security,
security: startPluginsDeps.security,
};
return services;
})();

View file

@ -23,7 +23,7 @@ import type {
TriggersAndActionsUIPublicPluginStart as TriggersActionsStart,
} from '@kbn/triggers-actions-ui-plugin/public';
import type { CasesUiStart } from '@kbn/cases-plugin/public';
import type { SecurityPluginSetup } from '@kbn/security-plugin/public';
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import type { TimelinesUIStart } from '@kbn/timelines-plugin/public';
import type { SessionViewStart } from '@kbn/session-view-plugin/public';
import type { KubernetesSecurityStart } from '@kbn/kubernetes-security-plugin/public';
@ -87,7 +87,7 @@ export interface StartPlugins {
spaces?: SpacesPluginStart;
dataViewFieldEditor: IndexPatternFieldEditorStart;
osquery?: OsqueryPluginStart;
security: SecurityPluginSetup;
security: SecurityPluginStart;
cloudSecurityPosture: CspClientPluginStart;
threatIntelligence: ThreatIntelligencePluginStart;
}