[Security Solution] Implement Azure and Okta asset integration (user flyout) (#171629)

## Summary



b749781c-1941-40d5-8dc8-094659fba9e5

<img width="674" alt="Screenshot 2023-12-05 at 10 31 10"
src="dc0dc39e-6ac1-47e6-b608-ec6667be251b">


* Remove the `firstLastSeen` call from managed user data because it can
be inferred from the event timestamp.
* Updated the managed data API to return Okta and Azure data.
* Create a Flyout asset document details panel
* Create a cell action that add/remove fields from the asset table
* Persist selected field on the redux store
* Persist the selected fields on local storage
* [] TODO update query match to use e-mail field

### How to test it?

* Enable the experimental flag
`xpack.securitySolution.enableExperimental: ['newUserDetailsFlyout']`
* Start an elastic cluster with fleet and elastic agent
  * Follow this steps to setup a cluster with elastic-package
  * Install https://github.com/elastic/elastic-package 
  * `elastic-package stack up -vd --version 8.12.0-SNAPSHOT`
  * connect your local kibana instance to the cluster ES instance
* configured `server.port`, `elasticsearch.hosts`,
`elasticsearch.ssl.certificateAuthorities`,
`elasticsearch.serviceAccountToken`
* Install Okta and Entra integrations (ask @machadoum for credentials)
* Create a rule that generates alerts for every event
* Go to the alerts table and click on the username to open the flyout



**Tip:** You can open your Docker application and explore the files to
copy the token and the certificate.

<img width="1420" alt="Screenshot 2023-12-04 at 16 29 53"
src="60032e34-6f50-4316-ad88-2a13109a5622">


### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [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
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
This commit is contained in:
Pablo Machado 2023-12-05 20:52:48 +01:00 committed by GitHub
parent 1f35f23a5d
commit d922ae06ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2631 additions and 889 deletions

View file

@ -12,6 +12,7 @@ import { requestBasicOptionsSchema } from '../model/request_basic_options';
export const managedUserDetailsSchema = requestBasicOptionsSchema.extend({
userName: z.string(),
userEmail: z.array(z.string()).optional(),
factoryQueryType: z.literal(UsersQueries.managedDetails),
});

View file

@ -6,21 +6,25 @@
*/
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import type { EcsBase, EcsEvent, EcsHost, EcsUser, EcsAgent } from '@kbn/ecs';
import type { SearchTypes } from '../../../../detection_engine/types';
import type { Inspect, Maybe } from '../../../common';
export interface ManagedUserDetailsStrategyResponse extends IEsSearchResponse {
userDetails?: AzureManagedUser;
users: ManagedUserHits;
inspect?: Maybe<Inspect>;
}
export interface AzureManagedUser extends Pick<EcsBase, '@timestamp'> {
agent: EcsAgent;
host: EcsHost;
event: EcsEvent;
user: EcsUser & {
last_name?: string;
first_name?: string;
phone?: string[];
};
export enum ManagedUserDatasetKey {
ENTRA = 'entityanalytics_entra_id.user',
OKTA = 'entityanalytics_okta.user',
}
export interface ManagedUserHit {
_index: string;
_id: string;
fields?: ManagedUserFields;
}
export type ManagedUserHits = Record<ManagedUserDatasetKey, ManagedUserHit | undefined>;
export type ManagedUserFields = Record<string, SearchTypes[]>;

View file

@ -29,6 +29,7 @@ import {
createCopyToClipboardDiscoverCellActionFactory,
} from './copy_to_clipboard';
import { createToggleColumnCellActionFactory } from './toggle_column';
import { createToggleUserAssetFieldCellActionFactory } from './toggle_asset_column';
import { SecurityCellActionsTrigger } from './constants';
import type {
DiscoverCellActionName,
@ -117,6 +118,7 @@ const registerCellActions = (
showTopN: createShowTopNCellActionFactory({ services }),
copyToClipboard: createCopyToClipboardCellActionFactory({ services }),
toggleColumn: createToggleColumnCellActionFactory({ store, services }),
toggleUserAssetField: createToggleUserAssetFieldCellActionFactory({ store }),
};
const registerCellActionsTrigger = (
@ -147,6 +149,7 @@ const registerCellActions = (
'filterOut',
'addToTimeline',
'toggleColumn',
'toggleUserAssetField',
'showTopN',
'copyToClipboard',
]);

View file

@ -0,0 +1,137 @@
/*
* 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 type { CellActionExecutionContext } from '@kbn/cell-actions';
import { createToggleUserAssetFieldCellActionFactory } from './toggle_asset_column';
import type { SecurityAppStore } from '../../../common/store/types';
import { mockGlobalState } from '../../../common/mock';
import { UserAssetTableType } from '../../../explore/users/store/model';
import { usersActions } from '../../../explore/users/store';
const existingFieldName = 'existing.field';
const fieldName = 'user.name';
const mockToggleColumn = jest.fn();
const mockDispatch = jest.fn();
const mockGetState = jest.fn().mockReturnValue({
...mockGlobalState,
users: {
...mockGlobalState.users,
flyout: {
...mockGlobalState.users.flyout,
queries: {
[UserAssetTableType.assetEntra]: {
fields: [existingFieldName],
},
[UserAssetTableType.assetOkta]: {
fields: [existingFieldName],
},
},
},
},
});
const store = {
dispatch: mockDispatch,
getState: mockGetState,
} as unknown as SecurityAppStore;
const context = {
data: [
{
field: { name: fieldName },
},
],
metadata: {
scopeId: UserAssetTableType.assetEntra,
},
} as unknown as CellActionExecutionContext;
describe('createToggleUserAssetFieldCellActionFactory', () => {
const toggleColumnActionFactory = createToggleUserAssetFieldCellActionFactory({
store,
});
const toggleColumnAction = toggleColumnActionFactory({ id: 'testAction' });
beforeEach(() => {
jest.clearAllMocks();
});
it('should return display name', () => {
expect(toggleColumnAction.getDisplayName(context)).toEqual('Toggle field in asset table');
});
it('should return icon type', () => {
expect(toggleColumnAction.getIconType(context)).toEqual('listAdd');
});
describe('isCompatible', () => {
it('should return false if scopeId is undefined', async () => {
expect(
await toggleColumnAction.isCompatible({ ...context, metadata: { scopeId: undefined } })
).toEqual(false);
});
it('should return false if scopeId is different than Okta or Entra asset table', async () => {
expect(
await toggleColumnAction.isCompatible({
...context,
metadata: { scopeId: 'test-scopeId-1234' },
})
).toEqual(false);
});
it('should return true if scopeId is okta or entra asset table', async () => {
expect(
await toggleColumnAction.isCompatible({
...context,
metadata: { scopeId: UserAssetTableType.assetEntra },
})
).toEqual(true);
expect(
await toggleColumnAction.isCompatible({
...context,
metadata: { scopeId: UserAssetTableType.assetOkta },
})
).toEqual(true);
});
});
describe('execute', () => {
afterEach(() => {
mockToggleColumn.mockClear();
});
it('should remove field', async () => {
await toggleColumnAction.execute({
...context,
data: [
{ ...context.data[0], field: { ...context.data[0].field, name: existingFieldName } },
],
});
expect(mockDispatch).toHaveBeenCalledWith(
usersActions.removeUserAssetTableField({
tableId: UserAssetTableType.assetEntra,
fieldName: existingFieldName,
})
);
});
it('should add field', async () => {
const name = 'new-field-name';
await toggleColumnAction.execute({
...context,
data: [{ ...context.data[0], field: { ...context.data[0].field, name } }],
});
expect(mockDispatch).toHaveBeenCalledWith(
usersActions.addUserAssetTableField({
tableId: UserAssetTableType.assetEntra,
fieldName: name,
})
);
});
});
});

View file

@ -0,0 +1,69 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { createCellActionFactory, type CellActionTemplate } from '@kbn/cell-actions';
import { UserAssetTableType } from '../../../explore/users/store/model';
import { usersActions, usersSelectors } from '../../../explore/users/store';
import { fieldHasCellActions } from '../../utils';
import type { SecurityAppStore } from '../../../common/store';
import type { SecurityCellAction } from '../../types';
import { SecurityCellActionType } from '../../constants';
const ICON = 'listAdd';
const TOGGLE_FIELD = i18n.translate(
'xpack.securitySolution.actions.toggleFieldToAssetTable.label',
{
defaultMessage: 'Toggle field in asset table',
}
);
export const createToggleUserAssetFieldCellActionFactory = createCellActionFactory(
({ store }: { store: SecurityAppStore }): CellActionTemplate<SecurityCellAction> => ({
type: SecurityCellActionType.TOGGLE_COLUMN,
getIconType: () => ICON,
getDisplayName: () => TOGGLE_FIELD,
getDisplayNameTooltip: ({ data }) => TOGGLE_FIELD,
isCompatible: async ({ data, metadata }) => {
const field = data[0]?.field;
return (
data.length === 1 &&
fieldHasCellActions(field.name) &&
!metadata?.isObjectArray &&
!!metadata?.scopeId &&
Object.values(UserAssetTableType).includes(
metadata?.scopeId as unknown as UserAssetTableType
)
);
},
execute: async ({ metadata, data }) => {
const field = data[0]?.field;
const scopeId = metadata?.scopeId as UserAssetTableType | undefined;
if (!scopeId) return;
const { fields } = usersSelectors.selectUserAssetTableById(store.getState(), scopeId);
if (fields.some((f) => f === field.name)) {
store.dispatch(
usersActions.removeUserAssetTableField({
fieldName: field.name,
tableId: scopeId,
})
);
} else {
store.dispatch(
usersActions.addUserAssetTableField({
fieldName: field.name,
tableId: scopeId,
})
);
}
},
})
);

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { createToggleUserAssetFieldCellActionFactory } from './cell_action/toggle_asset_column';

View file

@ -62,6 +62,7 @@ export interface SecurityCellActions {
showTopN: CellActionFactory;
copyToClipboard: CellActionFactory;
toggleColumn: CellActionFactory;
toggleUserAssetField: CellActionFactory;
}
// All security cell actions names

View file

@ -257,6 +257,16 @@ export const mockGlobalState: State = {
[usersModel.UsersTableType.events]: { activePage: 0, limit: 10 },
},
},
flyout: {
queries: {
[usersModel.UserAssetTableType.assetEntra]: {
fields: [],
},
[usersModel.UserAssetTableType.assetOkta]: {
fields: [],
},
},
},
},
inputs: {
global: {

View file

@ -7,11 +7,12 @@
import { hostsReducer } from '../../explore/hosts/store';
import { networkReducer } from '../../explore/network/store';
import { usersReducer } from '../../explore/users/store';
import { makeUsersReducer } from '../../explore/users/store';
import { timelineReducer } from '../../timelines/store/timeline/reducer';
import { managementReducer } from '../../management/store/reducer';
import type { ManagementPluginReducer } from '../../management';
import type { SubPluginsInitReducer } from '../store';
import { createSecuritySolutionStorageMock } from './mock_local_storage';
type GlobalThis = typeof globalThis;
interface Global extends GlobalThis {
@ -23,10 +24,12 @@ interface Global extends GlobalThis {
export const globalNode: Global = global;
const { storage } = createSecuritySolutionStorageMock();
export const SUB_PLUGINS_REDUCER: SubPluginsInitReducer = {
hosts: hostsReducer,
network: networkReducer,
users: usersReducer,
users: makeUsersReducer(storage),
timeline: timelineReducer,
/**
* These state's are wrapped in `Immutable`, but for compatibility with the overall app architecture,

View file

@ -9,6 +9,9 @@ import type { Epic } from 'redux-observable';
import { combineEpics } from 'redux-observable';
import type { Action } from 'redux';
import type { Observable } from 'rxjs';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import { createTimelineEpic } from '../../timelines/store/timeline/epic';
import { createTimelineChangedEpic } from '../../timelines/store/timeline/epic_changed';
import { createTimelineFavoriteEpic } from '../../timelines/store/timeline/epic_favorite';
@ -16,18 +19,26 @@ import { createTimelineNoteEpic } from '../../timelines/store/timeline/epic_note
import { createTimelinePinnedEventEpic } from '../../timelines/store/timeline/epic_pinned_event';
import type { TimelineEpicDependencies } from '../../timelines/store/timeline/types';
import { createDataTableLocalStorageEpic } from './data_table/epic_local_storage';
import { createUserAssetTableLocalStorageEpic } from '../../explore/users/store/epic_storage';
import type { State } from './types';
export const createRootEpic = <State>(): Epic<
export interface RootEpicDependencies {
kibana$: Observable<CoreStart>;
storage: Storage;
}
export const createRootEpic = <StateT extends State>(): Epic<
Action,
Action,
State,
TimelineEpicDependencies<State>
StateT,
TimelineEpicDependencies<StateT>
> =>
combineEpics(
createTimelineEpic<State>(),
createTimelineEpic<StateT>(),
createTimelineChangedEpic(),
createTimelineFavoriteEpic<State>(),
createTimelineNoteEpic<State>(),
createTimelinePinnedEventEpic<State>(),
createDataTableLocalStorageEpic<State>()
createTimelineFavoriteEpic<StateT>(),
createTimelineNoteEpic<StateT>(),
createTimelinePinnedEventEpic<StateT>(),
createDataTableLocalStorageEpic<StateT>(),
createUserAssetTableLocalStorageEpic<StateT>()
);

View file

@ -169,6 +169,7 @@ export const createStoreFactory = async (
return createStore(initialState, rootReducer, libs$.pipe(pluck('kibana')), storage, [
...(subPlugins.management.store.middleware ?? []),
...(subPlugins.explore.store.middleware ?? []),
...[resolverMiddlewareFactory(dataAccessLayerFactory(coreStart)) ?? []],
]);
};

View file

@ -15,7 +15,7 @@ import { routes } from './routes';
import type { NetworkState } from './network/store';
import { initialNetworkState, networkReducer } from './network/store';
import { getDataTablesInStorageByIds } from '../timelines/containers/local_storage';
import { initialUsersState, usersReducer } from './users/store';
import { makeUsersReducer, getInitialUsersState } from './users/store';
import { hostsReducer, initialHostsState } from './hosts/store';
export interface ExploreState {
@ -51,10 +51,10 @@ export class Explore {
store: {
initialState: {
network: initialNetworkState,
users: initialUsersState,
users: getInitialUsersState(storage),
hosts: initialHostsState,
},
reducer: { network: networkReducer, users: usersReducer, hosts: hostsReducer },
reducer: { network: networkReducer, users: makeUsersReducer(storage), hosts: hostsReducer },
},
};
}

View file

@ -9,6 +9,7 @@ import actionCreatorFactory from 'typescript-fsa';
import type { usersModel } from '.';
import type { RiskScoreSortField, RiskSeverity } from '../../../../common/search_strategy';
import type { SortUsersField } from '../../../../common/search_strategy/security_solution/users/common';
import type { UserAssetTableType } from './model';
const actionCreator = actionCreatorFactory('x-pack/security_solution/local/users');
@ -48,3 +49,13 @@ export const updateUsersAnomaliesInterval = actionCreator<{
interval: string;
usersType: usersModel.UsersType;
}>('UPDATE_USERS_ANOMALIES_INTERVAL');
export const addUserAssetTableField = actionCreator<{
fieldName: string;
tableId: UserAssetTableType;
}>('ADD_USER_ASSET_TABLE_FIELD');
export const removeUserAssetTableField = actionCreator<{
fieldName: string;
tableId: UserAssetTableType;
}>('REMOVE_USER_ASSET_TABLE_FIELD');

View file

@ -0,0 +1,29 @@
/*
* 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 { UserAssetTableType } from './model';
export const USER_ASSET_TABLE_DEFAULTS_FIELDS = {
[UserAssetTableType.assetOkta]: [
'user.id',
'user.profile.first_name',
'user.profile.last_name',
'user.profile.primaryPhone',
'user.profile.mobile_phone',
'user.profile.job_title',
'user.geo.city_name',
'user.geo.country_iso_code',
],
[UserAssetTableType.assetEntra]: [
'user.id',
'user.first_name',
'user.last_name',
'user.phone',
'user.job_title',
'user.work.location_name',
],
};

View file

@ -0,0 +1,54 @@
/*
* 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 { waitFor } from '@testing-library/react';
import {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
} from '../../../common/mock';
import { createStore } from '../../../common/store';
import { addUserAssetTableField, removeUserAssetTableField } from './actions';
import { UserAssetTableType } from './model';
import { getUserAssetTableFromStorage } from './storage';
import type { Store } from 'redux';
let store: Store;
const storage = createSecuritySolutionStorageMock().storage;
describe('UsersAssetTable EpicStorage', () => {
beforeEach(() => {
jest.useFakeTimers();
storage.clear();
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
});
it('persist asset table when adding and removing fields', async () => {
const fieldName = 'test-field';
// Add field to the table
store.dispatch(addUserAssetTableField({ tableId: UserAssetTableType.assetEntra, fieldName }));
await waitFor(() => {
return expect(getUserAssetTableFromStorage(storage)).toEqual({
[UserAssetTableType.assetEntra]: { fields: [fieldName] },
});
});
jest.runAllTimers(); // pass the time to ensure that the state is persisted to local storage
// Remove field from the table
store.dispatch(
removeUserAssetTableField({ tableId: UserAssetTableType.assetEntra, fieldName })
);
await waitFor(() => {
return expect(getUserAssetTableFromStorage(storage)).toEqual({
[UserAssetTableType.assetEntra]: { fields: [] },
});
});
});
});

View file

@ -0,0 +1,38 @@
/*
* 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 type { Action } from 'redux';
import { map, filter, ignoreElements, tap, withLatestFrom, delay } from 'rxjs/operators';
import type { Epic } from 'redux-observable';
import { get } from 'lodash/fp';
import type { RootEpicDependencies } from '../../../common/store/epic';
import { usersActions } from '.';
import { selectUserAssetTables } from './selectors';
import type { UserAssetTableType } from './model';
import type { State } from '../../../common/store/types';
export const isNotNull = <T>(value: T | null): value is T => value !== null;
import { persistUserAssetTableInStorage } from './storage';
const { removeUserAssetTableField, addUserAssetTableField } = usersActions;
const tableActionTypes = new Set([removeUserAssetTableField.type, addUserAssetTableField.type]);
export const createUserAssetTableLocalStorageEpic =
<StateT extends State>(): Epic<Action, Action, StateT, RootEpicDependencies> =>
(action$, state$, { storage }) => {
const table$ = state$.pipe(map(selectUserAssetTables), filter(isNotNull));
return action$.pipe(
delay(500),
withLatestFrom(table$),
tap(([action, tableById]) => {
if (tableActionTypes.has(action.type)) {
const tableId: UserAssetTableType = get('payload.tableId', action);
persistUserAssetTableInStorage(storage, tableId, tableById[tableId]);
}
}),
ignoreElements()
);
};

View file

@ -0,0 +1,68 @@
/*
* 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 { addAssetTableField, removeAssetTableField } from './helpers';
import { UserAssetTableType } from './model';
describe('Users store helpers', () => {
const fieldName = 'test-field-name';
const emptyTableById = {
[UserAssetTableType.assetEntra]: {
fields: [],
},
[UserAssetTableType.assetOkta]: {
fields: [],
},
};
test('addAssetTableField', () => {
expect(
addAssetTableField({
tableById: emptyTableById,
tableId: UserAssetTableType.assetEntra,
fieldName,
})
).toEqual({
...emptyTableById,
[UserAssetTableType.assetEntra]: {
fields: [fieldName],
},
});
});
test('addAssetTableField does not add field if it already exists', () => {
const tableById = {
...emptyTableById,
[UserAssetTableType.assetEntra]: {
fields: [fieldName],
},
};
expect(
addAssetTableField({
tableById,
tableId: UserAssetTableType.assetEntra,
fieldName,
})
).toBe(tableById);
});
test('removeAssetTableField', () => {
expect(
removeAssetTableField({
tableById: {
...emptyTableById,
[UserAssetTableType.assetEntra]: {
fields: [fieldName],
},
},
tableId: UserAssetTableType.assetEntra,
fieldName,
})
).toEqual(emptyTableById);
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { UsersModel, UsersQueries } from './model';
import type { UserFlyoutQueries, UserAssetTableType, UsersModel, UsersQueries } from './model';
import { UsersTableType } from './model';
import { DEFAULT_TABLE_ACTIVE_PAGE } from '../../../common/store/constants';
@ -16,3 +16,54 @@ export const setUsersPageQueriesActivePageToZero = (state: UsersModel): UsersQue
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
},
});
interface UpsertAssetTableFieldParams {
fieldName: string;
tableId: UserAssetTableType;
tableById: UserFlyoutQueries;
}
export const addAssetTableField = ({
tableById,
tableId,
fieldName,
}: UpsertAssetTableFieldParams): UserFlyoutQueries => {
const table = tableById[tableId];
if (table.fields.includes(fieldName)) {
// Do not add the field if it already exists
return tableById;
}
return {
...tableById,
[tableId]: {
...table,
fields: [fieldName, ...table.fields],
},
};
};
interface RemoveAssetTableFieldParams {
tableId: UserAssetTableType;
fieldName: string;
tableById: UserFlyoutQueries;
}
export const removeAssetTableField = ({
tableId,
fieldName,
tableById,
}: RemoveAssetTableFieldParams): UserFlyoutQueries => {
const table = tableById[tableId];
const fields = table.fields.filter((c) => c !== fieldName);
return {
...tableById,
[tableId]: {
...table,
fields,
},
};
};

View file

@ -11,6 +11,7 @@ import type { SortUsersField } from '../../../../common/search_strategy/security
export enum UsersType {
page = 'page',
details = 'details',
flyout = 'flyout',
}
export enum UsersTableType {
@ -28,6 +29,11 @@ export enum UsersDetailsTableType {
events = 'events',
}
export enum UserAssetTableType {
assetEntra = 'userAssetEntra',
assetOkta = 'userAssetOkta',
}
export interface BasicQueryPaginated {
activePage: number;
limit: number;
@ -47,6 +53,10 @@ export interface UsersAnomaliesQuery {
intervalSelection: string;
}
export interface UserAssetQuery {
fields: string[];
}
export interface UsersQueries {
[UsersTableType.allUsers]: AllUsersQuery;
[UsersTableType.authentications]: BasicQueryPaginated;
@ -60,6 +70,11 @@ export interface UserDetailsQueries {
[UsersTableType.events]: BasicQueryPaginated;
}
export interface UserFlyoutQueries {
[UserAssetTableType.assetEntra]: UserAssetQuery;
[UserAssetTableType.assetOkta]: UserAssetQuery;
}
export interface UsersPageModel {
queries: UsersQueries;
}
@ -68,7 +83,12 @@ export interface UserDetailsPageModel {
queries: UserDetailsQueries;
}
export interface UserFlyoutModel {
queries: UserFlyoutQueries;
}
export interface UsersModel {
[UsersType.page]: UsersPageModel;
[UsersType.details]: UserDetailsPageModel;
[UsersType.flyout]: UserFlyoutModel;
}

View file

@ -7,9 +7,11 @@
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import { set } from '@kbn/safer-lodash-set/fp';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../../common/store/constants';
import {
removeUserAssetTableField,
setUsersTablesActivePageToZero,
updateTableActivePage,
updateTableLimit,
@ -17,138 +19,184 @@ import {
updateUserRiskScoreSeverityFilter,
updateUsersAnomaliesInterval,
updateUsersAnomaliesJobIdFilter,
addUserAssetTableField,
} from './actions';
import { setUsersPageQueriesActivePageToZero } from './helpers';
import {
addAssetTableField,
removeAssetTableField,
setUsersPageQueriesActivePageToZero,
} from './helpers';
import type { UsersModel } from './model';
import { UsersTableType } from './model';
import { UserAssetTableType, UsersTableType } from './model';
import { Direction } from '../../../../common/search_strategy/common';
import { RiskScoreFields } from '../../../../common/search_strategy';
import { UsersFields } from '../../../../common/search_strategy/security_solution/users/common';
import { USER_ASSET_TABLE_DEFAULTS_FIELDS } from './constants';
import { getUserAssetTableFromStorage } from './storage';
export type UsersState = UsersModel;
export const initialUsersState: UsersState = {
page: {
queries: {
[UsersTableType.allUsers]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
sort: {
field: UsersFields.lastSeen,
direction: Direction.desc,
export const getInitialUsersState = (storage: Storage): UsersState => {
const userAssetTable = getUserAssetTableFromStorage(storage);
return {
page: {
queries: {
[UsersTableType.allUsers]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
sort: {
field: UsersFields.lastSeen,
direction: Direction.desc,
},
},
[UsersTableType.authentications]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
[UsersTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
sort: {
field: RiskScoreFields.userRiskScore,
direction: Direction.desc,
},
severitySelection: [],
},
[UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[UsersTableType.events]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
},
[UsersTableType.authentications]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
[UsersTableType.risk]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
sort: {
field: RiskScoreFields.userRiskScore,
direction: Direction.desc,
},
details: {
queries: {
[UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[UsersTableType.events]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
severitySelection: [],
},
[UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[UsersTableType.events]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
},
},
},
details: {
queries: {
[UsersTableType.anomalies]: {
jobIdSelection: [],
intervalSelection: 'auto',
},
[UsersTableType.events]: {
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
limit: DEFAULT_TABLE_LIMIT,
flyout: {
queries: {
[UserAssetTableType.assetEntra]: (userAssetTable &&
userAssetTable[UserAssetTableType.assetEntra]) ?? {
fields: USER_ASSET_TABLE_DEFAULTS_FIELDS[UserAssetTableType.assetEntra],
},
[UserAssetTableType.assetOkta]: (userAssetTable &&
userAssetTable[UserAssetTableType.assetOkta]) ?? {
fields: USER_ASSET_TABLE_DEFAULTS_FIELDS[UserAssetTableType.assetOkta],
},
},
},
},
};
};
export const usersReducer = reducerWithInitialState(initialUsersState)
.case(setUsersTablesActivePageToZero, (state) => ({
...state,
page: {
...state.page,
queries: setUsersPageQueriesActivePageToZero(state),
},
}))
.case(updateTableActivePage, (state, { activePage, tableType }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[tableType]: {
...state.page.queries[tableType],
activePage,
export const makeUsersReducer = (storage: Storage) =>
reducerWithInitialState(getInitialUsersState(storage))
.case(setUsersTablesActivePageToZero, (state) => ({
...state,
page: {
...state.page,
queries: setUsersPageQueriesActivePageToZero(state),
},
}))
.case(updateTableActivePage, (state, { activePage, tableType }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[tableType]: {
...state.page.queries[tableType],
activePage,
},
},
},
},
}))
.case(updateTableLimit, (state, { limit, tableType }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[tableType]: {
...state.page.queries[tableType],
limit,
}))
.case(updateTableLimit, (state, { limit, tableType }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[tableType]: {
...state.page.queries[tableType],
limit,
},
},
},
},
}))
.case(updateTableSorting, (state, { sort, tableType }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[tableType]: {
...state.page.queries[tableType],
sort,
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
}))
.case(updateTableSorting, (state, { sort, tableType }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[tableType]: {
...state.page.queries[tableType],
sort,
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
},
},
},
},
}))
.case(updateUserRiskScoreSeverityFilter, (state, { severitySelection }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[UsersTableType.risk]: {
...state.page.queries[UsersTableType.risk],
severitySelection,
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
}))
.case(updateUserRiskScoreSeverityFilter, (state, { severitySelection }) => ({
...state,
page: {
...state.page,
queries: {
...state.page.queries,
[UsersTableType.risk]: {
...state.page.queries[UsersTableType.risk],
severitySelection,
activePage: DEFAULT_TABLE_ACTIVE_PAGE,
},
},
},
},
}))
.case(updateUsersAnomaliesJobIdFilter, (state, { jobIds, usersType }) => {
if (usersType === 'page') {
return set('page.queries.anomalies.jobIdSelection', jobIds, state);
} else {
return set('details.queries.anomalies.jobIdSelection', jobIds, state);
}
})
.case(updateUsersAnomaliesInterval, (state, { interval, usersType }) => {
if (usersType === 'page') {
return set('page.queries.anomalies.intervalSelection', interval, state);
} else {
return set('details.queries.anomalies.intervalSelection', interval, state);
}
})
.build();
}))
.case(updateUsersAnomaliesJobIdFilter, (state, { jobIds, usersType }) => {
if (usersType === 'page') {
return set('page.queries.anomalies.jobIdSelection', jobIds, state);
} else {
return set('details.queries.anomalies.jobIdSelection', jobIds, state);
}
})
.case(updateUsersAnomaliesInterval, (state, { interval, usersType }) => {
if (usersType === 'page') {
return set('page.queries.anomalies.intervalSelection', interval, state);
} else {
return set('details.queries.anomalies.intervalSelection', interval, state);
}
})
.case(addUserAssetTableField, (state, { fieldName, tableId }) => ({
...state,
flyout: {
...state.flyout,
queries: addAssetTableField({
fieldName,
tableId,
tableById: state.flyout.queries,
}),
},
}))
.case(removeUserAssetTableField, (state, { fieldName, tableId }) => ({
...state,
flyout: {
...state.flyout,
queries: removeAssetTableField({
tableId,
fieldName,
tableById: state.flyout.queries,
}),
},
}))
.build();

View file

@ -0,0 +1,21 @@
/*
* 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 { mockGlobalState } from '../../../common/mock';
import { UserAssetTableType } from './model';
import { selectUserAssetTableById } from './selectors';
describe('Users store selector', () => {
test('selectUserAssetTableById', () => {
expect(selectUserAssetTableById(mockGlobalState, UserAssetTableType.assetOkta)).toBe(
mockGlobalState.users.flyout.queries[UserAssetTableType.assetOkta]
);
expect(selectUserAssetTableById(mockGlobalState, UserAssetTableType.assetEntra)).toBe(
mockGlobalState.users.flyout.queries[UserAssetTableType.assetEntra]
);
});
});

View file

@ -9,13 +9,26 @@ import { createSelector } from 'reselect';
import type { State } from '../../../common/store/types';
import type { UserDetailsPageModel, UsersPageModel, UsersType } from './model';
import { UsersTableType } from './model';
import type {
UserAssetQuery,
UserDetailsPageModel,
UserFlyoutQueries,
UserAssetTableType,
UsersPageModel,
} from './model';
import { UsersTableType, UsersType } from './model';
const selectUserPage = (state: State): UsersPageModel => state.users.page;
const selectUsers = (state: State, usersType: UsersType): UsersPageModel | UserDetailsPageModel =>
state.users[usersType];
const selectUsersAndDetailsPage = (
state: State,
usersType: UsersType
): UsersPageModel | UserDetailsPageModel | null => {
if (usersType === UsersType.details || usersType === UsersType.page) {
return state.users[usersType];
}
return null;
};
export const allUsersSelector = () =>
createSelector(selectUserPage, (users) => users.queries[UsersTableType.allUsers]);
@ -30,7 +43,21 @@ export const authenticationsSelector = () =>
createSelector(selectUserPage, (users) => users.queries[UsersTableType.authentications]);
export const usersAnomaliesJobIdFilterSelector = () =>
createSelector(selectUsers, (users) => users.queries[UsersTableType.anomalies].jobIdSelection);
createSelector(
selectUsersAndDetailsPage,
(users) => users?.queries[UsersTableType.anomalies].jobIdSelection ?? []
);
export const usersAnomaliesIntervalSelector = () =>
createSelector(selectUsers, (users) => users.queries[UsersTableType.anomalies].intervalSelection);
createSelector(
selectUsersAndDetailsPage,
(users) => users?.queries[UsersTableType.anomalies].intervalSelection ?? 'auto'
);
export const selectUserAssetTableById = (
state: State,
tableId: UserAssetTableType
): UserAssetQuery => state.users.flyout.queries[tableId];
export const selectUserAssetTables = (state: State): UserFlyoutQueries =>
state.users.flyout.queries;

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { UserAssetQuery, UserAssetTableType } from './model';
export const LOCAL_STORAGE_KEY = 'securityUserFlyoutAssetTable';
export const getUserAssetTableFromStorage = (storage: Storage) => storage.get(LOCAL_STORAGE_KEY);
export const persistUserAssetTableInStorage = (
storage: Storage,
id: UserAssetTableType,
table: UserAssetQuery
) => {
const tables = storage.get(LOCAL_STORAGE_KEY);
storage.set(LOCAL_STORAGE_KEY, {
...tables,
[id]: table,
});
};

View file

@ -11,7 +11,6 @@ import { FLYOUT_BODY_TEST_ID } from './test_ids';
import type { RightPanelPaths } from '.';
import type { RightPanelTabsType } from './tabs';
import { FlyoutBody } from '../../shared/components/flyout_body';
import {} from './tabs';
export interface PanelContentProps {
/**

View file

@ -0,0 +1,74 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import { AssetDocumentLeftPanel } from '.';
import { JSON_TAB_TEST_ID, TABLE_TAB_TEST_ID } from './test_ids';
import { RightPanelContext } from '../../document_details/right/context';
import { mockContextValue } from '../../document_details/right/mocks/mock_context';
describe('<AssetDocumentLeftPanel />', () => {
it('renders', () => {
const { getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={mockContextValue}>
<AssetDocumentLeftPanel />
</RightPanelContext.Provider>
</TestProviders>
);
expect(getByTestId(TABLE_TAB_TEST_ID)).toBeInTheDocument();
expect(getByTestId(JSON_TAB_TEST_ID)).toBeInTheDocument();
});
it('should preselect the table tab', () => {
const { getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={mockContextValue}>
<AssetDocumentLeftPanel />
</RightPanelContext.Provider>
</TestProviders>
);
expect(getByTestId('securitySolutionFlyoutAssetTableTab')).toHaveAttribute(
'aria-selected',
'true'
);
});
it('should select json tab when path tab is json', () => {
const { getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={mockContextValue}>
<AssetDocumentLeftPanel path={{ tab: 'json' }} />
</RightPanelContext.Provider>
</TestProviders>
);
expect(getByTestId('securitySolutionFlyoutAssetJsonTab')).toHaveAttribute(
'aria-selected',
'true'
);
});
it('should select table tab when path tab is table', () => {
const { getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={mockContextValue}>
<AssetDocumentLeftPanel path={{ tab: 'table' }} />
</RightPanelContext.Provider>
</TestProviders>
);
expect(getByTestId('securitySolutionFlyoutAssetTableTab')).toHaveAttribute(
'aria-selected',
'true'
);
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 type { FC } from 'react';
import React, { memo, useMemo } from 'react';
import type { FlyoutPanelProps, PanelPath } from '@kbn/expandable-flyout';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { FormattedMessage } from '@kbn/i18n-react';
import { useRightPanelContext } from '../../document_details/right/context';
import { PanelHeader } from '../../document_details/right/header';
import type { RightPanelTabsType } from '../../document_details/right/tabs';
import { PanelContent } from '../../document_details/right/content';
import { JsonTab } from '../../document_details/right/tabs/json_tab';
import { TableTab } from '../../document_details/right/tabs/table_tab';
import { JSON_TAB_TEST_ID, TABLE_TAB_TEST_ID } from './test_ids';
export type RightPanelPaths = 'overview' | 'table' | 'json';
export const AssetDocumentLeftPanelKey: AssetDocumentLeftPanelProps['key'] =
'asset-document-details-left';
export interface AssetDocumentLeftPanelProps extends FlyoutPanelProps {
key: 'asset-document-details-left';
path?: PanelPath;
params?: {
id: string;
indexName: string;
scopeId: string;
};
}
const tabs: RightPanelTabsType = [
{
id: 'table',
'data-test-subj': TABLE_TAB_TEST_ID,
name: (
<FormattedMessage
id="xpack.securitySolution.flyout.left.header.tableTabLabel"
defaultMessage="Table"
/>
),
content: <TableTab />,
},
{
id: 'json',
'data-test-subj': JSON_TAB_TEST_ID,
name: (
<FormattedMessage
id="xpack.securitySolution.flyout.left.header.jsonTabLabel"
defaultMessage="JSON"
/>
),
content: <JsonTab />,
},
];
export const AssetDocumentLeftPanel: FC<Partial<AssetDocumentLeftPanelProps>> = memo(({ path }) => {
const { openLeftPanel } = useExpandableFlyoutContext();
const { eventId, indexName, scopeId } = useRightPanelContext();
const selectedTabId = useMemo(() => {
const defaultTab = tabs[0].id;
if (!path) return defaultTab;
return tabs.map((tab) => tab.id).find((tabId) => tabId === path.tab) ?? defaultTab;
}, [path]);
const setSelectedTabId = (tabId: string) => {
openLeftPanel({
id: AssetDocumentLeftPanelKey,
path: {
tab: tabId,
},
params: {
id: eventId,
indexName,
scopeId,
},
});
};
return (
<>
<PanelHeader tabs={tabs} selectedTabId={selectedTabId} setSelectedTabId={setSelectedTabId} />
<PanelContent tabs={tabs} selectedTabId={selectedTabId} />
</>
);
});
AssetDocumentLeftPanel.displayName = 'AssetDocumentLeftPanel';

View file

@ -0,0 +1,11 @@
/*
* 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 { PREFIX } from '../../shared/test_ids';
export const TABLE_TAB_TEST_ID = `${PREFIX}AssetTableTab` as const;
export const JSON_TAB_TEST_ID = `${PREFIX}AssetJsonTab` as const;

View file

@ -28,7 +28,7 @@ export const Default: Story<void> = () => {
<StorybookProviders>
<ExpandableFlyoutContext.Provider value={flyoutContextValue}>
<div style={{ maxWidth: '300px' }}>
<RiskSummary riskScoreData={mockRiskScoreState} queryId={'testQuery'} />
<RiskSummary riskScoreData={{ ...mockRiskScoreState, data: [] }} queryId={'testQuery'} />
</div>
</ExpandableFlyoutContext.Provider>
</StorybookProviders>

View file

@ -12,7 +12,7 @@ import type { ExpandableFlyoutContextValue } from '@kbn/expandable-flyout/src/co
import { ExpandableFlyoutContext } from '@kbn/expandable-flyout/src/context';
import { StorybookProviders } from '../../../common/mock/storybook_providers';
import {
mockManagedUser,
mockManagedUserData,
mockObservedUser,
mockRiskScoreState,
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
@ -23,6 +23,8 @@ const flyoutContextValue = {
panels: {},
} as unknown as ExpandableFlyoutContextValue;
const riskScoreData = { ...mockRiskScoreState, data: [] };
storiesOf('Components/UserPanelContent', module)
.addDecorator((storyFn) => (
<StorybookProviders>
@ -35,9 +37,9 @@ storiesOf('Components/UserPanelContent', module)
))
.add('default', () => (
<UserPanelContent
managedUser={mockManagedUser}
managedUser={mockManagedUserData}
observedUser={mockObservedUser}
riskScoreState={mockRiskScoreState}
riskScoreState={riskScoreData}
contextID={'test-user-details'}
scopeId={'test-scopeId'}
isDraggable={false}
@ -46,20 +48,12 @@ storiesOf('Components/UserPanelContent', module)
.add('integration disabled', () => (
<UserPanelContent
managedUser={{
details: undefined,
data: undefined,
isLoading: false,
isIntegrationEnabled: false,
firstSeen: {
isLoading: false,
date: undefined,
},
lastSeen: {
isLoading: false,
date: undefined,
},
}}
observedUser={mockObservedUser}
riskScoreState={mockRiskScoreState}
riskScoreState={riskScoreData}
contextID={'test-user-details'}
scopeId={'test-scopeId'}
isDraggable={false}
@ -68,20 +62,12 @@ storiesOf('Components/UserPanelContent', module)
.add('no managed data', () => (
<UserPanelContent
managedUser={{
details: undefined,
data: undefined,
isLoading: false,
isIntegrationEnabled: true,
firstSeen: {
isLoading: false,
date: undefined,
},
lastSeen: {
isLoading: false,
date: undefined,
},
}}
observedUser={mockObservedUser}
riskScoreState={mockRiskScoreState}
riskScoreState={riskScoreData}
contextID={'test-user-details'}
scopeId={'test-scopeId'}
isDraggable={false}
@ -89,7 +75,7 @@ storiesOf('Components/UserPanelContent', module)
))
.add('no observed data', () => (
<UserPanelContent
managedUser={mockManagedUser}
managedUser={mockManagedUserData}
observedUser={{
details: {
user: {
@ -115,7 +101,7 @@ storiesOf('Components/UserPanelContent', module)
},
anomalies: { isLoading: false, anomalies: null, jobNameById: {} },
}}
riskScoreState={mockRiskScoreState}
riskScoreState={riskScoreData}
contextID={'test-user-details'}
scopeId={'test-scopeId'}
isDraggable={false}
@ -124,17 +110,9 @@ storiesOf('Components/UserPanelContent', module)
.add('loading', () => (
<UserPanelContent
managedUser={{
details: undefined,
data: undefined,
isLoading: true,
isIntegrationEnabled: true,
firstSeen: {
isLoading: true,
date: undefined,
},
lastSeen: {
isLoading: true,
date: undefined,
},
}}
observedUser={{
details: {
@ -161,7 +139,7 @@ storiesOf('Components/UserPanelContent', module)
},
anomalies: { isLoading: true, anomalies: null, jobNameById: {} },
}}
riskScoreState={mockRiskScoreState}
riskScoreState={riskScoreData}
contextID={'test-user-details'}
scopeId={'test-scopeId'}
isDraggable={false}

View file

@ -8,11 +8,11 @@
import { EuiHorizontalRule } from '@elastic/eui';
import React from 'react';
import { ManagedUser } from '../../../timelines/components/side_panel/new_user_detail/managed_user';
import type {
ManagedUserData,
ObservedUserData,
} from '../../../timelines/components/side_panel/new_user_detail/types';
import { ManagedUser } from '../../../timelines/components/side_panel/new_user_detail/managed_user';
import { ObservedUser } from '../../../timelines/components/side_panel/new_user_detail/observed_user';
import type { RiskScoreEntity } from '../../../../common/search_strategy';
import type { RiskScoreState } from '../../../explore/containers/risk_score';
@ -52,12 +52,7 @@ export const UserPanelContent = ({
isDraggable={isDraggable}
/>
<EuiHorizontalRule margin="m" />
<ManagedUser
managedUser={managedUser}
contextID={contextID}
scopeId={scopeId}
isDraggable={isDraggable}
/>
<ManagedUser managedUser={managedUser} contextID={contextID} isDraggable={isDraggable} />
</FlyoutBody>
);
};

View file

@ -5,18 +5,20 @@
* 2.0.
*/
import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details';
import { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../common/mock';
import {
mockManagedUser,
managedUserDetails,
mockManagedUserData,
mockObservedUser,
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
import { UserPanelHeader } from './header';
const mockProps = {
userName: 'test',
managedUser: mockManagedUser,
managedUser: mockManagedUserData,
observedUser: mockObservedUser,
};
@ -57,16 +59,23 @@ describe('UserDetailsContent', () => {
it('renders managed user date when it is bigger than observed user date', () => {
const futureDay = '2989-03-07T20:00:00.000Z';
const entraManagedUser = managedUserDetails[ManagedUserDatasetKey.ENTRA]!;
const { getByTestId } = render(
<TestProviders>
<UserPanelHeader
{...{
...mockProps,
managedUser: {
...mockManagedUser,
lastSeen: {
isLoading: false,
date: futureDay,
...mockManagedUserData,
data: {
[ManagedUserDatasetKey.ENTRA]: {
...entraManagedUser,
fields: {
...entraManagedUser.fields,
'@timestamp': [futureDay],
},
},
[ManagedUserDatasetKey.OKTA]: undefined,
},
},
}}
@ -109,17 +118,17 @@ describe('UserDetailsContent', () => {
expect(queryByTestId('user-panel-header-observed-badge')).not.toBeInTheDocument();
});
it('does not render managed badge when lastSeen date is undefined', () => {
it('does not render managed badge when managed data is undefined', () => {
const { queryByTestId } = render(
<TestProviders>
<UserPanelHeader
{...{
...mockProps,
managedUser: {
...mockManagedUser,
lastSeen: {
isLoading: false,
date: undefined,
...mockManagedUserData,
data: {
[ManagedUserDatasetKey.ENTRA]: undefined,
[ManagedUserDatasetKey.OKTA]: undefined,
},
},
}}

View file

@ -10,6 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React, { useMemo } from 'react';
import { max } from 'lodash/fp';
import { SecurityPageName } from '@kbn/security-solution-navigation';
import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details';
import { getUsersDetailsUrl } from '../../../common/components/link_to/redirect_to_users';
import type {
ManagedUserData,
@ -28,10 +29,20 @@ interface UserPanelHeaderProps {
}
export const UserPanelHeader = ({ userName, observedUser, managedUser }: UserPanelHeaderProps) => {
const oktaTimestamp = managedUser.data?.[ManagedUserDatasetKey.OKTA]?.fields?.[
'@timestamp'
][0] as string | undefined;
const entraTimestamp = managedUser.data?.[ManagedUserDatasetKey.ENTRA]?.fields?.[
'@timestamp'
][0] as string | undefined;
const isManaged = !!oktaTimestamp || !!entraTimestamp;
const lastSeenDate = useMemo(
() =>
max([observedUser.lastSeen, managedUser.lastSeen].map((el) => el.date && new Date(el.date))),
[managedUser.lastSeen, observedUser.lastSeen]
max(
[observedUser.lastSeen.date, entraTimestamp, oktaTimestamp].map((el) => el && new Date(el))
),
[oktaTimestamp, entraTimestamp, observedUser.lastSeen]
);
return (
@ -66,7 +77,7 @@ export const UserPanelHeader = ({ userName, observedUser, managedUser }: UserPan
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{managedUser.lastSeen.date && (
{isManaged && (
<EuiBadge data-test-subj="user-panel-header-managed-badge" color="hollow">
<FormattedMessage
id="xpack.securitySolution.flyout.entityDetails.user.managedBadge"

View file

@ -13,7 +13,7 @@ import { UserPanel } from '.';
import { mockRiskScoreState } from './mocks';
import {
mockManagedUser,
mockManagedUserData,
mockObservedUser,
} from '../../../timelines/components/side_panel/new_user_detail/__mocks__';
@ -31,24 +31,27 @@ jest.mock('../../../explore/containers/risk_score', () => ({
useRiskScore: () => mockedUseRiskScore(),
}));
const mockedUseManagedUser = jest.fn().mockReturnValue(mockManagedUser);
const mockedUseManagedUser = jest.fn().mockReturnValue(mockManagedUserData);
const mockedUseObservedUser = jest.fn().mockReturnValue(mockObservedUser);
jest.mock('../../../timelines/components/side_panel/new_user_detail/hooks', () => {
const originalModule = jest.requireActual(
'../../../timelines/components/side_panel/new_user_detail/hooks'
);
return {
...originalModule,
jest.mock(
'../../../timelines/components/side_panel/new_user_detail/hooks/use_managed_user',
() => ({
useManagedUser: () => mockedUseManagedUser(),
})
);
jest.mock(
'../../../timelines/components/side_panel/new_user_detail/hooks/use_observed_user',
() => ({
useObservedUser: () => mockedUseObservedUser(),
};
});
})
);
describe('UserPanel', () => {
beforeEach(() => {
mockedUseRiskScore.mockReturnValue(mockRiskScoreState);
mockedUseManagedUser.mockReturnValue(mockManagedUser);
mockedUseManagedUser.mockReturnValue(mockManagedUserData);
mockedUseObservedUser.mockReturnValue(mockObservedUser);
});
@ -97,7 +100,7 @@ describe('UserPanel', () => {
it('renders loading state when managed user is loading', () => {
mockedUseManagedUser.mockReturnValue({
...mockManagedUser,
...mockManagedUserData,
isLoading: true,
});

View file

@ -8,14 +8,12 @@
import React, { useCallback, useMemo } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { useManagedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_managed_user';
import { useObservedUser } from '../../../timelines/components/side_panel/new_user_detail/hooks/use_observed_user';
import { useQueryInspector } from '../../../common/components/page/manage_query';
import { UsersType } from '../../../explore/users/store/model';
import { getCriteriaFromUsersType } from '../../../common/components/ml/criteria/get_criteria_from_users_type';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import {
useManagedUser,
useObservedUser,
} from '../../../timelines/components/side_panel/new_user_detail/hooks';
import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider';
import { buildUserNamesFilter } from '../../../../common/search_strategy';
import { useRiskScore } from '../../../explore/containers/risk_score';
@ -62,7 +60,7 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan
const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime();
const observedUser = useObservedUser(userName);
const managedUser = useManagedUser(userName);
const managedUser = useManagedUser(userName, observedUser);
const { data: userRisk } = riskScoreState;
const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined;

View file

@ -30,6 +30,12 @@ import type { UserPanelExpandableFlyoutProps } from './entity_details/user_right
import { UserPanel, UserPanelKey } from './entity_details/user_right';
import type { RiskInputsExpandableFlyoutProps } from './entity_details/risk_inputs_left';
import { RiskInputsPanel, RiskInputsPanelKey } from './entity_details/risk_inputs_left';
import type { AssetDocumentLeftPanelProps } from './entity_details/asset_document_left';
import {
AssetDocumentLeftPanel,
AssetDocumentLeftPanelKey,
} from './entity_details/asset_document_left';
/**
* List of all panels that will be used within the document details expandable flyout.
* This needs to be passed to the expandable flyout registeredPanels property.
@ -43,6 +49,14 @@ const expandableFlyoutDocumentsPanels: ExpandableFlyoutProps['registeredPanels']
</RightPanelProvider>
),
},
{
key: AssetDocumentLeftPanelKey,
component: (props) => (
<RightPanelProvider {...(props as RightPanelProps).params}>
<AssetDocumentLeftPanel {...(props as AssetDocumentLeftPanelProps)} />
</RightPanelProvider>
),
},
{
key: DocumentDetailsLeftPanelKey,
component: (props) => (

View file

@ -5,9 +5,14 @@
* 2.0.
*/
import type {
ManagedUserHits,
ManagedUserFields,
} from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { ManagedUserDatasetKey } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { RiskSeverity } from '../../../../../../common/search_strategy';
import { mockAnomalies } from '../../../../../common/components/ml/mock';
import type { ObservedUserData } from '../types';
import type { ManagedUserData, ObservedUserData } from '../types';
const userRiskScore = {
'@timestamp': '123456',
@ -41,20 +46,6 @@ export const mockRiskScoreState = {
const anomaly = mockAnomalies.anomalies[0];
export const managedUserDetails = {
'@timestamp': '',
agent: {},
host: {},
event: {},
user: {
id: '123456',
last_name: 'user',
first_name: 'test',
full_name: 'test user',
phone: ['123456', '654321'],
},
};
export const observedUserDetails = {
user: {
id: ['1234', '321'],
@ -69,20 +60,6 @@ export const observedUserDetails = {
},
};
export const mockManagedUser = {
details: managedUserDetails,
isLoading: false,
isIntegrationEnabled: true,
firstSeen: {
isLoading: false,
date: '2023-03-23T20:03:17.489Z',
},
lastSeen: {
isLoading: false,
date: '2023-03-23T20:03:17.489Z',
},
};
export const mockObservedUser: ObservedUserData = {
details: observedUserDetails,
isLoading: false,
@ -103,3 +80,44 @@ export const mockObservedUser: ObservedUserData = {
jobNameById: { [anomaly.jobId]: 'job_name' },
},
};
export const mockOktaUserFields: ManagedUserFields = {
'@timestamp': ['2023-11-16T13:42:23.074Z'],
'event.dataset': [ManagedUserDatasetKey.OKTA],
'user.profile.last_name': ['Okta last name'],
'user.profile.first_name': ['Okta first name'],
'user.profile.mobile_phone': ['1234567'],
'user.profile.job_title': ['Okta Unit tester'],
'user.geo.city_name': ["A'dam"],
'user.geo.country_iso_code': ['NL'],
'user.id': ['00ud9ohoh9ww644Px5d7'],
'user.email': ['okta.test.user@elastic.co'],
'user.name': ['okta.test.user@elastic.co'],
};
export const mockEntraUserFields: ManagedUserFields = {
'@timestamp': ['2023-11-16T13:42:23.074Z'],
'event.dataset': [ManagedUserDatasetKey.ENTRA],
'user.id': ['12345'],
'user.first_name': ['Entra first name'],
'user.last_name': ['Entra last name'],
'user.full_name': ['Entra full name'],
'user.phone': ['123456'],
'user.job_title': ['Entra Unit tester'],
'user.work.location_name': ['USA, CA'],
};
export const managedUserDetails: ManagedUserHits = {
[ManagedUserDatasetKey.ENTRA]: {
fields: mockEntraUserFields,
_index: 'test-index',
_id: '123-test',
},
[ManagedUserDatasetKey.OKTA]: undefined,
};
export const mockManagedUserData: ManagedUserData = {
data: managedUserDetails,
isLoading: false,
isIntegrationEnabled: true,
};

View file

@ -10,7 +10,10 @@ import React, { useCallback } from 'react';
import { head } from 'lodash/fp';
import { euiLightVars } from '@kbn/ui-theme';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiToolTip } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { EcsFlat } from '@kbn/ecs';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { DefaultFieldRenderer } from '../../field_renderers/field_renderers';
import type {
ManagedUsersTableColumns,
@ -32,21 +35,22 @@ import { getSourcererScopeId } from '../../../../helpers';
const fieldColumn: EuiBasicTableColumn<ObservedUserTable | ManagedUserTable> = {
name: i18n.FIELD_COLUMN_TITLE,
field: 'label',
render: (label: string) => (
<span
css={css`
font-weight: ${euiLightVars.euiFontWeightMedium};
color: ${euiLightVars.euiTitleColor};
`}
>
{label}
</span>
render: (label: string, { field }) => (
<EuiToolTip content={EcsFlat[field as keyof typeof EcsFlat]?.short ?? field}>
<span
css={css`
font-weight: ${euiLightVars.euiFontWeightMedium};
color: ${euiLightVars.euiTitleColor};
`}
>
{label ?? field}
</span>
</EuiToolTip>
),
};
export const getManagedUserTableColumns = (
contextID: string,
scopeId: string,
isDraggable: boolean
): ManagedUsersTableColumns => [
fieldColumn,
@ -56,11 +60,11 @@ export const getManagedUserTableColumns = (
render: (value: ManagedUserTable['value'], { field }) => {
return field && value ? (
<DefaultFieldRenderer
rowItems={[value]}
rowItems={value.map((v) => value.toString())}
attrName={field}
idPrefix={contextID ? `managedUser-${contextID}` : 'managedUser'}
isDraggable={isDraggable}
sourcererScopeId={getSourcererScopeId(scopeId)}
sourcererScopeId={SourcererScopeName.default}
/>
) : (
defaultToEmptyTag(value)

View file

@ -4,9 +4,17 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const getEntraUserIndex = (spaceId: string = 'default') =>
`logs-entityanalytics_entra_id.user-${spaceId}`;
export const ENTRA_ID_PACKAGE_NAME = 'entityanalytics_entra_id';
export const INSTALL_EA_INTEGRATIONS_HREF = `browse/security?q=entityanalytics`;
export const MANAGED_USER_INDEX = ['logs-entityanalytics_azure.users-*'];
export const MANAGED_USER_PACKAGE_NAME = 'entityanalytics_azure';
export const INSTALL_INTEGRATION_HREF = `/detail/${MANAGED_USER_PACKAGE_NAME}/overview`;
export const ONE_WEEK_IN_HOURS = 24 * 7;
export const MANAGED_USER_QUERY_ID = 'managedUserDetailsQuery';
export const getOktaUserIndex = (spaceId: string = 'default') =>
`logs-entityanalytics_okta.user-${spaceId}`;
export const OKTA_PACKAGE_NAME = 'entityanalytics_okta';

View file

@ -1,61 +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.
*/
import { renderHook } from '@testing-library/react-hooks';
import type { InstalledIntegration } from '../../../../../common/api/detection_engine/fleet_integrations';
import { TestProviders } from '../../../../common/mock';
import { MANAGED_USER_PACKAGE_NAME } from './constants';
import { useManagedUser } from './hooks';
const makeInstalledIntegration = (
pkgName = 'testPkg',
isEnabled = false
): InstalledIntegration => ({
package_name: pkgName,
package_title: '',
package_version: '',
integration_name: '',
integration_title: '',
is_enabled: isEnabled,
});
const mockUseInstalledIntegrations = jest.fn().mockReturnValue({
data: [],
});
jest.mock(
'../../../../detections/components/rules/related_integrations/use_installed_integrations',
() => ({
useInstalledIntegrations: () => mockUseInstalledIntegrations(),
})
);
describe('useManagedUser', () => {
it('returns isIntegrationEnabled:true when it finds an enabled integration with the given name', () => {
mockUseInstalledIntegrations.mockReturnValue({
data: [makeInstalledIntegration(MANAGED_USER_PACKAGE_NAME, true)],
});
const { result } = renderHook(() => useManagedUser('test-userName'), {
wrapper: TestProviders,
});
expect(result.current.isIntegrationEnabled).toBeTruthy();
});
it('returns isIntegrationEnabled:false when it does not find an enabled integration with the given name', () => {
mockUseInstalledIntegrations.mockReturnValue({
data: [makeInstalledIntegration('fake-name', true)],
});
const { result } = renderHook(() => useManagedUser('test-userName'), {
wrapper: TestProviders,
});
expect(result.current.isIntegrationEnabled).toBeFalsy();
});
});

View file

@ -1,236 +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.
*/
import { useMemo, useEffect } from 'react';
import * as i18n from './translations';
import type { ManagedUserTable, ObservedUserData, ObservedUserTable } from './types';
import type { AzureManagedUser } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import {
Direction,
UsersQueries,
NOT_EVENT_KIND_ASSET_FILTER,
} from '../../../../../common/search_strategy';
import { useSourcererDataView } from '../../../../common/containers/sourcerer';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useSearchStrategy } from '../../../../common/containers/use_search_strategy';
import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details';
import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen';
import { useInstalledIntegrations } from '../../../../detections/components/rules/related_integrations/use_installed_integrations';
import { MANAGED_USER_INDEX, MANAGED_USER_PACKAGE_NAME, MANAGED_USER_QUERY_ID } from './constants';
import { useQueryInspector } from '../../../../common/components/page/manage_query';
export const useObservedUserItems = (userData: ObservedUserData): ObservedUserTable[] =>
useMemo(
() =>
!userData.details
? []
: [
{ label: i18n.USER_ID, values: userData.details.user?.id, field: 'user.id' },
{ label: 'Domain', values: userData.details.user?.domain, field: 'user.domain' },
{
label: i18n.MAX_ANOMALY_SCORE_BY_JOB,
field: 'anomalies',
values: userData.anomalies,
},
{
label: i18n.FIRST_SEEN,
values: userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
field: '@timestamp',
},
{
label: i18n.LAST_SEEN,
values: userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
field: '@timestamp',
},
{
label: i18n.OPERATING_SYSTEM_TITLE,
values: userData.details.host?.os?.name,
field: 'host.os.name',
},
{
label: i18n.FAMILY,
values: userData.details.host?.os?.family,
field: 'host.os.family',
},
{ label: i18n.IP_ADDRESSES, values: userData.details.host?.ip, field: 'host.ip' },
],
[userData.details, userData.anomalies, userData.firstSeen, userData.lastSeen]
);
export const useManagedUserItems = (
managedUserDetails?: AzureManagedUser
): ManagedUserTable[] | null =>
useMemo(
() =>
!managedUserDetails
? null
: [
{
label: i18n.USER_ID,
value: managedUserDetails.user.id,
field: 'user.id',
},
{
label: i18n.FULL_NAME,
value: managedUserDetails.user.full_name,
field: 'user.full_name',
},
{
label: i18n.FIRST_NAME,
value: managedUserDetails.user.first_name,
},
{
label: i18n.LAST_NAME,
value: managedUserDetails.user.last_name,
},
{ label: i18n.PHONE, value: managedUserDetails.user.phone?.join(', ') },
],
[managedUserDetails]
);
export const useManagedUser = (userName: string) => {
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
const {
loading: loadingManagedUser,
result: { userDetails: managedUserDetails },
search,
refetch,
inspect,
} = useSearchStrategy<UsersQueries.managedDetails>({
factoryQueryType: UsersQueries.managedDetails,
initialResult: {},
errorMessage: i18n.FAIL_MANAGED_USER,
});
useEffect(() => {
if (!isInitializing) {
search({
defaultIndex: MANAGED_USER_INDEX,
userName,
});
}
}, [from, search, to, userName, isInitializing]);
const { data: installedIntegrations, isLoading: loadingIntegrations } = useInstalledIntegrations({
packages: [MANAGED_USER_PACKAGE_NAME],
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId: MANAGED_USER_QUERY_ID,
loading: loadingManagedUser,
});
const isIntegrationEnabled = useMemo(
() =>
!!installedIntegrations?.some(
({ package_name: packageName, is_enabled: isEnabled }) =>
packageName === MANAGED_USER_PACKAGE_NAME && isEnabled
),
[installedIntegrations]
);
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: MANAGED_USER_INDEX,
order: Direction.asc,
});
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: MANAGED_USER_INDEX,
order: Direction.desc,
});
return useMemo(
() => ({
details: managedUserDetails,
isLoading: loadingManagedUser || loadingIntegrations,
isIntegrationEnabled,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
},
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
}),
[
firstSeen,
isIntegrationEnabled,
loadingIntegrations,
lastSeen,
loadingFirstSeen,
loadingLastSeen,
loadingManagedUser,
managedUserDetails,
]
);
};
export const useObservedUser = (userName: string) => {
const { selectedPatterns } = useSourcererDataView();
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
const [loadingObservedUser, { userDetails: observedUserDetails, inspect, refetch, id: queryId }] =
useObservedUserDetails({
endDate: to,
startDate: from,
userName,
indexNames: selectedPatterns,
skip: isInitializing,
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId,
loading: loadingObservedUser,
});
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: selectedPatterns,
order: Direction.asc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: selectedPatterns,
order: Direction.desc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
return useMemo(
() => ({
details: observedUserDetails,
isLoading: loadingObservedUser,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
},
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
}),
[
firstSeen,
lastSeen,
loadingFirstSeen,
loadingLastSeen,
loadingObservedUser,
observedUserDetails,
]
);
};

View file

@ -0,0 +1,132 @@
/*
* 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 type { InstalledIntegration } from '../../../../../../common/api/detection_engine/fleet_integrations';
import { TestProviders } from '../../../../../common/mock';
import { ENTRA_ID_PACKAGE_NAME } from '../constants';
import { useManagedUser } from './use_managed_user';
import type { ObserverUser } from './use_observed_user';
const makeInstalledIntegration = (
pkgName = 'testPkg',
isEnabled = false
): InstalledIntegration => ({
package_name: pkgName,
package_title: '',
package_version: '',
integration_name: '',
integration_title: '',
is_enabled: isEnabled,
});
const mockUseInstalledIntegrations = jest.fn().mockReturnValue({
data: [],
});
jest.mock(
'../../../../../detections/components/rules/related_integrations/use_installed_integrations',
() => ({
useInstalledIntegrations: () => mockUseInstalledIntegrations(),
})
);
jest.mock('../../../../../common/hooks/use_space_id', () => ({
useSpaceId: () => 'test-space-id',
}));
const mockSearch = jest.fn().mockReturnValue({
data: [],
});
jest.mock('../../../../../common/containers/use_search_strategy', () => ({
useSearchStrategy: () => ({
loading: false,
result: { users: [] },
search: (...params: unknown[]) => mockSearch(...params),
refetch: () => {},
inspect: {},
}),
}));
const observedUser: ObserverUser = {
isLoading: false,
details: {},
firstSeen: {
date: undefined,
isLoading: false,
},
lastSeen: {
date: undefined,
isLoading: false,
},
};
describe('useManagedUser', () => {
beforeEach(() => {
mockSearch.mockClear();
});
it('returns isIntegrationEnabled:true when it finds an enabled integration with the given name', () => {
mockUseInstalledIntegrations.mockReturnValue({
data: [makeInstalledIntegration(ENTRA_ID_PACKAGE_NAME, true)],
});
const { result } = renderHook(() => useManagedUser('test-userName', observedUser), {
wrapper: TestProviders,
});
expect(result.current.isIntegrationEnabled).toBeTruthy();
});
it('returns isIntegrationEnabled:false when it does not find an enabled integration with the given name', () => {
mockUseInstalledIntegrations.mockReturnValue({
data: [makeInstalledIntegration('fake-name', true)],
});
const { result } = renderHook(() => useManagedUser('test-userName', observedUser), {
wrapper: TestProviders,
});
expect(result.current.isIntegrationEnabled).toBeFalsy();
});
it('should search', () => {
renderHook(() => useManagedUser('test-userName', observedUser), {
wrapper: TestProviders,
});
expect(mockSearch).toHaveBeenCalled();
});
it('should not search while observed user is loading', () => {
renderHook(() => useManagedUser('test-userName', { ...observedUser, isLoading: true }), {
wrapper: TestProviders,
});
expect(mockSearch).not.toHaveBeenCalled();
});
it('should search by email if the field is available', () => {
const email = ['test@email.com'];
renderHook(
() =>
useManagedUser('test-userName', {
...observedUser,
details: { user: { email } },
}),
{
wrapper: TestProviders,
}
);
expect(mockSearch).toBeCalledWith(
expect.objectContaining({
userEmail: email,
})
);
});
});

View file

@ -0,0 +1,102 @@
/*
* 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 { useEffect, useMemo } from 'react';
import { useInstalledIntegrations } from '../../../../../detections/components/rules/related_integrations/use_installed_integrations';
import { ManagedUserDatasetKey } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { UsersQueries } from '../../../../../../common/search_strategy';
import { useSpaceId } from '../../../../../common/hooks/use_space_id';
import { useSearchStrategy } from '../../../../../common/containers/use_search_strategy';
import { useGlobalTime } from '../../../../../common/containers/use_global_time';
import { useQueryInspector } from '../../../../../common/components/page/manage_query';
import {
ENTRA_ID_PACKAGE_NAME,
OKTA_PACKAGE_NAME,
getEntraUserIndex,
getOktaUserIndex,
MANAGED_USER_QUERY_ID,
} from '../constants';
import * as i18n from '../translations';
import type { ObserverUser } from './use_observed_user';
const packages = [ENTRA_ID_PACKAGE_NAME, OKTA_PACKAGE_NAME];
export const useManagedUser = (userName: string, observedUser: ObserverUser) => {
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
const spaceId = useSpaceId();
const {
loading: loadingManagedUser,
result: { users: managedUserData },
search,
refetch,
inspect,
} = useSearchStrategy<UsersQueries.managedDetails>({
factoryQueryType: UsersQueries.managedDetails,
initialResult: {
users: {
[ManagedUserDatasetKey.ENTRA]: undefined,
[ManagedUserDatasetKey.OKTA]: undefined,
},
},
errorMessage: i18n.FAIL_MANAGED_USER,
});
const defaultIndex = useMemo(
() => (spaceId ? [getEntraUserIndex(spaceId), getOktaUserIndex(spaceId)] : []),
[spaceId]
);
useEffect(() => {
if (!isInitializing && defaultIndex.length > 0 && !observedUser.isLoading && userName) {
search({
defaultIndex,
userEmail: observedUser.details.user?.email,
userName,
});
}
}, [
from,
search,
to,
isInitializing,
defaultIndex,
userName,
observedUser.isLoading,
observedUser.details.user?.email,
]);
const { data: installedIntegrations, isLoading: loadingIntegrations } = useInstalledIntegrations({
packages,
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId: MANAGED_USER_QUERY_ID,
loading: loadingManagedUser,
});
const isIntegrationEnabled = useMemo(
() =>
!!installedIntegrations?.some(
({ package_name: packageName, is_enabled: isEnabled }) =>
isEnabled && packages.includes(packageName)
),
[installedIntegrations]
);
return useMemo(
() => ({
data: managedUserData,
isLoading: loadingManagedUser || loadingIntegrations,
isIntegrationEnabled,
}),
[isIntegrationEnabled, loadingIntegrations, loadingManagedUser, managedUserData]
);
};

View file

@ -0,0 +1,87 @@
/*
* 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 {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../../../common/mock';
import { useManagedUserItems } from './use_managed_user_items';
import { mockEntraUserFields, mockOktaUserFields } from '../__mocks__';
import { UserAssetTableType } from '../../../../../explore/users/store/model';
import React from 'react';
import { createStore } from '../../../../../common/store';
const mockState = {
...mockGlobalState,
users: {
...mockGlobalState.users,
flyout: {
...mockGlobalState.users.flyout,
queries: {
[UserAssetTableType.assetEntra]: {
fields: ['user.id', 'user.first_name'],
},
[UserAssetTableType.assetOkta]: {
fields: ['user.id', 'user.profile.first_name'],
},
},
},
},
};
const { storage } = createSecuritySolutionStorageMock();
const mockStore = createStore(mockState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<TestProviders store={mockStore}>{children}</TestProviders>
);
describe('useManagedUserItems', () => {
it('returns managed user items for Entra user', () => {
const { result } = renderHook(
() => useManagedUserItems(UserAssetTableType.assetEntra, mockEntraUserFields),
{
wrapper: TestWrapper,
}
);
expect(result.current).toEqual([
{
field: 'user.id',
value: ['12345'],
},
{
field: 'user.first_name',
value: ['Entra first name'],
},
]);
});
it('returns managed user items for Okta user', () => {
const { result } = renderHook(
() => useManagedUserItems(UserAssetTableType.assetOkta, mockOktaUserFields),
{
wrapper: TestWrapper,
}
);
expect(result.current).toEqual([
{
field: 'user.id',
value: ['00ud9ohoh9ww644Px5d7'],
},
{
field: 'user.profile.first_name',
value: ['Okta first name'],
},
]);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { useMemo } from 'react';
import { useSelector } from 'react-redux';
import type { UserAssetTableType } from '../../../../../explore/users/store/model';
import type { State } from '../../../../../common/store/types';
import { usersSelectors } from '../../../../../explore/users/store';
import type { ManagedUserTable } from '../types';
import type { ManagedUserFields } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
export const useManagedUserItems = (
tableType: UserAssetTableType,
managedUserDetails: ManagedUserFields
): ManagedUserTable[] | null => {
const tableData = useSelector((state: State) =>
usersSelectors.selectUserAssetTableById(state, tableType)
);
return useMemo(
() =>
tableData.fields.map((fieldName) => ({
value: managedUserDetails[fieldName],
field: fieldName,
})),
[managedUserDetails, tableData.fields]
);
};

View file

@ -0,0 +1,87 @@
/*
* 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 { useMemo } from 'react';
import { useObservedUserDetails } from '../../../../../explore/users/containers/users/observed_details';
import type { UserItem } from '../../../../../../common/search_strategy';
import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../../common/search_strategy';
import { useSourcererDataView } from '../../../../../common/containers/sourcerer';
import { useGlobalTime } from '../../../../../common/containers/use_global_time';
import { useFirstLastSeen } from '../../../../../common/containers/use_first_last_seen';
import { useQueryInspector } from '../../../../../common/components/page/manage_query';
export interface ObserverUser {
details: UserItem;
isLoading: boolean;
firstSeen: {
date: string | null | undefined;
isLoading: boolean;
};
lastSeen: {
date: string | null | undefined;
isLoading: boolean;
};
}
export const useObservedUser = (userName: string): ObserverUser => {
const { selectedPatterns } = useSourcererDataView();
const { to, from, isInitializing, deleteQuery, setQuery } = useGlobalTime();
const [loadingObservedUser, { userDetails: observedUserDetails, inspect, refetch, id: queryId }] =
useObservedUserDetails({
endDate: to,
startDate: from,
userName,
indexNames: selectedPatterns,
skip: isInitializing,
});
useQueryInspector({
deleteQuery,
inspect,
refetch,
setQuery,
queryId,
loading: loadingObservedUser,
});
const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: selectedPatterns,
order: Direction.asc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({
field: 'user.name',
value: userName,
defaultIndex: selectedPatterns,
order: Direction.desc,
filterQuery: NOT_EVENT_KIND_ASSET_FILTER,
});
return useMemo(
() => ({
details: observedUserDetails,
isLoading: loadingObservedUser,
firstSeen: {
date: firstSeen,
isLoading: loadingFirstSeen,
},
lastSeen: { date: lastSeen, isLoading: loadingLastSeen },
}),
[
firstSeen,
lastSeen,
loadingFirstSeen,
loadingLastSeen,
loadingObservedUser,
observedUserDetails,
]
);
};

View file

@ -0,0 +1,62 @@
/*
* 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 { TestProviders } from '../../../../../common/mock';
import { mockObservedUser } from '../__mocks__';
import { useObservedUserItems } from './use_observed_user_items';
describe('useManagedUserItems', () => {
it('returns managed user items for Entra user', () => {
const { result } = renderHook(() => useObservedUserItems(mockObservedUser), {
wrapper: TestProviders,
});
expect(result.current).toEqual([
{
field: 'user.id',
label: 'User ID',
values: ['1234', '321'],
},
{
field: 'user.domain',
label: 'Domain',
values: ['test domain', 'another test domain'],
},
{
field: 'anomalies',
label: 'Max anomaly score by job',
values: mockObservedUser.anomalies,
},
{
field: '@timestamp',
label: 'First seen',
values: ['2023-02-23T20:03:17.489Z'],
},
{
field: '@timestamp',
label: 'Last seen',
values: ['2023-02-23T20:03:17.489Z'],
},
{
field: 'host.os.name',
label: 'Operating system',
values: ['testOs'],
},
{
field: 'host.os.family',
label: 'Family',
values: ['testFamily'],
},
{
field: 'host.ip',
label: 'IP addresses',
values: ['10.0.0.1', '127.0.0.1'],
},
]);
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { useMemo } from 'react';
import * as i18n from '../translations';
import type { ObservedUserData, ObservedUserTable } from '../types';
export const useObservedUserItems = (userData: ObservedUserData): ObservedUserTable[] =>
useMemo(
() =>
!userData.details
? []
: [
{ label: i18n.USER_ID, values: userData.details.user?.id, field: 'user.id' },
{ label: 'Domain', values: userData.details.user?.domain, field: 'user.domain' },
{
label: i18n.MAX_ANOMALY_SCORE_BY_JOB,
field: 'anomalies',
values: userData.anomalies,
},
{
label: i18n.FIRST_SEEN,
values: userData.firstSeen.date ? [userData.firstSeen.date] : undefined,
field: '@timestamp',
},
{
label: i18n.LAST_SEEN,
values: userData.lastSeen.date ? [userData.lastSeen.date] : undefined,
field: '@timestamp',
},
{
label: i18n.OPERATING_SYSTEM_TITLE,
values: userData.details.host?.os?.name,
field: 'host.os.name',
},
{
label: i18n.FAMILY,
values: userData.details.host?.os?.family,
field: 'host.os.family',
},
{ label: i18n.IP_ADDRESSES, values: userData.details.host?.ip, field: 'host.ip' },
],
[userData.details, userData.anomalies, userData.firstSeen, userData.lastSeen]
);

View file

@ -5,15 +5,17 @@
* 2.0.
*/
import { ManagedUserDatasetKey } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../common/mock';
import { mockManagedUserData, mockOktaUserFields } from './__mocks__';
import { ManagedUser } from './managed_user';
import { mockManagedUser } from './__mocks__';
describe('ManagedUser', () => {
const mockProps = {
managedUser: mockManagedUser,
managedUser: mockManagedUserData,
contextID: '',
scopeId: '',
isDraggable: false,
@ -36,7 +38,7 @@ describe('ManagedUser', () => {
</TestProviders>
);
expect(getByTestId('managedUser-data')).toHaveTextContent('Updated Mar 23, 2023');
expect(getByTestId('managedUser-data')).toHaveTextContent('Nov 16, 2023');
});
it('renders enable integration callout when the integration is disabled', () => {
@ -46,7 +48,7 @@ describe('ManagedUser', () => {
{...{
...mockProps,
managedUser: {
...mockManagedUser,
...mockManagedUserData,
isIntegrationEnabled: false,
},
}}
@ -57,23 +59,13 @@ describe('ManagedUser', () => {
expect(getByTestId('managedUser-integration-disable-callout')).toBeInTheDocument();
});
it('renders phone number separated by comma', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('managedUser-data')).toHaveTextContent('123456, 654321');
});
it('it renders the call out when the integration is disabled', () => {
it('renders the call out when the integration is disabled', () => {
const { queryByTestId } = render(
<TestProviders>
<ManagedUser
{...{
...mockProps,
managedUser: { ...mockManagedUser, isIntegrationEnabled: false },
managedUser: { ...mockManagedUserData, isIntegrationEnabled: false },
}}
/>
</TestProviders>
@ -88,7 +80,7 @@ describe('ManagedUser', () => {
<ManagedUser
{...{
...mockProps,
managedUser: { ...mockManagedUser, isLoading: true, isIntegrationEnabled: false },
managedUser: { ...mockManagedUserData, isLoading: true, isIntegrationEnabled: false },
}}
/>
</TestProviders>
@ -96,4 +88,39 @@ describe('ManagedUser', () => {
expect(queryByTestId('managedUser-integration-disable-callout')).not.toBeInTheDocument();
});
it('renders Entra managed user', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser {...mockProps} />
</TestProviders>
);
expect(getByTestId('managedUser-table')).toBeInTheDocument();
});
it('renders Okta managed user', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUser
{...{
...mockProps,
managedUser: {
...mockManagedUserData,
data: {
[ManagedUserDatasetKey.ENTRA]: undefined,
[ManagedUserDatasetKey.OKTA]: {
fields: mockOktaUserFields,
_index: '123',
_id: '12234',
},
},
},
}}
/>
</TestProviders>
);
expect(getByTestId('managedUser-table')).toBeInTheDocument();
});
});

View file

@ -11,66 +11,54 @@ import {
EuiAccordion,
EuiTitle,
EuiPanel,
useEuiTheme,
EuiEmptyPrompt,
EuiCallOut,
useEuiFontSize,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { UserAssetTableType } from '../../../../explore/users/store/model';
import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import { ManagedUserDatasetKey } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import * as i18n from './translations';
import { BasicTable } from '../../../../common/components/ml/tables/basic_table';
import { getManagedUserTableColumns } from './columns';
import { useManagedUserItems } from './hooks';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import type { ManagedUserData } from './types';
import { INSTALL_INTEGRATION_HREF, MANAGED_USER_QUERY_ID, ONE_WEEK_IN_HOURS } from './constants';
import { INSTALL_EA_INTEGRATIONS_HREF, MANAGED_USER_QUERY_ID } from './constants';
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
import { useAppUrl } from '../../../../common/lib/kibana';
import { ManagedUserAccordion } from './managed_user_accordion';
import { useManagedUserItems } from './hooks/use_managed_user_items';
export const ManagedUser = ({
managedUser,
contextID,
scopeId,
isDraggable,
}: {
managedUser: ManagedUserData;
contextID: string;
scopeId: string;
isDraggable: boolean;
}) => {
const { euiTheme } = useEuiTheme();
const managedItems = useManagedUserItems(managedUser.details);
const managedUserTableColumns = useMemo(
() => getManagedUserTableColumns(contextID, scopeId, isDraggable),
[isDraggable, contextID, scopeId]
);
const entraManagedUser = managedUser.data?.[ManagedUserDatasetKey.ENTRA];
const oktaManagedUser = managedUser.data?.[ManagedUserDatasetKey.OKTA];
const { getAppUrl } = useAppUrl();
const installedIntegrationHref = useMemo(
() => getAppUrl({ appId: 'integrations', path: INSTALL_INTEGRATION_HREF }),
() => getAppUrl({ appId: 'integrations', path: INSTALL_EA_INTEGRATIONS_HREF }),
[getAppUrl]
);
const xsFontSize = useEuiFontSize('xxs').fontSize;
return (
<>
<InspectButtonContainer>
<EuiAccordion
isLoading={managedUser.isLoading}
initialIsOpen={false}
initialIsOpen={true}
id={'managedUser-data'}
data-test-subj="managedUser-data"
buttonProps={{
'data-test-subj': 'managedUser-accordion-button',
css: css`
color: ${euiTheme.colors.primary};
`,
}}
buttonContent={
<EuiTitle size="xs">
@ -78,47 +66,21 @@ export const ManagedUser = ({
</EuiTitle>
}
extraAction={
<>
<span
css={css`
margin-right: ${euiTheme.size.s};
`}
>
<InspectButton
queryId={MANAGED_USER_QUERY_ID}
title={i18n.MANAGED_USER_INSPECT_TITLE}
/>
</span>
{managedUser.lastSeen.date && (
<span
css={css`
font-size: ${xsFontSize};
`}
>
<FormattedMessage
id="xpack.securitySolution.timeline.userDetails.updatedTime"
defaultMessage="Updated {time}"
values={{
time: (
<FormattedRelativePreferenceDate
value={managedUser.lastSeen.date}
dateFormat="MMM D, YYYY"
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
/>
),
}}
/>
</span>
)}
</>
<InspectButton
queryId={MANAGED_USER_QUERY_ID}
title={i18n.MANAGED_USER_INSPECT_TITLE}
/>
}
css={css`
.euiAccordion__optionalAction {
margin-left: auto;
}
`}
>
<EuiSpacer size="m" />
<FormattedMessage
id="xpack.securitySolution.timeline.userDetails.managed.description"
defaultMessage="Metadata from any asset repository integrations enabled in your environment."
/>
<EuiSpacer size="m" />
{!managedUser.isLoading && !managedUser.isIntegrationEnabled ? (
<EuiPanel
data-test-subj="managedUser-integration-disable-callout"
@ -138,22 +100,53 @@ export const ManagedUser = ({
</EuiPanel>
) : (
<>
{managedItems || managedUser.isLoading ? (
<BasicTable
loading={managedUser.isLoading}
data-test-subj="managedUser-table"
columns={managedUserTableColumns}
items={managedItems ?? []}
/>
) : (
{!entraManagedUser && !oktaManagedUser && !managedUser.isLoading ? (
<EuiCallOut
data-test-subj="managedUser-no-data"
title={i18n.NO_AZURE_DATA_TITLE}
title={i18n.NO_MANAGED_DATA_TITLE}
color="warning"
iconType="help"
>
<p>{i18n.NO_AZURE_DATA_TEXT}</p>
<p>{i18n.NO_MANAGED_DATA_TEXT}</p>
</EuiCallOut>
) : (
<>
{entraManagedUser && entraManagedUser.fields && (
<ManagedUserAccordion
title={i18n.ENTRA_DATA_PANEL_TITLE}
managedUser={entraManagedUser.fields}
indexName={entraManagedUser._index}
eventId={entraManagedUser._id}
tableType={UserAssetTableType.assetEntra}
>
<ManagedUserTable
isDraggable={isDraggable}
contextID={contextID}
managedUser={entraManagedUser.fields}
tableType={UserAssetTableType.assetEntra}
/>
</ManagedUserAccordion>
)}
{entraManagedUser && oktaManagedUser && <EuiSpacer size="m" />}
{oktaManagedUser && oktaManagedUser.fields && (
<ManagedUserAccordion
title={i18n.OKTA_DATA_PANEL_TITLE}
managedUser={oktaManagedUser.fields}
indexName={oktaManagedUser._index}
eventId={oktaManagedUser._id}
tableType={UserAssetTableType.assetEntra}
>
<ManagedUserTable
isDraggable={isDraggable}
contextID={contextID}
managedUser={oktaManagedUser.fields}
tableType={UserAssetTableType.assetOkta}
/>
</ManagedUserAccordion>
)}
</>
)}
</>
)}
@ -162,3 +155,29 @@ export const ManagedUser = ({
</>
);
};
export const ManagedUserTable = ({
managedUser,
contextID,
isDraggable,
tableType,
}: {
managedUser: ManagedUserFields;
contextID: string;
isDraggable: boolean;
tableType: UserAssetTableType;
}) => {
const managedUserTableColumns = useMemo(
() => getManagedUserTableColumns(contextID, isDraggable),
[isDraggable, contextID]
);
const managedItems = useManagedUserItems(tableType, managedUser);
return (
<BasicTable
data-test-subj="managedUser-table"
columns={managedUserTableColumns}
items={managedItems ?? []}
/>
);
};

View file

@ -0,0 +1,33 @@
/*
* 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 { TestProviders } from '../../../../common/mock';
import { render } from '@testing-library/react';
import React from 'react';
import { ManagedUserAccordion } from './managed_user_accordion';
import { mockEntraUserFields } from './__mocks__';
import { UserAssetTableType } from '../../../../explore/users/store/model';
describe('useManagedUserItems', () => {
it('it renders children', () => {
const { getByTestId } = render(
<TestProviders>
<ManagedUserAccordion
title="test title"
managedUser={mockEntraUserFields}
indexName="test-index"
eventId="123"
tableType={UserAssetTableType.assetEntra}
>
<div data-test-subj="test-children" />
</ManagedUserAccordion>
</TestProviders>
);
expect(getByTestId('test-children')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,95 @@
/*
* 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 { useEuiFontSize } from '@elastic/eui';
import React, { useCallback } from 'react';
import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import { get } from 'lodash/fp';
import { useExpandableFlyoutContext } from '@kbn/expandable-flyout';
import { ExpandablePanel } from '../../../../flyout/shared/components/expandable_panel';
import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import { ONE_WEEK_IN_HOURS } from './constants';
import { AssetDocumentLeftPanelKey } from '../../../../flyout/entity_details/asset_document_left';
import type { UserAssetTableType } from '../../../../explore/users/store/model';
interface ManagedUserAccordionProps {
children: React.ReactNode;
title: string;
managedUser: ManagedUserFields;
indexName: string;
eventId: string;
tableType: UserAssetTableType;
}
export const ManagedUserAccordion: React.FC<ManagedUserAccordionProps> = ({
children,
title,
managedUser,
indexName,
eventId,
tableType,
}) => {
const xsFontSize = useEuiFontSize('xxs').fontSize;
const timestamp = get('@timestamp[0]', managedUser) as unknown as string | undefined;
const { openLeftPanel } = useExpandableFlyoutContext();
const toggleDetails = useCallback(() => {
openLeftPanel({
id: AssetDocumentLeftPanelKey,
params: {
id: eventId,
indexName,
scopeId: tableType,
},
});
}, [openLeftPanel, eventId, indexName, tableType]);
return (
<ExpandablePanel
header={{
title,
iconType: 'arrowStart',
headerContent: timestamp && (
<span
css={css`
font-size: ${xsFontSize};
`}
>
<FormattedMessage
id="xpack.securitySolution.timeline.userDetails.updatedTime"
defaultMessage="Updated {time}"
values={{
time: (
<FormattedRelativePreferenceDate
value={timestamp}
dateFormat="MMM D, YYYY"
relativeThresholdInHrs={ONE_WEEK_IN_HOURS}
/>
),
}}
/>
</span>
),
link: {
callback: toggleDetails,
tooltip: (
<FormattedMessage
id="xpack.securitySolution.flyout.entityDetails.showAssetDocument"
defaultMessage="Show asset details"
/>
),
},
}}
expand={{ expandable: false }}
>
{children}
</ExpandablePanel>
);
};

View file

@ -12,13 +12,13 @@ import { css } from '@emotion/react';
import { FormattedMessage } from '@kbn/i18n-react';
import * as i18n from './translations';
import type { ObservedUserData } from './types';
import { useObservedUserItems } from './hooks';
import { BasicTable } from '../../../../common/components/ml/tables/basic_table';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
import { getObservedUserTableColumns } from './columns';
import { ONE_WEEK_IN_HOURS } from './constants';
import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect';
import { OBSERVED_USER_QUERY_ID } from '../../../../explore/users/containers/users/observed_details';
import { useObservedUserItems } from './hooks/use_observed_user_items';
export const ObservedUser = ({
observedUser,
@ -44,7 +44,7 @@ export const ObservedUser = ({
<>
<InspectButtonContainer>
<EuiAccordion
initialIsOpen={false}
initialIsOpen={true}
isLoading={observedUser.isLoading}
id="observedUser-data"
data-test-subj="observedUser-data"

View file

@ -46,17 +46,17 @@ export const OBSERVED_DATA_TITLE = i18n.translate(
}
);
export const HIDE_AZURE_DATA_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.hideManagedDataButton',
export const ENTRA_DATA_PANEL_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.EntraDataPanelTitle',
{
defaultMessage: 'Hide Azure AD data',
defaultMessage: 'Entra ID data',
}
);
export const SHOW_AZURE_DATA_BUTTON = i18n.translate(
'xpack.securitySolution.timeline.userDetails.showManagedDataButton',
export const OKTA_DATA_PANEL_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.hideOktaDataPanelTitle',
{
defaultMessage: 'Show Azure AD data',
defaultMessage: 'Okta data',
}
);
@ -124,31 +124,6 @@ export const IP_ADDRESSES = i18n.translate(
}
);
export const FULL_NAME = i18n.translate(
'xpack.securitySolution.timeline.userDetails.fullNameLabel',
{
defaultMessage: 'Full name',
}
);
export const FIRST_NAME = i18n.translate(
'xpack.securitySolution.timeline.userDetails.firstNameLabel',
{
defaultMessage: 'First name',
}
);
export const LAST_NAME = i18n.translate(
'xpack.securitySolution.timeline.userDetails.lastNameLabel',
{
defaultMessage: 'Last name',
}
);
export const PHONE = i18n.translate('xpack.securitySolution.timeline.userDetails.phoneLabel', {
defaultMessage: 'Phone',
});
export const NO_ACTIVE_INTEGRATION_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noActiveIntegrationTitle',
{
@ -171,14 +146,14 @@ export const ADD_EXTERNAL_INTEGRATION_BUTTON = i18n.translate(
}
);
export const NO_AZURE_DATA_TITLE = i18n.translate(
export const NO_MANAGED_DATA_TITLE = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noAzureDataTitle',
{
defaultMessage: 'No metadata found for this user',
}
);
export const NO_AZURE_DATA_TEXT = i18n.translate(
export const NO_MANAGED_DATA_TEXT = i18n.translate(
'xpack.securitySolution.timeline.userDetails.noAzureDataText',
{
defaultMessage:

View file

@ -6,19 +6,18 @@
*/
import type { EuiBasicTableColumn } from '@elastic/eui';
import type { SearchTypes } from '../../../../../common/detection_engine/types';
import type { UserItem } from '../../../../../common/search_strategy';
import type { AzureManagedUser } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import type { ManagedUserHits } from '../../../../../common/search_strategy/security_solution/users/managed_details';
import type { AnomalyTableProviderChildrenProps } from '../../../../common/components/ml/anomaly/anomaly_table_provider';
export interface ObservedUserTable {
label: string;
values: string[] | null | undefined | UserAnomalies;
field: string;
}
export interface ManagedUserTable {
label: string;
value: string | null | undefined;
value: SearchTypes[];
field?: string;
}
@ -35,10 +34,8 @@ export interface ObservedUserData {
export interface ManagedUserData {
isLoading: boolean;
details: AzureManagedUser | undefined;
data: ManagedUserHits | undefined;
isIntegrationEnabled: boolean;
firstSeen: FirstLastSeenData;
lastSeen: FirstLastSeenData;
}
export interface FirstLastSeenData {

View file

@ -5,12 +5,9 @@
* 2.0.
*/
import type { Observable } from 'rxjs';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
import type { CoreStart } from '@kbn/core/public';
import type { FilterManager } from '@kbn/data-plugin/public';
import type { TableById } from '@kbn/securitysolution-data-table';
import type { RootEpicDependencies } from '../../../common/store/epic';
import type { ColumnHeaderOptions, SortColumnTimeline } from '../../../../common/types';
import type { RowRendererId } from '../../../../common/api/timeline';
import type { inputsModel } from '../../../common/store/inputs';
@ -39,14 +36,12 @@ export interface TimelineState {
insertTimeline: InsertTimeline | null;
}
export interface TimelineEpicDependencies<State> {
export interface TimelineEpicDependencies<State> extends RootEpicDependencies {
timelineByIdSelector: (state: State) => TimelineById;
timelineTimeRangeSelector: (state: State) => inputsModel.TimeRange;
selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery;
selectNotesByIdSelector: (state: State) => NotesById;
tableByIdSelector: (state: State) => TableById;
kibana$: Observable<CoreStart>;
storage: Storage;
}
export interface TimelineModelSettings {

View file

@ -16,8 +16,11 @@ Object {
\\"bool\\": {
\\"filter\\": [
{
\\"term\\": {
\\"user.name\\": \\"test-user-name\\"
\\"terms\\": {
\\"event.dataset\\": [
\\"entityanalytics_okta.user\\",
\\"entityanalytics_entra_id.user\\"
]
}
},
{
@ -25,10 +28,52 @@ Object {
\\"event.kind\\": \\"asset\\"
}
}
]
],
\\"should\\": [
{
\\"term\\": {
\\"user.name\\": \\"test-user-name\\"
}
},
{
\\"terms\\": {
\\"user.email\\": [
\\"test-user-name@mail.com\\"
]
}
}
],
\\"minimum_should_match\\": 1
}
},
\\"size\\": 1
\\"size\\": 0,
\\"aggs\\": {
\\"datasets\\": {
\\"terms\\": {
\\"field\\": \\"event.dataset\\"
},
\\"aggs\\": {
\\"latest_hit\\": {
\\"top_hits\\": {
\\"fields\\": [
\\"*\\",
\\"_index\\",
\\"_id\\"
],
\\"_source\\": false,
\\"size\\": 1,
\\"sort\\": [
{
\\"@timestamp\\": {
\\"order\\": \\"desc\\"
}
}
]
}
}
}
}
}
},
\\"sort\\": [
{
@ -44,128 +89,430 @@ Object {
"rawResponse": Object {
"_shards": Object {
"failed": 0,
"skipped": 1,
"skipped": 0,
"successful": 2,
"total": 2,
},
"hits": Object {
"hits": Array [
Object {
"_id": "9AxbIocB-WLv2258YZtS",
"_index": ".test",
"_score": null,
"_source": Object {
"@timestamp": "2023-02-23T20:03:17.489Z",
"agent": Object {
"ephemeral_id": "914fd1fa-aa37-4ab4-b36d-972ab9b19cde",
"id": "9528bb69-1511-4631-a5af-1d7e93c02009",
"name": "docker-fleet-agent",
"type": "filebeat",
"version": "8.8.0",
},
"event": Object {
"action": "user-discovered",
"agent_id_status": "verified",
"dataset": "entityanalytics_azure.users",
"ingested": "2023-02-23T20:03:18Z",
"kind": "asset",
"provider": "Azure AD",
"type": Array [
"user",
"info",
],
},
"host": Object {
"architecture": "x86_64",
"hostname": "docker-fleet-agent",
"id": "cff3d165179d4aef9596ddbb263e3adb",
"ip": Array [
"172.26.0.7",
],
"mac": Array [
"02-42-AC-1A-00-07",
],
"name": "docker-fleet-agent",
"os": Object {
"family": "debian",
"kernel": "5.10.47-linuxkit",
"name": "Ubuntu",
"platform": "ubuntu",
"type": "linux",
"version": "20.04.5 LTS (Focal Fossa)",
"aggregations": Object {
"datasets": Object {
"buckets": Array [
Object {
"doc_count": 122,
"key": "entityanalytics_okta.user",
"latest_hit": Object {
"hits": Object {
"hits": Array [
Object {
"_id": "Bnwi8osBcjOsowlA78aM",
"_index": ".ds-logs-entityanalytics_okta.user-default-2023.11.15-000001",
"_score": null,
"fields": Object {
"@timestamp": Array [
"2023-11-28T13:32:54.446Z",
],
"_id": Array [
"XoEiFowBcjOsowlAIN1T",
],
"_index": Array [
".ds-logs-entityanalytics_okta.user-default-2023.11.15-000001",
],
"agent.ephemeral_id": Array [
"7ddc108f-026a-4a20-afc1-ebc983145df4",
],
"agent.id": Array [
"ced095f0-df97-4bdc-86a9-25cc11238317",
],
"agent.name": Array [
"docker-fleet-agent",
],
"agent.type": Array [
"filebeat",
],
"agent.version": Array [
"8.12.0",
],
"asset.category": Array [
"entity",
],
"asset.create_date": Array [
"2023-11-14T16:33:53.000Z",
],
"asset.id": Array [
"00ud9ohoh9ww644Px5d7",
],
"asset.last_seen": Array [
"2023-11-21T08:08:46.000Z",
],
"asset.last_status_change_date": Array [
"2023-11-15T07:09:05.000Z",
],
"asset.last_updated": Array [
"2023-11-21T08:14:56.000Z",
],
"asset.status": Array [
"ACTIVE",
],
"asset.type": Array [
"okta_user",
],
"data_stream.dataset": Array [
"entityanalytics_okta.user",
],
"data_stream.namespace": Array [
"default",
],
"data_stream.type": Array [
"logs",
],
"ecs.version": Array [
"8.11.0",
],
"elastic_agent.id": Array [
"ced095f0-df97-4bdc-86a9-25cc11238317",
],
"elastic_agent.snapshot": Array [
true,
],
"elastic_agent.version": Array [
"8.12.0",
],
"entityanalytics_okta.user._links": Array [
Object {
"self": Object {
"href": "https://dev-36006609.okta.com/api/v1/users/00ud9ohoh9ww644Px5d7",
},
},
],
"entityanalytics_okta.user.type": Array [
Object {
"id": "otyf1r6hlGf9AXhZ95d6",
},
],
"event.action": Array [
"user-modified",
],
"event.agent_id_status": Array [
"verified",
],
"event.category": Array [
"iam",
],
"event.dataset": Array [
"entityanalytics_okta.user",
],
"event.ingested": Array [
"2023-11-28T13:33:04Z",
],
"event.kind": Array [
"asset",
],
"event.module": Array [
"entityanalytics_okta",
],
"event.type": Array [
"user",
"info",
],
"input.type": Array [
"entity-analytics",
],
"labels.identity_source": Array [
"entity-analytics-entityanalytics_okta.user-be940503-bec8-4849-8ec7-2b526d6f2609",
],
"related.user": Array [
"00ud9ohoh9ww644Px5d7",
"test@elastic.co",
"Test",
"User",
],
"tags": Array [
"forwarded",
"entityanalytics_okta-user",
],
"user.account.activated_date": Array [
"2023-11-14T16:33:54.000Z",
],
"user.account.change_date": Array [
"2023-11-15T07:09:05.000Z",
],
"user.account.create_date": Array [
"2023-11-14T16:33:53.000Z",
],
"user.account.password_change_date": Array [
"2023-11-15T07:09:05.000Z",
],
"user.account.status.deprovisioned": Array [
false,
],
"user.account.status.locked_out": Array [
false,
],
"user.account.status.password_expired": Array [
false,
],
"user.account.status.recovery": Array [
false,
],
"user.account.status.suspended": Array [
false,
],
"user.email": Array [
"test@elastic.co",
],
"user.geo.city_name": Array [
"Adam",
],
"user.geo.country_iso_code": Array [
"NL",
],
"user.id": Array [
"00ud9ohoh9ww644Px5d7",
],
"user.name": Array [
"test@elastic.co",
],
"user.name.text": Array [
"test@elastic.co",
],
"user.profile.first_name": Array [
"User First Name",
],
"user.profile.job_title": Array [
"Unit Test Writer",
],
"user.profile.last_name": Array [
"Test Last Name",
],
"user.profile.mobile_phone": Array [
"99999999",
],
"user.profile.primaryPhone": Array [
"99999999",
],
"user.profile.status": Array [
"ACTIVE",
],
},
"sort": Array [
1700574447551,
],
},
],
"max_score": null,
"total": Object {
"relation": "eq",
"value": 122,
},
},
},
"user": Object {
"email": "tes.user@elastic.co",
"first_name": "Taylor",
"full_name": "Test user",
"id": "39fac578-91fb-47f6-8f7a-fab05ce70d8b",
"last_name": "Test last name",
"phone": Array [
"1235559999",
],
},
},
"sort": Array [
1677182597489,
],
},
],
],
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
},
},
"hits": Object {
"hits": Array [],
"max_score": null,
},
"timed_out": false,
"took": 124,
"took": 5,
},
"total": 21,
"userDetails": Object {
"@timestamp": "2023-02-23T20:03:17.489Z",
"agent": Object {
"ephemeral_id": "914fd1fa-aa37-4ab4-b36d-972ab9b19cde",
"id": "9528bb69-1511-4631-a5af-1d7e93c02009",
"name": "docker-fleet-agent",
"type": "filebeat",
"version": "8.8.0",
},
"event": Object {
"action": "user-discovered",
"agent_id_status": "verified",
"dataset": "entityanalytics_azure.users",
"ingested": "2023-02-23T20:03:18Z",
"kind": "asset",
"provider": "Azure AD",
"type": Array [
"user",
"info",
],
},
"host": Object {
"architecture": "x86_64",
"hostname": "docker-fleet-agent",
"id": "cff3d165179d4aef9596ddbb263e3adb",
"ip": Array [
"172.26.0.7",
],
"mac": Array [
"02-42-AC-1A-00-07",
],
"name": "docker-fleet-agent",
"os": Object {
"family": "debian",
"kernel": "5.10.47-linuxkit",
"name": "Ubuntu",
"platform": "ubuntu",
"type": "linux",
"version": "20.04.5 LTS (Focal Fossa)",
"users": Object {
"entityanalytics_okta.user": Object {
"_id": "Bnwi8osBcjOsowlA78aM",
"_index": ".ds-logs-entityanalytics_okta.user-default-2023.11.15-000001",
"_score": null,
"fields": Object {
"@timestamp": Array [
"2023-11-28T13:32:54.446Z",
],
"_id": Array [
"XoEiFowBcjOsowlAIN1T",
],
"_index": Array [
".ds-logs-entityanalytics_okta.user-default-2023.11.15-000001",
],
"agent.ephemeral_id": Array [
"7ddc108f-026a-4a20-afc1-ebc983145df4",
],
"agent.id": Array [
"ced095f0-df97-4bdc-86a9-25cc11238317",
],
"agent.name": Array [
"docker-fleet-agent",
],
"agent.type": Array [
"filebeat",
],
"agent.version": Array [
"8.12.0",
],
"asset.category": Array [
"entity",
],
"asset.create_date": Array [
"2023-11-14T16:33:53.000Z",
],
"asset.id": Array [
"00ud9ohoh9ww644Px5d7",
],
"asset.last_seen": Array [
"2023-11-21T08:08:46.000Z",
],
"asset.last_status_change_date": Array [
"2023-11-15T07:09:05.000Z",
],
"asset.last_updated": Array [
"2023-11-21T08:14:56.000Z",
],
"asset.status": Array [
"ACTIVE",
],
"asset.type": Array [
"okta_user",
],
"data_stream.dataset": Array [
"entityanalytics_okta.user",
],
"data_stream.namespace": Array [
"default",
],
"data_stream.type": Array [
"logs",
],
"ecs.version": Array [
"8.11.0",
],
"elastic_agent.id": Array [
"ced095f0-df97-4bdc-86a9-25cc11238317",
],
"elastic_agent.snapshot": Array [
true,
],
"elastic_agent.version": Array [
"8.12.0",
],
"entityanalytics_okta.user._links": Array [
Object {
"self": Object {
"href": "https://dev-36006609.okta.com/api/v1/users/00ud9ohoh9ww644Px5d7",
},
},
],
"entityanalytics_okta.user.type": Array [
Object {
"id": "otyf1r6hlGf9AXhZ95d6",
},
],
"event.action": Array [
"user-modified",
],
"event.agent_id_status": Array [
"verified",
],
"event.category": Array [
"iam",
],
"event.dataset": Array [
"entityanalytics_okta.user",
],
"event.ingested": Array [
"2023-11-28T13:33:04Z",
],
"event.kind": Array [
"asset",
],
"event.module": Array [
"entityanalytics_okta",
],
"event.type": Array [
"user",
"info",
],
"input.type": Array [
"entity-analytics",
],
"labels.identity_source": Array [
"entity-analytics-entityanalytics_okta.user-be940503-bec8-4849-8ec7-2b526d6f2609",
],
"related.user": Array [
"00ud9ohoh9ww644Px5d7",
"test@elastic.co",
"Test",
"User",
],
"tags": Array [
"forwarded",
"entityanalytics_okta-user",
],
"user.account.activated_date": Array [
"2023-11-14T16:33:54.000Z",
],
"user.account.change_date": Array [
"2023-11-15T07:09:05.000Z",
],
"user.account.create_date": Array [
"2023-11-14T16:33:53.000Z",
],
"user.account.password_change_date": Array [
"2023-11-15T07:09:05.000Z",
],
"user.account.status.deprovisioned": Array [
false,
],
"user.account.status.locked_out": Array [
false,
],
"user.account.status.password_expired": Array [
false,
],
"user.account.status.recovery": Array [
false,
],
"user.account.status.suspended": Array [
false,
],
"user.email": Array [
"test@elastic.co",
],
"user.geo.city_name": Array [
"Adam",
],
"user.geo.country_iso_code": Array [
"NL",
],
"user.id": Array [
"00ud9ohoh9ww644Px5d7",
],
"user.name": Array [
"test@elastic.co",
],
"user.name.text": Array [
"test@elastic.co",
],
"user.profile.first_name": Array [
"User First Name",
],
"user.profile.job_title": Array [
"Unit Test Writer",
],
"user.profile.last_name": Array [
"Test Last Name",
],
"user.profile.mobile_phone": Array [
"99999999",
],
"user.profile.primaryPhone": Array [
"99999999",
],
"user.profile.status": Array [
"ACTIVE",
],
},
},
"user": Object {
"email": "tes.user@elastic.co",
"first_name": "Taylor",
"full_name": "Test user",
"id": "39fac578-91fb-47f6-8f7a-fab05ce70d8b",
"last_name": "Test last name",
"phone": Array [
"1235559999",
"sort": Array [
1700574447551,
],
},
},

View file

@ -4,12 +4,42 @@ exports[`buildManagedUserDetailsQuery build query from options correctly 1`] = `
Object {
"allow_no_indices": true,
"body": Object {
"aggs": Object {
"datasets": Object {
"aggs": Object {
"latest_hit": Object {
"top_hits": Object {
"_source": false,
"fields": Array [
"*",
"_index",
"_id",
],
"size": 1,
"sort": Array [
Object {
"@timestamp": Object {
"order": "desc",
},
},
],
},
},
},
"terms": Object {
"field": "event.dataset",
},
},
},
"query": Object {
"bool": Object {
"filter": Array [
Object {
"term": Object {
"user.name": "test-user-name",
"terms": Object {
"event.dataset": Array [
"entityanalytics_okta.user",
"entityanalytics_entra_id.user",
],
},
},
Object {
@ -18,9 +48,24 @@ Object {
},
},
],
"minimum_should_match": 1,
"should": Array [
Object {
"term": Object {
"user.name": "test-user-name",
},
},
Object {
"terms": Object {
"user.email": Array [
"test-user-name@mail.com",
],
},
},
],
},
},
"size": 1,
"size": 0,
},
"ignore_unavailable": true,
"index": Array [

View file

@ -7,7 +7,7 @@
import * as buildQuery from './query.managed_user_details.dsl';
import { managedUserDetails } from '.';
import type { AzureManagedUser } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import type { ManagedUserFields } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import type { IEsSearchResponse } from '@kbn/data-plugin/public';
import type { ManagedUserDetailsRequestOptionsInput } from '../../../../../../common/api/search_strategy';
import { UsersQueries } from '../../../../../../common/api/search_strategy';
@ -15,74 +15,126 @@ import { UsersQueries } from '../../../../../../common/api/search_strategy';
export const mockOptions: ManagedUserDetailsRequestOptionsInput = {
defaultIndex: ['logs-*'],
userName: 'test-user-name',
userEmail: ['test-user-name@mail.com'],
factoryQueryType: UsersQueries.managedDetails,
};
export const mockSearchStrategyResponse: IEsSearchResponse<AzureManagedUser> = {
export const mockSearchStrategyResponse: IEsSearchResponse<ManagedUserFields> = {
isPartial: false,
isRunning: false,
rawResponse: {
took: 124,
took: 5,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 1,
skipped: 0,
failed: 0,
},
hits: {
max_score: null,
hits: [
{
_index: '.test',
_id: '9AxbIocB-WLv2258YZtS',
_score: null,
_source: {
agent: {
name: 'docker-fleet-agent',
id: '9528bb69-1511-4631-a5af-1d7e93c02009',
type: 'filebeat',
ephemeral_id: '914fd1fa-aa37-4ab4-b36d-972ab9b19cde',
version: '8.8.0',
},
'@timestamp': '2023-02-23T20:03:17.489Z',
host: {
hostname: 'docker-fleet-agent',
os: {
kernel: '5.10.47-linuxkit',
name: 'Ubuntu',
type: 'linux',
family: 'debian',
version: '20.04.5 LTS (Focal Fossa)',
platform: 'ubuntu',
hits: [],
},
aggregations: {
datasets: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'entityanalytics_okta.user',
doc_count: 122,
latest_hit: {
hits: {
total: {
value: 122,
relation: 'eq',
},
max_score: null,
hits: [
{
_index: '.ds-logs-entityanalytics_okta.user-default-2023.11.15-000001',
_id: 'Bnwi8osBcjOsowlA78aM',
_score: null,
fields: {
'elastic_agent.version': ['8.12.0'],
'event.category': ['iam'],
'entityanalytics_okta.user.type': [
{
id: 'otyf1r6hlGf9AXhZ95d6',
},
],
'user.profile.last_name': ['Test Last Name'],
'asset.last_seen': ['2023-11-21T08:08:46.000Z'],
'user.profile.status': ['ACTIVE'],
'asset.status': ['ACTIVE'],
'asset.id': ['00ud9ohoh9ww644Px5d7'],
'user.account.status.deprovisioned': [false],
'agent.name': ['docker-fleet-agent'],
'user.account.status.locked_out': [false],
'event.agent_id_status': ['verified'],
'event.kind': ['asset'],
'user.profile.job_title': ['Unit Test Writer'],
'user.profile.first_name': ['User First Name'],
'user.id': ['00ud9ohoh9ww644Px5d7'],
'user.account.status.suspended': [false],
'input.type': ['entity-analytics'],
'entityanalytics_okta.user._links': [
{
self: {
href: 'https://dev-36006609.okta.com/api/v1/users/00ud9ohoh9ww644Px5d7',
},
},
],
'data_stream.type': ['logs'],
'related.user': ['00ud9ohoh9ww644Px5d7', 'test@elastic.co', 'Test', 'User'],
tags: ['forwarded', 'entityanalytics_okta-user'],
'agent.id': ['ced095f0-df97-4bdc-86a9-25cc11238317'],
'ecs.version': ['8.11.0'],
'asset.last_updated': ['2023-11-21T08:14:56.000Z'],
'labels.identity_source': [
'entity-analytics-entityanalytics_okta.user-be940503-bec8-4849-8ec7-2b526d6f2609',
],
'agent.version': ['8.12.0'],
_id: ['XoEiFowBcjOsowlAIN1T'],
'asset.category': ['entity'],
'user.account.status.recovery': [false],
_index: ['.ds-logs-entityanalytics_okta.user-default-2023.11.15-000001'],
'user.account.activated_date': ['2023-11-14T16:33:54.000Z'],
'asset.type': ['okta_user'],
'user.name': ['test@elastic.co'],
'asset.last_status_change_date': ['2023-11-15T07:09:05.000Z'],
'user.profile.mobile_phone': ['99999999'],
'user.geo.city_name': ['Adam'],
'agent.type': ['filebeat'],
'event.module': ['entityanalytics_okta'],
'user.email': ['test@elastic.co'],
'user.profile.primaryPhone': ['99999999'],
'elastic_agent.snapshot': [true],
'asset.create_date': ['2023-11-14T16:33:53.000Z'],
'elastic_agent.id': ['ced095f0-df97-4bdc-86a9-25cc11238317'],
'user.account.create_date': ['2023-11-14T16:33:53.000Z'],
'data_stream.namespace': ['default'],
'user.account.change_date': ['2023-11-15T07:09:05.000Z'],
'user.geo.country_iso_code': ['NL'],
'event.action': ['user-modified'],
'event.ingested': ['2023-11-28T13:33:04Z'],
'@timestamp': ['2023-11-28T13:32:54.446Z'],
'data_stream.dataset': ['entityanalytics_okta.user'],
'event.type': ['user', 'info'],
'agent.ephemeral_id': ['7ddc108f-026a-4a20-afc1-ebc983145df4'],
'user.account.status.password_expired': [false],
'user.account.password_change_date': ['2023-11-15T07:09:05.000Z'],
'event.dataset': ['entityanalytics_okta.user'],
'user.name.text': ['test@elastic.co'],
},
sort: [1700574447551],
},
],
},
ip: ['172.26.0.7'],
name: 'docker-fleet-agent',
id: 'cff3d165179d4aef9596ddbb263e3adb',
mac: ['02-42-AC-1A-00-07'],
architecture: 'x86_64',
},
event: {
agent_id_status: 'verified',
ingested: '2023-02-23T20:03:18Z',
provider: 'Azure AD',
kind: 'asset',
action: 'user-discovered',
type: ['user', 'info'],
dataset: 'entityanalytics_azure.users',
},
user: {
full_name: 'Test user',
phone: ['1235559999'],
last_name: 'Test last name',
id: '39fac578-91fb-47f6-8f7a-fab05ce70d8b',
first_name: 'Taylor',
email: 'tes.user@elastic.co',
},
},
sort: [1677182597489],
},
],
],
},
},
},
total: 21,

View file

@ -7,33 +7,65 @@
import type { IEsSearchResponse } from '@kbn/data-plugin/common';
import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import { getOr } from 'lodash/fp';
import { inspectStringifyObject } from '../../../../../utils/build_query';
import type { SecuritySolutionFactory } from '../../types';
import { buildManagedUserDetailsQuery } from './query.managed_user_details.dsl';
import type { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users';
import type {
AzureManagedUser,
ManagedUserHits,
ManagedUserHit,
ManagedUserDetailsStrategyResponse,
ManagedUserFields,
} from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import { ManagedUserDatasetKey } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
interface ManagedUserBucket {
key: ManagedUserDatasetKey;
latest_hit: SearchResponse<ManagedUserFields | ManagedUserFields>;
}
export const managedUserDetails: SecuritySolutionFactory<UsersQueries.managedDetails> = {
buildDsl: (options) => buildManagedUserDetailsQuery(options),
parse: async (
options,
response: IEsSearchResponse<AzureManagedUser>
response: IEsSearchResponse<ManagedUserFields>
): Promise<ManagedUserDetailsStrategyResponse> => {
const inspect = {
dsl: [inspectStringifyObject(buildManagedUserDetailsQuery(options))],
};
const hits = response.rawResponse.hits.hits;
const userDetails = hits.length > 0 ? hits[0]._source : undefined;
const buckets: ManagedUserBucket[] = getOr(
[],
'aggregations.datasets.buckets',
response.rawResponse
);
const managedUsers: ManagedUserHits = buckets.reduce(
(acc: ManagedUserHits, bucket: ManagedUserBucket) => {
acc[bucket.key] = bucket.latest_hit.hits.hits[0] as unknown as ManagedUserHit;
return acc;
},
{} as ManagedUserHits
);
if (buckets.length === 0) {
return {
...response,
inspect,
users: {
[ManagedUserDatasetKey.ENTRA]: undefined,
[ManagedUserDatasetKey.OKTA]: undefined,
},
};
}
return {
...response,
inspect,
userDetails,
users: managedUsers,
};
},
};

View file

@ -11,8 +11,9 @@ import { buildManagedUserDetailsQuery } from './query.managed_user_details.dsl';
export const mockOptions: ManagedUserDetailsRequestOptions = {
defaultIndex: ['logs-*'],
userName: 'test-user-name',
userEmail: ['test-user-name@mail.com'],
factoryQueryType: UsersQueries.managedDetails,
userName: 'test-user-name',
};
describe('buildManagedUserDetailsQuery', () => {

View file

@ -6,14 +6,29 @@
*/
import type { ISearchRequestParams } from '@kbn/data-plugin/common';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { ManagedUserDatasetKey } from '../../../../../../common/search_strategy/security_solution/users/managed_details';
import type { ManagedUserDetailsRequestOptions } from '../../../../../../common/api/search_strategy';
import { EVENT_KIND_ASSET_FILTER } from '../../../../../../common/search_strategy';
export const buildManagedUserDetailsQuery = ({
userEmail,
userName,
defaultIndex,
}: ManagedUserDetailsRequestOptions): ISearchRequestParams => {
const filter = [{ term: { 'user.name': userName } }, EVENT_KIND_ASSET_FILTER];
const should: QueryDslQueryContainer[] = [{ term: { 'user.name': userName } }];
if (userEmail) {
const emailQuery = { terms: { 'user.email': userEmail } };
should.push(emailQuery);
}
const filter = [
{
terms: { 'event.dataset': [ManagedUserDatasetKey.OKTA, ManagedUserDatasetKey.ENTRA] },
},
EVENT_KIND_ASSET_FILTER,
];
const dslQuery = {
allow_no_indices: true,
@ -21,8 +36,31 @@ export const buildManagedUserDetailsQuery = ({
ignore_unavailable: true,
track_total_hits: false,
body: {
query: { bool: { filter } },
size: 1,
query: { bool: { filter, should, minimum_should_match: 1 } },
size: 0,
aggs: {
datasets: {
terms: {
field: 'event.dataset',
},
aggs: {
latest_hit: {
top_hits: {
fields: ['*', '_index', '_id'], // '_index' and '_id' are not returned by default
_source: false,
size: 1,
sort: [
{
'@timestamp': {
order: 'desc' as const,
},
},
],
},
},
},
},
},
},
sort: [{ '@timestamp': 'desc' }],
};

View file

@ -36612,13 +36612,9 @@
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "Impossible de lancer la recherche sur des données gérées par l'utilisateur",
"xpack.securitySolution.timeline.userDetails.familyLabel": "Famille",
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "Champ",
"xpack.securitySolution.timeline.userDetails.firstNameLabel": "Prénom",
"xpack.securitySolution.timeline.userDetails.firstSeenLabel": "Vu en premier",
"xpack.securitySolution.timeline.userDetails.fullNameLabel": "Nom complet",
"xpack.securitySolution.timeline.userDetails.hideManagedDataButton": "Masquer les données Azure AD",
"xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "Système d'exploitation",
"xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "Adresses IP",
"xpack.securitySolution.timeline.userDetails.lastNameLabel": "Nom",
"xpack.securitySolution.timeline.userDetails.lastSeenLabel": "Vu en dernier",
"xpack.securitySolution.timeline.userDetails.managedBadge": "GÉRÉ",
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "Données gérées",
@ -36631,9 +36627,7 @@
"xpack.securitySolution.timeline.userDetails.observedBadge": "OBSERVÉ",
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "Données observées",
"xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "Utilisateur observé",
"xpack.securitySolution.timeline.userDetails.phoneLabel": "Téléphone",
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "Score de risque",
"xpack.securitySolution.timeline.userDetails.showManagedDataButton": "Afficher les données Azure AD",
"xpack.securitySolution.timeline.userDetails.userIdLabel": "ID utilisateur",
"xpack.securitySolution.timeline.userDetails.userLabel": "Utilisateur",
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "Valeurs",

View file

@ -36611,13 +36611,9 @@
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "ユーザーが管理するデータで検索を実行できませんでした",
"xpack.securitySolution.timeline.userDetails.familyLabel": "ファミリー",
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "フィールド",
"xpack.securitySolution.timeline.userDetails.firstNameLabel": "名",
"xpack.securitySolution.timeline.userDetails.firstSeenLabel": "初回の認識",
"xpack.securitySolution.timeline.userDetails.fullNameLabel": "フルネーム",
"xpack.securitySolution.timeline.userDetails.hideManagedDataButton": "Azure ADデータを非表示",
"xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "オペレーティングシステム",
"xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "IP アドレス",
"xpack.securitySolution.timeline.userDetails.lastNameLabel": "姓",
"xpack.securitySolution.timeline.userDetails.lastSeenLabel": "前回の認識",
"xpack.securitySolution.timeline.userDetails.managedBadge": "管理対象",
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "管理対象のデータ",
@ -36630,9 +36626,7 @@
"xpack.securitySolution.timeline.userDetails.observedBadge": "観測済み",
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "観測されたデータ",
"xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "観測されたユーザー",
"xpack.securitySolution.timeline.userDetails.phoneLabel": "電話",
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "リスクスコア",
"xpack.securitySolution.timeline.userDetails.showManagedDataButton": "Azure ADデータを表示",
"xpack.securitySolution.timeline.userDetails.userIdLabel": "ユーザーID",
"xpack.securitySolution.timeline.userDetails.userLabel": "ユーザー",
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "値",

View file

@ -36606,13 +36606,9 @@
"xpack.securitySolution.timeline.userDetails.failManagedUserDescription": "无法对用户托管数据执行搜索",
"xpack.securitySolution.timeline.userDetails.familyLabel": "系列",
"xpack.securitySolution.timeline.userDetails.fieldColumnTitle": "字段",
"xpack.securitySolution.timeline.userDetails.firstNameLabel": "名字",
"xpack.securitySolution.timeline.userDetails.firstSeenLabel": "首次看到时间",
"xpack.securitySolution.timeline.userDetails.fullNameLabel": "全名",
"xpack.securitySolution.timeline.userDetails.hideManagedDataButton": "隐藏 Azure AD 数据",
"xpack.securitySolution.timeline.userDetails.hostOsNameLabel": "操作系统",
"xpack.securitySolution.timeline.userDetails.ipAddressesLabel": "IP 地址",
"xpack.securitySolution.timeline.userDetails.lastNameLabel": "姓氏",
"xpack.securitySolution.timeline.userDetails.lastSeenLabel": "最后看到时间",
"xpack.securitySolution.timeline.userDetails.managedBadge": "托管",
"xpack.securitySolution.timeline.userDetails.managedDataTitle": "托管数据",
@ -36625,9 +36621,7 @@
"xpack.securitySolution.timeline.userDetails.observedBadge": "已观察",
"xpack.securitySolution.timeline.userDetails.observedDataTitle": "观察数据",
"xpack.securitySolution.timeline.userDetails.observedUserInspectTitle": "已观察用户",
"xpack.securitySolution.timeline.userDetails.phoneLabel": "电话",
"xpack.securitySolution.timeline.userDetails.riskScoreLabel": "风险分数",
"xpack.securitySolution.timeline.userDetails.showManagedDataButton": "显示 Azure AD 数据",
"xpack.securitySolution.timeline.userDetails.userIdLabel": "用户 ID",
"xpack.securitySolution.timeline.userDetails.userLabel": "用户",
"xpack.securitySolution.timeline.userDetails.valuesColumnTitle": "值",