[Entity Analytics][Privmon] CSV upload (#221798)

## Summary
 
This PR adds the ability to upload a CSV file with privileged users from
the Entity Analytics pages

## Changes

### Backend

- Added (or completed) the upload CSV route:
`/api/entity_analytics/monitoring/users/_csv`
- Added shared utilities for batching with Node streams
- Added bulk processing actions for the upload
  - Parsing users from CSV
  - Soft delete for omitted users 
  - Batch upsert via the bulk API
- Added a check for installing all required privmon resources

### Frontend

- File uploader components
- File validation logic
- Updated EA privmon page to account for the new flow
- Added managing users panels 
  - open upload flow (same as asset criticality)

## Screen recording


https://github.com/user-attachments/assets/7956f1cf-49e0-4430-8c23-7d6178a15342

## How to test

#### Prerequisite

Make sure you have a CSV file with usernames
Check
[here](https://gist.github.com/tiansivive/0be2f09e1bb380fdde6609a131e929ed)
for a little helper script

Create a few copies where some of the users are deleted, in order to
test soft delete

1. Start up kibana and ES
2. Navigate to Security > Entity Analytics > Privilege User Monitoring
3. Select the `File` option to add data
4. Add one of the CSV files to the open modal and upload
5. Repeat but now upload one of files with the omitted users 

Alternatively, testing only the backend only is possible by directly
hitting the API wit curl
```
curl -u elastic:changeme \
  -X POST "http://localhost:5601/api/entity_analytics/monitoring/users/_csv" \
  -H "kbn-xsrf: true" \
  -F "file=@test.csv;type=text/csv"
```

#### Verifying

Easiest way is to use the dev tools to `_search` the privmon users index
with:
```
GET .entity_analytics.monitoring.users-default/_search
```

Look for number of hits and/or use `query` to search for omitted users. 


## Remaining work

- [x] API integration tests
- [ ] Batching logic unit tests
- [ ] E2E tests?

---------

Co-authored-by: machadoum <pablo.nevesmachado@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Tiago Vila Verde 2025-06-18 15:23:03 +02:00 committed by GitHub
parent 5e28a4200f
commit a8a7574c66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 3065 additions and 389 deletions

View file

@ -11146,58 +11146,48 @@ paths:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_csv:
post:
operationId: BulkUploadUsersCSV
operationId: PrivmonBulkUploadUsersCSV
requestBody:
content:
text/csv:
schema:
type: string
description: CSV file containing users to upsert
required: true
responses:
'200':
content:
application/json:
schema:
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_json:
post:
operationId: BulkUploadUsersJSON
requestBody:
content:
text/json:
multipart/form-data:
schema:
type: object
properties:
users:
items:
type: object
properties:
is_monitored:
type: boolean
user_name:
type: string
type: array
description: JSON file containing users to upsert
required: true
file:
description: The CSV file to upload.
format: binary
type: string
required:
- file
responses:
'200':
content:
application/json:
schema:
example:
errors:
- index: 1
message: Invalid monitored field
username: john.doe
stats:
failed: 1
successful: 1
total: 2
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via JSON upload
errors:
items:
$ref: '#/components/schemas/Security_Entity_Analytics_API_PrivmonUserCsvUploadErrorItem'
type: array
stats:
$ref: '#/components/schemas/Security_Entity_Analytics_API_PrivmonUserCsvUploadStats'
required:
- errors
- stats
description: Bulk upload successful
'413':
description: File too large
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/{id}:
@ -66988,6 +66978,34 @@ components:
required:
- type
- status
Security_Entity_Analytics_API_PrivmonUserCsvUploadErrorItem:
type: object
properties:
index:
nullable: true
type: integer
message:
type: string
username:
nullable: true
type: string
required:
- message
- index
- username
Security_Entity_Analytics_API_PrivmonUserCsvUploadStats:
type: object
properties:
failed:
type: integer
successful:
type: integer
total:
type: integer
required:
- successful
- failed
- total
Security_Entity_Analytics_API_RiskEngineScheduleNowErrorResponse:
type: object
properties:

View file

@ -13305,58 +13305,48 @@ paths:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_csv:
post:
operationId: BulkUploadUsersCSV
operationId: PrivmonBulkUploadUsersCSV
requestBody:
content:
text/csv:
schema:
type: string
description: CSV file containing users to upsert
required: true
responses:
'200':
content:
application/json:
schema:
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_json:
post:
operationId: BulkUploadUsersJSON
requestBody:
content:
text/json:
multipart/form-data:
schema:
type: object
properties:
users:
items:
type: object
properties:
is_monitored:
type: boolean
user_name:
type: string
type: array
description: JSON file containing users to upsert
required: true
file:
description: The CSV file to upload.
format: binary
type: string
required:
- file
responses:
'200':
content:
application/json:
schema:
example:
errors:
- index: 1
message: Invalid monitored field
username: john.doe
stats:
failed: 1
successful: 1
total: 2
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via JSON upload
errors:
items:
$ref: '#/components/schemas/Security_Entity_Analytics_API_PrivmonUserCsvUploadErrorItem'
type: array
stats:
$ref: '#/components/schemas/Security_Entity_Analytics_API_PrivmonUserCsvUploadStats'
required:
- errors
- stats
description: Bulk upload successful
'413':
description: File too large
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/{id}:
@ -76488,6 +76478,34 @@ components:
required:
- type
- status
Security_Entity_Analytics_API_PrivmonUserCsvUploadErrorItem:
type: object
properties:
index:
nullable: true
type: integer
message:
type: string
username:
nullable: true
type: string
required:
- message
- index
- username
Security_Entity_Analytics_API_PrivmonUserCsvUploadStats:
type: object
properties:
failed:
type: integer
successful:
type: integer
total:
type: integer
required:
- successful
- failed
- total
Security_Entity_Analytics_API_RiskEngineScheduleNowErrorResponse:
type: object
properties:

View file

@ -16,7 +16,22 @@
import { z } from '@kbn/zod';
export type BulkUploadUsersCSVResponse = z.infer<typeof BulkUploadUsersCSVResponse>;
export const BulkUploadUsersCSVResponse = z.object({
upserted_count: z.number().int().optional(),
export type PrivmonUserCsvUploadErrorItem = z.infer<typeof PrivmonUserCsvUploadErrorItem>;
export const PrivmonUserCsvUploadErrorItem = z.object({
message: z.string(),
username: z.string().nullable(),
index: z.number().int().nullable(),
});
export type PrivmonUserCsvUploadStats = z.infer<typeof PrivmonUserCsvUploadStats>;
export const PrivmonUserCsvUploadStats = z.object({
successful: z.number().int(),
failed: z.number().int(),
total: z.number().int(),
});
export type PrivmonBulkUploadUsersCSVResponse = z.infer<typeof PrivmonBulkUploadUsersCSVResponse>;
export const PrivmonBulkUploadUsersCSVResponse = z.object({
errors: z.array(PrivmonUserCsvUploadErrorItem),
stats: PrivmonUserCsvUploadStats,
});

View file

@ -8,22 +8,77 @@ paths:
post:
x-labels: [ess, serverless]
x-codegen-enabled: true
operationId: BulkUploadUsersCSV
operationId: PrivmonBulkUploadUsersCSV
summary: Upsert multiple monitored users via CSV upload
requestBody:
description: CSV file containing users to upsert
required: true
content:
text/csv:
multipart/form-data:
schema:
type: string
type: object
properties:
file:
type: string
format: binary
description: The CSV file to upload.
required:
- file
responses:
"200":
description: Successful response
description: Bulk upload successful
content:
application/json:
schema:
type: object
example:
errors:
- message: "Invalid monitored field"
username: "john.doe"
index: 1
stats:
successful: 1
failed: 1
total: 2
properties:
upserted_count:
type: integer
errors:
type: array
items:
$ref: "#/components/schemas/PrivmonUserCsvUploadErrorItem"
stats:
$ref: "#/components/schemas/PrivmonUserCsvUploadStats"
required:
- errors
- stats
"413":
description: File too large
components:
schemas:
PrivmonUserCsvUploadErrorItem:
type: object
properties:
message:
type: string
username:
type: string
nullable: true
index:
type: integer
nullable: true
required:
- message
- index
- username
PrivmonUserCsvUploadStats:
type: object
properties:
successful:
type: integer
failed:
type: integer
total:
type: integer
required:
- successful
- failed
- total

View file

@ -1,22 +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.
*/
/*
* NOTICE: Do not edit this file manually.
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
*
* info:
* title: Privileged User Monitoring API
* version: 2023-10-31
*/
import { z } from '@kbn/zod';
export type BulkUploadUsersJSONResponse = z.infer<typeof BulkUploadUsersJSONResponse>;
export const BulkUploadUsersJSONResponse = z.object({
upserted_count: z.number().int().optional(),
});

View file

@ -1,39 +0,0 @@
openapi: 3.0.0
info:
title: Privileged User Monitoring API
version: "2023-10-31"
paths:
/api/entity_analytics/monitoring/users/_json:
post:
x-labels: [ess, serverless]
x-codegen-enabled: true
operationId: BulkUploadUsersJSON
summary: Upsert multiple monitored users via JSON upload
requestBody:
description: JSON file containing users to upsert
required: true
content:
text/json:
schema:
type: object
properties:
users:
type: array
items:
type: object
properties:
user_name:
type: string
is_monitored:
type: boolean
responses:
"200":
description: Successful response
content:
application/json:
schema:
type: object
properties:
upserted_count:
type: integer

View file

@ -286,8 +286,7 @@ import type {
UpdatePrivMonUserRequestBodyInput,
UpdatePrivMonUserResponse,
} from './entity_analytics/privilege_monitoring/users/update.gen';
import type { BulkUploadUsersCSVResponse } from './entity_analytics/privilege_monitoring/users/upload_csv.gen';
import type { BulkUploadUsersJSONResponse } from './entity_analytics/privilege_monitoring/users/upload_json.gen';
import type { PrivmonBulkUploadUsersCSVResponse } from './entity_analytics/privilege_monitoring/users/upload_csv.gen';
import type { CleanUpRiskEngineResponse } from './entity_analytics/risk_engine/engine_cleanup_route.gen';
import type {
ConfigureRiskEngineSavedObjectRequestBodyInput,
@ -490,30 +489,6 @@ after 30 days. It also deletes other artifacts specific to the migration impleme
})
.catch(catchAxiosErrorFormatAndThrow);
}
async bulkUploadUsersCsv() {
this.log.info(`${new Date().toISOString()} Calling API BulkUploadUsersCSV`);
return this.kbnClient
.request<BulkUploadUsersCSVResponse>({
path: '/api/entity_analytics/monitoring/users/_csv',
headers: {
[ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
},
method: 'POST',
})
.catch(catchAxiosErrorFormatAndThrow);
}
async bulkUploadUsersJson() {
this.log.info(`${new Date().toISOString()} Calling API BulkUploadUsersJSON`);
return this.kbnClient
.request<BulkUploadUsersJSONResponse>({
path: '/api/entity_analytics/monitoring/users/_json',
headers: {
[ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
},
method: 'POST',
})
.catch(catchAxiosErrorFormatAndThrow);
}
/**
* Bulk upsert up to 1000 asset criticality records.
@ -2090,6 +2065,19 @@ The edit action is idempotent, meaning that if you add a tag to a rule that alre
})
.catch(catchAxiosErrorFormatAndThrow);
}
async privmonBulkUploadUsersCsv(props: PrivmonBulkUploadUsersCSVProps) {
this.log.info(`${new Date().toISOString()} Calling API PrivmonBulkUploadUsersCSV`);
return this.kbnClient
.request<PrivmonBulkUploadUsersCSVResponse>({
path: '/api/entity_analytics/monitoring/users/_csv',
headers: {
[ELASTIC_HTTP_VERSION_HEADER]: '2023-10-31',
},
method: 'POST',
body: props.attachment,
})
.catch(catchAxiosErrorFormatAndThrow);
}
async privMonHealth() {
this.log.info(`${new Date().toISOString()} Calling API PrivMonHealth`);
return this.kbnClient
@ -2834,6 +2822,9 @@ export interface PersistPinnedEventRouteProps {
export interface PreviewRiskScoreProps {
body: PreviewRiskScoreRequestBodyInput;
}
export interface PrivmonBulkUploadUsersCSVProps {
attachment: FormData;
}
export interface ReadAlertsMigrationStatusProps {
query: ReadAlertsMigrationStatusRequestQueryInput;
}

View file

@ -0,0 +1,16 @@
/*
* 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 const PRIVMON_PUBLIC_URL = `/api/entity_analytics/monitoring` as const;
export const PRIVMON_ENGINE_PUBLIC_URL = `${PRIVMON_PUBLIC_URL}/engine` as const;
export const PRIVMON_USER_PUBLIC_CSV_UPLOAD_URL = `${PRIVMON_PUBLIC_URL}/users/_csv` as const;
export const PRIVMON_PUBLIC_INIT = `${PRIVMON_PUBLIC_URL}/engine/init` as const;
export const PRIVMON_USERS_CSV_MAX_SIZE_BYTES = 1024 * 1024; // 1MB
export const PRIVMON_USERS_CSV_SIZE_TOLERANCE_BYTES = 1024 * 50; // ~= 50kb
export const PRIVMON_USERS_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE =
PRIVMON_USERS_CSV_MAX_SIZE_BYTES + PRIVMON_USERS_CSV_SIZE_TOLERANCE_BYTES;

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as E from 'fp-ts/Either';
import { i18n } from '@kbn/i18n';
import type { Either } from 'fp-ts/Either';
export const parseMonitoredPrivilegedUserCsvRow = (row: string[]): Either<string, string> => {
if (row.length !== 1) {
return E.left(expectedColumnsError(row.length));
}
const [username] = row;
if (!username) {
return E.left(missingUserNameError());
}
return E.right(username);
};
const expectedColumnsError = (rowLength: number) =>
i18n.translate(
'xpack.securitySolution.entityAnalytics.monitoring.privilegedUsers.csvUpload.expectedColumnsError',
{
defaultMessage: 'Expected 1 column, got {rowLength}',
values: { rowLength },
}
);
const missingUserNameError = () =>
i18n.translate(
'xpack.securitySolution.entityAnalytics.monitoring.privilegedUsers.csvUpload.missingUserNameError',
{
defaultMessage: 'Missing user name',
}
);

View file

@ -356,58 +356,48 @@ paths:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_csv:
post:
operationId: BulkUploadUsersCSV
operationId: PrivmonBulkUploadUsersCSV
requestBody:
content:
text/csv:
schema:
type: string
description: CSV file containing users to upsert
required: true
responses:
'200':
content:
application/json:
schema:
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_json:
post:
operationId: BulkUploadUsersJSON
requestBody:
content:
text/json:
multipart/form-data:
schema:
type: object
properties:
users:
items:
type: object
properties:
is_monitored:
type: boolean
user_name:
type: string
type: array
description: JSON file containing users to upsert
required: true
file:
description: The CSV file to upload.
format: binary
type: string
required:
- file
responses:
'200':
content:
application/json:
schema:
example:
errors:
- index: 1
message: Invalid monitored field
username: john.doe
stats:
failed: 1
successful: 1
total: 2
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via JSON upload
errors:
items:
$ref: '#/components/schemas/PrivmonUserCsvUploadErrorItem'
type: array
stats:
$ref: '#/components/schemas/PrivmonUserCsvUploadStats'
required:
- errors
- stats
description: Bulk upload successful
'413':
description: File too large
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/{id}:
@ -1670,6 +1660,34 @@ components:
required:
- type
- status
PrivmonUserCsvUploadErrorItem:
type: object
properties:
index:
nullable: true
type: integer
message:
type: string
username:
nullable: true
type: string
required:
- message
- index
- username
PrivmonUserCsvUploadStats:
type: object
properties:
failed:
type: integer
successful:
type: integer
total:
type: integer
required:
- successful
- failed
- total
RiskEngineScheduleNowErrorResponse:
type: object
properties:

View file

@ -356,58 +356,48 @@ paths:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_csv:
post:
operationId: BulkUploadUsersCSV
operationId: PrivmonBulkUploadUsersCSV
requestBody:
content:
text/csv:
schema:
type: string
description: CSV file containing users to upsert
required: true
responses:
'200':
content:
application/json:
schema:
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/_json:
post:
operationId: BulkUploadUsersJSON
requestBody:
content:
text/json:
multipart/form-data:
schema:
type: object
properties:
users:
items:
type: object
properties:
is_monitored:
type: boolean
user_name:
type: string
type: array
description: JSON file containing users to upsert
required: true
file:
description: The CSV file to upload.
format: binary
type: string
required:
- file
responses:
'200':
content:
application/json:
schema:
example:
errors:
- index: 1
message: Invalid monitored field
username: john.doe
stats:
failed: 1
successful: 1
total: 2
type: object
properties:
upserted_count:
type: integer
description: Successful response
summary: Upsert multiple monitored users via JSON upload
errors:
items:
$ref: '#/components/schemas/PrivmonUserCsvUploadErrorItem'
type: array
stats:
$ref: '#/components/schemas/PrivmonUserCsvUploadStats'
required:
- errors
- stats
description: Bulk upload successful
'413':
description: File too large
summary: Upsert multiple monitored users via CSV upload
tags:
- Security Entity Analytics API
/api/entity_analytics/monitoring/users/{id}:
@ -1670,6 +1660,34 @@ components:
required:
- type
- status
PrivmonUserCsvUploadErrorItem:
type: object
properties:
index:
nullable: true
type: integer
message:
type: string
username:
nullable: true
type: string
required:
- message
- index
- username
PrivmonUserCsvUploadStats:
type: object
properties:
failed:
type: integer
successful:
type: integer
total:
type: integer
required:
- successful
- failed
- total
RiskEngineScheduleNowErrorResponse:
type: object
properties:

View file

@ -100,6 +100,120 @@ export const addRiskInputToTimelineClickedEvent: EntityAnalyticsTelemetryEvent =
},
};
export const privilegedUserMonitoringFileSelectedEvent: EntityAnalyticsTelemetryEvent = {
eventType: EntityEventTypes.PrivilegedUserMonitoringFileSelected,
schema: {
valid: {
type: 'boolean',
_meta: {
description: 'If the file is valid',
optional: false,
},
},
errorCode: {
type: 'keyword',
_meta: {
description: 'Error code if the file is invalid',
optional: true,
},
},
file: {
properties: {
size: {
type: 'long',
_meta: {
description: 'File size in bytes',
optional: false,
},
},
},
},
},
};
export const privilegedUserMonitoringCsvImportedEvent: EntityAnalyticsTelemetryEvent = {
eventType: EntityEventTypes.PrivilegedUserMonitoringCsvImported,
schema: {
file: {
properties: {
size: {
type: 'long',
_meta: {
description: 'File size in bytes',
optional: false,
},
},
},
},
},
};
export const privilegedUserMonitoringCsvPreviewGeneratedEvent: EntityAnalyticsTelemetryEvent = {
eventType: EntityEventTypes.PrivilegedUserMonitoringCsvPreviewGenerated,
schema: {
file: {
properties: {
size: {
type: 'long',
_meta: {
description: 'File size in bytes',
optional: false,
},
},
},
},
processing: {
properties: {
startTime: {
type: 'date',
_meta: {
description: 'Processing start time',
optional: false,
},
},
endTime: {
type: 'date',
_meta: {
description: 'Processing end time',
optional: false,
},
},
tookMs: {
type: 'long',
_meta: {
description: 'Processing time in milliseconds',
optional: false,
},
},
},
},
stats: {
properties: {
validLines: {
type: 'long',
_meta: {
description: 'Number of valid lines',
optional: false,
},
},
invalidLines: {
type: 'long',
_meta: {
description: 'Number of invalid lines',
optional: false,
},
},
totalLines: {
type: 'long',
_meta: {
description: 'Total number of lines',
optional: false,
},
},
},
},
},
};
export const assetCriticalityFileSelectedEvent: EntityAnalyticsTelemetryEvent = {
eventType: EntityEventTypes.AssetCriticalityFileSelected,
schema: {
@ -316,6 +430,9 @@ export const entityTelemetryEvents = [
assetCriticalityCsvPreviewGeneratedEvent,
assetCriticalityFileSelectedEvent,
assetCriticalityCsvImportedEvent,
privilegedUserMonitoringCsvPreviewGeneratedEvent,
privilegedUserMonitoringFileSelectedEvent,
privilegedUserMonitoringCsvImportedEvent,
entityStoreEnablementEvent,
entityStoreInitEvent,
toggleRiskSummaryClickedEvent,

View file

@ -20,6 +20,9 @@ export enum EntityEventTypes {
AssetCriticalityCsvPreviewGenerated = 'Asset Criticality Csv Preview Generated',
AssetCriticalityFileSelected = 'Asset Criticality File Selected',
AssetCriticalityCsvImported = 'Asset Criticality CSV Imported',
PrivilegedUserMonitoringCsvPreviewGenerated = 'Privileged User Monitoring Csv Preview Generated',
PrivilegedUserMonitoringFileSelected = 'Privileged User Monitoring File Selected',
PrivilegedUserMonitoringCsvImported = 'Privileged User Monitoring CSV Imported',
AnomaliesCountClicked = 'Anomalies Count Clicked',
MLJobUpdate = 'ML Job Update',
}
@ -116,6 +119,9 @@ export interface EntityAnalyticsTelemetryEventsMap {
[EntityEventTypes.AssetCriticalityCsvPreviewGenerated]: ReportAssetCriticalityCsvPreviewGeneratedParams;
[EntityEventTypes.AssetCriticalityFileSelected]: ReportAssetCriticalityFileSelectedParams;
[EntityEventTypes.AssetCriticalityCsvImported]: ReportAssetCriticalityCsvImportedParams;
[EntityEventTypes.PrivilegedUserMonitoringCsvPreviewGenerated]: ReportAssetCriticalityCsvPreviewGeneratedParams;
[EntityEventTypes.PrivilegedUserMonitoringFileSelected]: ReportAssetCriticalityFileSelectedParams;
[EntityEventTypes.PrivilegedUserMonitoringCsvImported]: ReportAssetCriticalityCsvImportedParams;
[EntityEventTypes.AnomaliesCountClicked]: ReportAnomaliesCountClickedParams;
[EntityEventTypes.MLJobUpdate]: ReportMLJobUpdateParams;
}

View file

@ -6,6 +6,12 @@
*/
import { useMemo } from 'react';
import type { InitMonitoringEngineResponse } from '../../../common/api/entity_analytics/privilege_monitoring/engine/init.gen';
import {
PRIVMON_PUBLIC_INIT,
PRIVMON_USER_PUBLIC_CSV_UPLOAD_URL,
} from '../../../common/entity_analytics/privileged_user_monitoring/constants';
import type { PrivmonBulkUploadUsersCSVResponse } from '../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import {
ENTITY_STORE_INTERNAL_PRIVILEGES_URL,
LIST_ENTITIES_URL,
@ -277,6 +283,32 @@ export const useEntityAnalyticsRoutes = () => {
);
};
const uploadPrivilegedUserMonitoringFile = async (
fileContent: string,
fileName: string
): Promise<PrivmonBulkUploadUsersCSVResponse> => {
const file = new File([new Blob([fileContent])], fileName, {
type: 'text/csv',
});
const body = new FormData();
body.append('file', file);
return http.fetch<PrivmonBulkUploadUsersCSVResponse>(PRIVMON_USER_PUBLIC_CSV_UPLOAD_URL, {
version: API_VERSIONS.public.v1,
method: 'POST',
headers: {
'Content-Type': undefined, // Lets the browser set the appropriate content type
},
body,
});
};
const initPrivilegedMonitoringEngine = async (): Promise<InitMonitoringEngineResponse> =>
http.fetch<InitMonitoringEngineResponse>(PRIVMON_PUBLIC_INIT, {
version: API_VERSIONS.public.v1,
method: 'POST',
});
/**
* Fetches risk engine settings
*/
@ -319,6 +351,8 @@ export const useEntityAnalyticsRoutes = () => {
deleteAssetCriticality,
fetchAssetCriticality,
uploadAssetCriticalityFile,
uploadPrivilegedUserMonitoringFile,
initPrivilegedMonitoringEngine,
fetchRiskEngineSettings,
calculateEntityRiskScore,
cleanUpRiskEngine,

View file

@ -5,16 +5,71 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import React from 'react';
import { EuiButton, EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import React, { useCallback, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { useSpaceId } from '../../../common/hooks/use_space_id';
import { RiskLevelsPrivilegedUsersPanel } from './components/risk_level_panel';
import { UserActivityPrivilegedUsersPanel } from './components/privileged_user_activity';
export const PrivilegedUserMonitoring = () => {
export interface OnboardingCallout {
userCount: number;
}
export const PrivilegedUserMonitoring = ({
callout,
onManageUserClicked,
}: {
callout?: OnboardingCallout;
onManageUserClicked: () => void;
}) => {
const spaceId = useSpaceId();
const [dismissCallout, setDismissCallout] = useState(false);
const handleDismiss = useCallback(() => {
setDismissCallout(true);
}, []);
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
{callout && !dismissCallout && (
<EuiCallOut
title={
callout.userCount > 0 ? (
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboard.userCountCallout.title"
defaultMessage="Privileged user monitoring successfully set up: {userCount, plural, one {# user added} other {# users added}}"
values={{ userCount: callout.userCount }}
/>
) : (
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboard.noUserCallout.Title"
defaultMessage="Privileged user monitoring successfully set up"
/>
)
}
color="success"
iconType="check"
onDismiss={handleDismiss}
>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboard.callout.description"
defaultMessage={
'Your privileged users data source has been successfully added. Now you can start monitoring the privileged users activity to detect potential threats before they escalate or cause damage. You can always update your list of privileged users, add or change their data source in settings.'
}
/>
</p>
<EuiButton iconType="gear" color="success" fill size="s" onClick={onManageUserClicked}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboard.callout.manageUsersButton"
defaultMessage="Manage users"
/>
</EuiButton>
</EuiCallOut>
)}
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup responsive direction="row">
<EuiFlexItem>

View file

@ -50,7 +50,7 @@ jest.mock('../hooks/use_integrations', () => ({
describe('AddDataSourcePanel', () => {
it('renders the panel title and description', () => {
render(<AddDataSourcePanel />, { wrapper: TestProviders });
render(<AddDataSourcePanel onComplete={() => {}} />, { wrapper: TestProviders });
expect(screen.getByText('Add data source of your privileged users')).toBeInTheDocument();
expect(
@ -66,7 +66,7 @@ describe('AddDataSourcePanel', () => {
navigateTo: mockNavigateTo,
});
render(<AddDataSourcePanel />, { wrapper: TestProviders });
render(<AddDataSourcePanel onComplete={() => {}} />, { wrapper: TestProviders });
const integrationCards = await screen.findAllByTestId('entity_analytics-integration-card');
expect(integrationCards.length).toBe(3);
@ -77,7 +77,7 @@ describe('AddDataSourcePanel', () => {
});
it('renders the file import card', () => {
render(<AddDataSourcePanel />, { wrapper: TestProviders });
render(<AddDataSourcePanel onComplete={() => {}} />, { wrapper: TestProviders });
const fileCard = screen.getByRole('button', { name: /file/i });
expect(fileCard).toBeInTheDocument();

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { Suspense } from 'react';
import React, { Suspense, useState } from 'react';
import {
EuiCard,
EuiFlexGrid,
@ -23,10 +23,19 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { useBoolean } from '@kbn/react-hooks';
import { IndexSelectorModal } from './select_index_modal';
import { IntegrationCards } from './integrations_cards';
import { UploadPrivilegedUsersModal } from './file_uploader/upload_privileged_users_modal';
export const AddDataSourcePanel = () => {
interface AddDataSourcePanelProps {
onComplete: (userCount: number) => void;
}
export const AddDataSourcePanel = ({ onComplete }: AddDataSourcePanelProps) => {
const [isIndexModalOpen, { on: showIndexModal, off: hideIndexModal }] = useBoolean(false);
const [isImportFileModalVisible, setShowImportFileModal] = useState(false);
const closeImportFileModal = () => setShowImportFileModal(false);
const showImportFileModal = () => setShowImportFileModal(true);
return (
<EuiPanel paddingSize="xl" hasShadow={false} hasBorder={false}>
<EuiTitle>
@ -118,10 +127,13 @@ export const AddDataSourcePanel = () => {
defaultMessage="Import a list of privileged users from a CSV, TXT, or TSV file"
/>
}
onClick={() => {}}
onClick={showImportFileModal}
/>
</EuiFlexItem>
</EuiFlexGroup>
{isImportFileModalVisible && (
<UploadPrivilegedUsersModal onClose={closeImportFileModal} onImport={onComplete} />
)}
</EuiPanel>
);
};

View file

@ -0,0 +1,134 @@
/*
* 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 {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiCallOut,
EuiButtonEmpty,
useEuiTheme,
EuiCodeBlock,
EuiText,
} from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import type { PrivmonBulkUploadUsersCSVResponse } from '../../../../../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import { buildAnnotationsFromError } from '../helpers';
export const PrivilegedUserMonitoringErrorStep: React.FC<{
result?: PrivmonBulkUploadUsersCSVResponse;
validLinesAsText: string;
errorMessage?: string;
goToFirstStep: () => void;
onClose: () => void;
}> = ({ result, validLinesAsText, errorMessage, goToFirstStep, onClose }) => {
const { euiTheme } = useEuiTheme();
if (errorMessage !== undefined) {
return (
<>
<EuiCallOut
color="danger"
iconType="cross"
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.errorStep.errorTitle"
defaultMessage="Error uploading file"
/>
}
>
<p>{errorMessage}</p>
</EuiCallOut>
<ErrorStepFooter onTryAgain={goToFirstStep} onClose={onClose} />
</>
);
}
if (result === undefined || result.stats.failed === 0) {
return null;
}
const annotations = buildAnnotationsFromError(result.errors);
return (
<>
<EuiCallOut
title={
<FormattedMessage
defaultMessage="Some privileged user assignments were unsuccessful due to errors."
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.errorStep.title"
/>
}
color="warning"
iconType="warning"
>
<EuiSpacer size="s" />
<EuiText size="s">
<FormattedMessage
defaultMessage="{assignedCount, plural, one {# privileged user assignment succeeded.} other {# privileged user assignments succeeded.}}"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.errorStep.assignedEntities"
values={{ assignedCount: result.stats.successful }}
/>
</EuiText>
<EuiText size="s" color={euiTheme.colors.danger}>
<FormattedMessage
defaultMessage="{failedCount, plural, one {# privileged user assignment failed.} other {# privileged user assignments failed.}}"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.errorStep.failedEntities"
values={{ failedCount: result.stats.failed }}
/>
</EuiText>
<EuiCodeBlock
overflowHeight={300}
language="CSV"
isVirtualized
css={css`
border: 1px solid ${euiTheme.colors.warning};
`}
lineNumbers={{ annotations }}
>
{validLinesAsText}
</EuiCodeBlock>
</EuiCallOut>
<ErrorStepFooter onTryAgain={goToFirstStep} onClose={onClose} />
</>
);
};
const ErrorStepFooter = ({
onTryAgain,
onClose,
}: {
onTryAgain: () => void;
onClose: () => void;
}) => (
<>
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClose}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.errorStep.cancelButton"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={onTryAgain} color="primary" fill>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.errorStep.tryAgainButton"
defaultMessage="Try again"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);

View file

@ -0,0 +1,77 @@
/*
* 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, fireEvent } from '@testing-library/react';
import { PrivilegedUserMonitoringFilePickerStep } from './file_picker_step';
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
describe('PrivilegedUserMonitoringFilePickerStep', () => {
const mockOnFileChange = jest.fn();
const mockErrorMessage = 'Sample error message';
const mockIsLoading = false;
afterEach(() => {
jest.clearAllMocks();
});
it('should render without errors', () => {
const { queryByTestId } = render(
<PrivilegedUserMonitoringFilePickerStep
onFileChange={mockOnFileChange}
errorMessage={mockErrorMessage}
isLoading={mockIsLoading}
/>,
{ wrapper: TestProviders }
);
expect(queryByTestId('privileged-user-monitoring-file-picker')).toBeInTheDocument();
});
it('should call onFileChange when file is selected', () => {
const { getByTestId } = render(
<PrivilegedUserMonitoringFilePickerStep
onFileChange={mockOnFileChange}
errorMessage={mockErrorMessage}
isLoading={mockIsLoading}
/>,
{ wrapper: TestProviders }
);
const file = new File(['sample file content'], 'sample.csv', { type: 'text/csv' });
fireEvent.change(getByTestId('privileged-user-monitoring-file-picker'), {
target: { files: [file] },
});
expect(mockOnFileChange).toHaveBeenCalledWith([file]);
});
it('should display error message when errorMessage prop is provided', () => {
const { getByText } = render(
<PrivilegedUserMonitoringFilePickerStep
onFileChange={mockOnFileChange}
errorMessage={mockErrorMessage}
isLoading={mockIsLoading}
/>,
{ wrapper: TestProviders }
);
expect(getByText(mockErrorMessage)).toBeInTheDocument();
});
it('should display loading indicator when isLoading prop is true', () => {
const { container } = render(
<PrivilegedUserMonitoringFilePickerStep
onFileChange={mockOnFileChange}
errorMessage={mockErrorMessage}
isLoading={true}
/>,
{ wrapper: TestProviders }
);
expect(container.querySelector('.euiProgress')).not.toBeNull();
});
});

View file

@ -0,0 +1,160 @@
/*
* 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 {
EuiCodeBlock,
EuiFilePicker,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
useEuiFontSize,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/css';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useFormatBytes } from '../../../../../../common/components/formatted_bytes';
import { CRITICALITY_CSV_MAX_SIZE_BYTES } from '../../../../../../../common/constants';
import { SUPPORTED_FILE_EXTENSIONS, SUPPORTED_FILE_TYPES } from '../constants';
interface PrivilegedUserMonitoringFilePickerStepProps {
onFileChange: (fileList: FileList | null) => void;
isLoading: boolean;
errorMessage?: string;
}
const privilegedUserMonitoringCSVSample: string[] = [
'superadmin',
'admin01,Domain Admin',
'sec_ops',
'jdoe,IT Support',
];
export const PrivilegedUserMonitoringFilePickerStep: React.FC<PrivilegedUserMonitoringFilePickerStepProps> =
React.memo(({ onFileChange, errorMessage, isLoading }) => {
const formatBytes = useFormatBytes();
const { euiTheme } = useEuiTheme();
const listStyle = css`
list-style-type: disc;
margin-bottom: ${euiTheme.size.l};
margin-left: ${euiTheme.size.l};
line-height: ${useEuiFontSize('s').lineHeight};
`;
const sampleCSVContent = privilegedUserMonitoringCSVSample.join('\n');
return (
<>
<EuiPanel color="subdued" paddingSize="l" grow={false} hasShadow={false}>
<EuiTitle size="xxs">
<h3>
<FormattedMessage
defaultMessage="Supported file formats and size"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.csvFileFormatRequirements"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<ul className={listStyle}>
<li>
<FormattedMessage
defaultMessage="File formats: {formats}"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.acceptedFileFormats"
values={{
formats: SUPPORTED_FILE_EXTENSIONS.join(', '),
}}
/>
</li>
<li>
<FormattedMessage
defaultMessage="Maximum file size: {maxFileSize}"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.uploadFileSizeLimit"
values={{
maxFileSize: formatBytes(CRITICALITY_CSV_MAX_SIZE_BYTES),
}}
/>
</li>
</ul>
<EuiTitle size="xxs">
<h3>
<FormattedMessage
defaultMessage="File structure"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.CSVStructureTitle"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<ul className={listStyle}>
<li>
<FormattedMessage
defaultMessage="User.name: holds a privileged users name"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.userNameDescription"
/>
</li>
<li>
<FormattedMessage
defaultMessage="User.label (optional): represents a label assigned to user, e.g. their role, group, team etc."
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.userLabelDescription"
/>
</li>
</ul>
<EuiTitle size="xxs">
<h3>
<FormattedMessage
defaultMessage="Example"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.exampleTitle"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiCodeBlock
language="csv"
css={css`
background-color: ${euiTheme.colors.emptyShade};
`}
paddingSize="s"
lineNumbers
isCopyable
>
{sampleCSVContent}
</EuiCodeBlock>
</EuiPanel>
<EuiSpacer size="l" />
<EuiFilePicker
data-test-subj="privileged-user-monitoring-file-picker"
accept={SUPPORTED_FILE_TYPES.join(',')}
fullWidth
onChange={onFileChange}
isInvalid={!!errorMessage}
isLoading={isLoading}
aria-label={i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.AriaLabel',
{
defaultMessage: 'Privileged user monitoring file picker',
}
)}
/>
<br />
{errorMessage && (
<EuiText color="danger" size="xs">
{errorMessage}
</EuiText>
)}
</>
);
});
PrivilegedUserMonitoringFilePickerStep.displayName = 'PrivilegedUserMonitoringFilePickerStep';

View file

@ -0,0 +1,100 @@
/*
* 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, fireEvent } from '@testing-library/react';
import {
PrivilegedUserMonitoringValidationStep,
type PrivilegedUserMonitoringValidationStepProps,
} from './validation_step';
import { TestProviders } from '../../../../../../common/mock';
import { downloadBlob } from '../../../../../../common/utils/download_blob';
jest.mock('../../../../../../common/utils/download_blob');
jest.mock('../../../../../../common/lib/kibana/kibana_react', () => ({
useKibana: () => ({
services: {
telemetry: {
reportEvent: jest.fn(),
},
},
}),
}));
describe('PrivilegedUserMonitoringValidationStep', () => {
const mockOnConfirm = jest.fn();
const mockOnReturn = jest.fn();
const defaultProps: PrivilegedUserMonitoringValidationStepProps = {
validatedFile: {
name: 'test.csv',
size: 100,
validLines: {
text: 'Valid lines as text',
count: 10,
},
invalidLines: {
text: 'Invalid lines as text',
count: 5,
errors: [],
},
},
onConfirm: mockOnConfirm,
onReturn: mockOnReturn,
isLoading: false,
};
it('renders the component with correct counts and file name', () => {
const { container } = render(<PrivilegedUserMonitoringValidationStep {...defaultProps} />, {
wrapper: TestProviders,
});
expect(container).toHaveTextContent('10 users will be assigned');
expect(container).toHaveTextContent(`5 rows are invalid`);
expect(container).toHaveTextContent('test.csv preview');
});
it('calls onConfirm when add button is clicked', () => {
const { getByText } = render(<PrivilegedUserMonitoringValidationStep {...defaultProps} />, {
wrapper: TestProviders,
});
const confirmButton = getByText('Add privileged users');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalled();
});
it('calls onReturn when "back" button is clicked', () => {
const { getByText } = render(<PrivilegedUserMonitoringValidationStep {...defaultProps} />, {
wrapper: TestProviders,
});
const returnButton = getByText('Back');
fireEvent.click(returnButton);
expect(mockOnReturn).toHaveBeenCalled();
});
it('calls onReturn when "choose another file" button is clicked', () => {
const { getByText } = render(<PrivilegedUserMonitoringValidationStep {...defaultProps} />, {
wrapper: TestProviders,
});
const returnButton = getByText('Choose another file');
fireEvent.click(returnButton);
expect(mockOnReturn).toHaveBeenCalled();
});
it('downloads the invalid lines as text when Download CSV is clicked', () => {
const { getByText } = render(<PrivilegedUserMonitoringValidationStep {...defaultProps} />, {
wrapper: TestProviders,
});
const downloadButton = getByText('Download CSV');
fireEvent.click(downloadButton);
expect(downloadBlob).toHaveBeenCalledWith(
new Blob(['Invalid lines as text']),
'invalid_privileged_user_monitoring.csv'
);
});
});

View file

@ -0,0 +1,201 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EntityEventTypes } from '../../../../../../common/lib/telemetry';
import { useKibana } from '../../../../../../common/lib/kibana';
import { downloadBlob } from '../../../../../../common/utils/download_blob';
import type { ValidatedFile } from '../types';
import { buildAnnotationsFromError } from '../helpers';
export interface PrivilegedUserMonitoringValidationStepProps {
validatedFile: ValidatedFile;
isLoading: boolean;
onConfirm: () => void;
onReturn: () => void;
}
const CODE_BLOCK_HEIGHT = 250;
const INVALID_FILE_NAME = `invalid_privileged_user_monitoring.csv`;
export const PrivilegedUserMonitoringValidationStep: React.FC<PrivilegedUserMonitoringValidationStepProps> =
React.memo(({ validatedFile, isLoading, onConfirm, onReturn }) => {
const { validLines, invalidLines, size: fileSize, name: fileName } = validatedFile;
const { euiTheme } = useEuiTheme();
const { telemetry } = useKibana().services;
const annotations = buildAnnotationsFromError(invalidLines.errors);
const onConfirmClick = () => {
telemetry.reportEvent(EntityEventTypes.PrivilegedUserMonitoringCsvImported, {
file: {
size: fileSize,
},
});
onConfirm();
};
return (
<>
<b>
<FormattedMessage
defaultMessage="{fileName} preview"
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.fileNamePreviewText"
values={{ fileName }}
/>
</b>
<EuiSpacer size="m" />
{validLines.count > 0 && (
<>
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem grow>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type={'checkInCircleFilled'} color="success" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span data-test-subj="privileged-user-monitoring-validLinesMessage">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.validLinesMessage"
defaultMessage="{validLinesCount, plural, one {{validLinesCountBold} user will be assigned} other {{validLinesCountBold} users will be assigned}}"
values={{
validLinesCount: validLines.count,
validLinesCountBold: <b>{validLines.count}</b>,
}}
/>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty flush="right" onClick={onReturn} size="xs" disabled={isLoading}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.chooseAnotherFileText"
defaultMessage="Choose another file"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiCodeBlock
overflowHeight={CODE_BLOCK_HEIGHT}
lineNumbers
language="CSV"
isVirtualized
css={css`
border: ${euiTheme.border.width.thin} solid ${euiTheme.colors.accentSecondary};
`}
>
{validLines.text}
</EuiCodeBlock>
<EuiSpacer size="l" />
</>
)}
{invalidLines.count > 0 && (
<>
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem grow>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type={'error'} color="danger" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span data-test-subj="privileged-user-monitoring-invalidLinesMessage">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.invalidLinesMessage"
defaultMessage="{invalidLinesCount, plural, one {{invalidLinesCountBold} row is invalid and won't be added} other {{invalidLinesCountBold} rows are invalid and wont be added}}"
values={{
invalidLinesCount: invalidLines.count,
invalidLinesCountBold: <b>{invalidLines.count}</b>,
}}
/>
</span>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{invalidLines.text && (
<EuiButtonEmpty
size="xs"
flush="right"
disabled={isLoading}
onClick={() => {
if (invalidLines.text.length > 0) {
downloadBlob(new Blob([invalidLines.text]), INVALID_FILE_NAME);
}
}}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.downloadCSV"
defaultMessage="Download CSV"
/>
</EuiButtonEmpty>
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiCodeBlock
overflowHeight={CODE_BLOCK_HEIGHT}
lineNumbers={{ annotations }}
language="CSV"
isVirtualized
css={css`
border: 1px solid ${euiTheme.colors.danger};
`}
>
{invalidLines.text}
</EuiCodeBlock>
</>
)}
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onReturn} disabled={isLoading}>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.backButtonText"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={onConfirmClick}
disabled={validLines.count === 0}
data-test-subj="privileged-user-monitoring-assign-button"
isLoading={isLoading}
>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validationStep.addButtonText"
defaultMessage="Add privileged users"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
});
PrivilegedUserMonitoringValidationStep.displayName = 'privilegedUserMonitoring.validationStep';

View file

@ -0,0 +1,13 @@
/*
* 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 const SUPPORTED_FILE_TYPES = [
'text/csv',
'text/plain',
'.csv', // Useful for Windows when it can't recognise the file extension.
];
export const SUPPORTED_FILE_EXTENSIONS = ['CSV', 'TXT'];

View file

@ -0,0 +1,49 @@
/*
* 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 { buildAnnotationsFromError, getStepStatus } from './helpers';
import { FileUploaderSteps } from './types';
describe('helpers', () => {
describe('getStepStatus', () => {
it('should return "disabled" for other steps when currentStep is 4 (ERROR)', () => {
const step = FileUploaderSteps.VALIDATION;
const currentStep = FileUploaderSteps.ERROR;
const status = getStepStatus(step, currentStep);
expect(status).toBe('disabled');
});
it('should return "current" if step is equal to currentStep', () => {
const step = FileUploaderSteps.RESULT;
const currentStep = FileUploaderSteps.RESULT;
const status = getStepStatus(step, currentStep);
expect(status).toBe('current');
});
it('should return "complete" if step is less than currentStep', () => {
const step = FileUploaderSteps.FILE_PICKER;
const currentStep = FileUploaderSteps.VALIDATION;
const status = getStepStatus(step, currentStep);
expect(status).toBe('complete');
});
});
describe('buildAnnotationsFromError', () => {
it('should return annotations from errors', () => {
const errors = [
{ index: 0, message: 'error 1' },
{ index: 1, message: 'error 2' },
];
const annotations = {
0: 'error 1',
1: 'error 2',
};
const res = buildAnnotationsFromError(errors);
expect(res).toEqual(annotations);
});
});
});

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 type { FilePickerState, ValidationStepState, ReducerState, ErrorStepState } from './reducer';
import { FileUploaderSteps } from './types';
export const getStepStatus = (step: FileUploaderSteps, currentStep: FileUploaderSteps) => {
if (currentStep === FileUploaderSteps.ERROR) {
return 'disabled';
}
if (currentStep === step) {
return 'current';
}
if (currentStep > step) {
return 'complete';
}
return 'disabled';
};
export const isFilePickerStep = (state: ReducerState): state is FilePickerState =>
state.step === FileUploaderSteps.FILE_PICKER;
export const isValidationStep = (state: ReducerState): state is ValidationStepState =>
state.step === FileUploaderSteps.VALIDATION;
export const isErrorStep = (state: ReducerState): state is ErrorStepState =>
state.step === FileUploaderSteps.ERROR;
export const buildAnnotationsFromError = (
errors: Array<{ message: string; index: number | null }>
): Record<number, string> => {
const annotations: Record<number, string> = {};
errors.forEach((e) => {
if (e.index !== undefined && e.index !== null) {
annotations[e.index] = e.message;
}
});
return annotations;
};

View file

@ -0,0 +1,97 @@
/*
* 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 { createTelemetryServiceMock } from '../../../../../common/lib/telemetry/telemetry_service.mock';
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
import { waitFor, renderHook } from '@testing-library/react';
import { useFileValidation } from './hooks';
import { useKibana as mockUseKibana } from '../../../../../common/lib/kibana/__mocks__';
import { mockGlobalState } from '../../../../../common/mock';
const mockedExperimentalFeatures = mockGlobalState.app.enableExperimental;
const mockedUseKibana = mockUseKibana();
const mockedTelemetry = createTelemetryServiceMock();
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
useEnableExperimental: () => ({ ...mockedExperimentalFeatures }),
}));
jest.mock('../../../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
...mockedUseKibana,
services: {
...mockedUseKibana.services,
telemetry: mockedTelemetry,
},
}),
};
});
describe('useFileValidation', () => {
const validLine = 'user1';
const invalidLine = 'user1,extra_field';
test('should call onError when an error occurs', () => {
const onErrorMock = jest.fn();
const onCompleteMock = jest.fn();
const invalidFileType = 'invalid file type';
const { result } = renderHook(
() => useFileValidation({ onError: onErrorMock, onComplete: onCompleteMock }),
{ wrapper: TestProviders }
);
result.current(new File([invalidLine], 'test.csv', { type: invalidFileType }));
expect(onErrorMock).toHaveBeenCalled();
expect(onCompleteMock).not.toHaveBeenCalled();
});
test('should call onComplete when file validation is complete', async () => {
const onErrorMock = jest.fn();
const onCompleteMock = jest.fn();
const fileName = 'test.csv';
const { result } = renderHook(
() => useFileValidation({ onError: onErrorMock, onComplete: onCompleteMock }),
{ wrapper: TestProviders }
);
result.current(new File([`${validLine}\n${invalidLine}`], fileName, { type: 'text/csv' }));
await waitFor(() => {
expect(onErrorMock).not.toHaveBeenCalled();
expect(onCompleteMock).toHaveBeenCalledWith(
expect.objectContaining({
validatedFile: {
name: fileName,
size: 23,
validLines: {
text: validLine,
count: 1,
},
invalidLines: {
text: invalidLine,
count: 1,
errors: [
{
message: 'Expected 1 column, got 2',
index: 1,
},
],
},
},
processingEndTime: expect.any(String),
processingStartTime: expect.any(String),
tookMs: expect.any(Number),
})
);
});
});
});

View file

@ -0,0 +1,159 @@
/*
* 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 type { ParseLocalConfig } from 'papaparse';
import { unparse, parse } from 'papaparse';
import { useCallback, useMemo } from 'react';
import type { EuiStepHorizontalProps } from '@elastic/eui/src/components/steps/step_horizontal';
import { noop } from 'lodash/fp';
import { EntityEventTypes } from '../../../../../common/lib/telemetry';
import { useKibana } from '../../../../../common/lib/kibana';
import { useFormatBytes } from '../../../../../common/components/formatted_bytes';
import { validateParsedContent, validateFile } from './validations';
import type { OnCompleteParams } from './types';
import type { ReducerState } from './reducer';
import { getStepStatus, isValidationStep } from './helpers';
interface UseFileChangeCbParams {
onError: (errorMessage: string, file: File) => void;
onComplete: (param: OnCompleteParams) => void;
}
export const useFileValidation = ({ onError, onComplete }: UseFileChangeCbParams) => {
const formatBytes = useFormatBytes();
const { telemetry } = useKibana().services;
const onErrorWrapper = useCallback(
(
error: {
message: string;
code?: string;
},
file: File
) => {
telemetry.reportEvent(EntityEventTypes.PrivilegedUserMonitoringFileSelected, {
valid: false,
errorCode: error.code,
file: {
size: file.size,
},
});
onError(error.message, file);
},
[onError, telemetry]
);
return useCallback(
(file: File) => {
const processingStartTime = Date.now();
const fileValidation = validateFile(file, formatBytes);
if (!fileValidation.valid) {
onErrorWrapper(
{
message: fileValidation.errorMessage,
code: fileValidation.code,
},
file
);
return;
}
telemetry.reportEvent(EntityEventTypes.PrivilegedUserMonitoringFileSelected, {
valid: true,
file: {
size: file.size,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parserConfig: ParseLocalConfig<any, File> = {
dynamicTyping: true,
skipEmptyLines: true,
complete(parsedFile, returnedFile) {
if (parsedFile.data.length === 0) {
onErrorWrapper(
{
message: i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.fileUploader.emptyFileError',
{ defaultMessage: 'The file is empty' }
),
},
file
);
return;
}
const { invalid, valid, errors } = validateParsedContent(parsedFile.data);
const validLinesAsText = unparse(valid);
const invalidLinesAsText = unparse(invalid);
const processingEndTime = Date.now();
const tookMs = processingEndTime - processingStartTime;
onComplete({
processingStartTime: new Date(processingStartTime).toISOString(),
processingEndTime: new Date(processingEndTime).toISOString(),
tookMs,
validatedFile: {
name: returnedFile?.name ?? '',
size: returnedFile?.size ?? 0,
validLines: {
text: validLinesAsText,
count: valid.length,
},
invalidLines: {
text: invalidLinesAsText,
count: invalid.length,
errors,
},
},
});
},
error(parserError) {
onErrorWrapper({ message: parserError.message }, file);
},
};
parse(file, parserConfig);
},
[formatBytes, telemetry, onErrorWrapper, onComplete]
);
};
export const useNavigationSteps = (
state: ReducerState,
goToFirstStep: () => void
): Array<Omit<EuiStepHorizontalProps, 'step'>> => {
return useMemo(
() => [
{
title: i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.selectFileStepTitle',
{
defaultMessage: 'Upload file',
}
),
status: getStepStatus(1, state.step),
onClick: () => {
if (isValidationStep(state)) {
goToFirstStep(); // User can only go back to the first step from the second step
}
},
},
{
title: i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.filePicker.fileValidationStepTitle',
{
defaultMessage: 'Confirm users',
}
),
status: getStepStatus(2, state.step),
onClick: noop, // Prevents the user from navigating by clicking on the step
},
],
[goToFirstStep, state]
);
};

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 * from './upload_privileged_users_modal';

View file

@ -0,0 +1,174 @@
/*
* 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 { EuiSpacer, EuiStepsHorizontal } from '@elastic/eui';
import React, { useCallback, useReducer } from 'react';
import { useKibana } from '../../../../../common/lib/kibana';
import { EntityEventTypes } from '../../../../../common/lib/telemetry';
import { useEntityAnalyticsRoutes } from '../../../../api/api';
import { INITIAL_STATE, reducer } from './reducer';
import { useFileValidation, useNavigationSteps } from './hooks';
import type { OnCompleteParams } from './types';
import { isErrorStep, isFilePickerStep, isValidationStep } from './helpers';
import { PrivilegedUserMonitoringFilePickerStep } from './components/file_picker_step';
import { PrivilegedUserMonitoringValidationStep } from './components/validation_step';
import { PrivilegedUserMonitoringErrorStep } from './components/error_step';
interface PrivilegedUsersFileUploaderProps {
onFileUploaded: (userCount: number) => void;
onClose: () => void;
}
export const PrivilegedUsersFileUploader: React.FC<PrivilegedUsersFileUploaderProps> = ({
onFileUploaded,
onClose,
}) => {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const { uploadPrivilegedUserMonitoringFile } = useEntityAnalyticsRoutes();
const { telemetry } = useKibana().services;
const onValidationComplete = useCallback(
({ validatedFile, processingStartTime, processingEndTime, tookMs }: OnCompleteParams) => {
telemetry.reportEvent(EntityEventTypes.PrivilegedUserMonitoringCsvPreviewGenerated, {
file: {
size: validatedFile.size,
},
processing: {
startTime: processingStartTime,
endTime: processingEndTime,
tookMs,
},
stats: {
validLines: validatedFile.validLines.count,
invalidLines: validatedFile.invalidLines.count,
totalLines: validatedFile.validLines.count + validatedFile.invalidLines.count,
},
});
dispatch({
type: 'fileValidated',
payload: {
validatedFile: {
name: validatedFile.name,
size: validatedFile.size,
validLines: {
text: validatedFile.validLines.text,
count: validatedFile.validLines.count,
},
invalidLines: {
text: validatedFile.invalidLines.text,
count: validatedFile.invalidLines.count,
errors: validatedFile.invalidLines.errors,
},
},
},
});
},
[telemetry]
);
const onValidationError = useCallback((message: string) => {
dispatch({ type: 'fileError', payload: { message } });
}, []);
const validateFile = useFileValidation({
onError: onValidationError,
onComplete: onValidationComplete,
});
const goToFirstStep = useCallback(() => {
dispatch({ type: 'resetState' });
}, []);
const onFileChange = useCallback(
(fileList: FileList | null) => {
const file = fileList?.item(0);
if (!file) {
// file removed
goToFirstStep();
return;
}
dispatch({
type: 'loadingFile',
payload: { fileName: file.name },
});
validateFile(file);
},
[validateFile, goToFirstStep]
);
const onUploadFile = useCallback(async () => {
if (isValidationStep(state)) {
dispatch({
type: 'uploadingFile',
});
try {
const result = await uploadPrivilegedUserMonitoringFile(
state.validatedFile.validLines.text,
state.validatedFile.name
);
if (result.stats.failed > 0) {
dispatch({
type: 'fileUploadError',
payload: {
response: result,
},
});
} else {
onFileUploaded(result.stats.successful);
}
} catch (e) {
dispatch({
type: 'fileUploadError',
payload: { errorMessage: e.message },
});
}
}
}, [onFileUploaded, state, uploadPrivilegedUserMonitoringFile]);
const steps = useNavigationSteps(state, goToFirstStep);
return (
<>
{!isErrorStep(state) && <EuiStepsHorizontal size="s" steps={steps} />}
<EuiSpacer size="m" />
{isFilePickerStep(state) && (
<PrivilegedUserMonitoringFilePickerStep
onFileChange={onFileChange}
isLoading={state.isLoading}
errorMessage={state.fileError}
/>
)}
{isValidationStep(state) && (
<PrivilegedUserMonitoringValidationStep
validatedFile={state.validatedFile}
isLoading={state.isLoading}
onReturn={goToFirstStep}
onConfirm={onUploadFile}
/>
)}
{isErrorStep(state) && (
<PrivilegedUserMonitoringErrorStep
result={state.fileUploadResponse}
validLinesAsText={state.validLinesAsText}
errorMessage={state.fileUploadError}
goToFirstStep={goToFirstStep}
onClose={onClose}
/>
)}
</>
);
};

View file

@ -0,0 +1,150 @@
/*
* 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 { PrivmonBulkUploadUsersCSVResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import type { ReducerAction, ReducerState, ValidationStepState } from './reducer';
import { reducer } from './reducer';
import { FileUploaderSteps } from './types';
describe('reducer', () => {
const initialState: ReducerState = {
isLoading: false,
step: FileUploaderSteps.FILE_PICKER,
};
const validatedFile = {
name: 'test.csv',
size: 100,
validLines: {
text: 'valid lines',
count: 10,
},
invalidLines: {
text: 'invalid lines',
count: 0,
errors: [],
},
};
it('should handle "uploadingFile" action', () => {
const action: ReducerAction = { type: 'uploadingFile' };
const state: ValidationStepState = {
validatedFile,
isLoading: false,
step: FileUploaderSteps.VALIDATION,
};
const nextState = reducer(state, action) as ValidationStepState;
expect(nextState.isLoading).toBe(true);
});
it('should handle "fileUploaded" action with response', () => {
const response: PrivmonBulkUploadUsersCSVResponse = {
errors: [],
stats: {
total: 10,
successful: 10,
failed: 0,
},
};
const state: ValidationStepState = {
validatedFile,
isLoading: true,
step: FileUploaderSteps.VALIDATION,
};
const action: ReducerAction = { type: 'fileUploaded', payload: { response } };
const nextState = reducer(state, action);
expect(nextState).toEqual({
step: FileUploaderSteps.RESULT,
fileUploadResponse: response,
fileUploadError: undefined,
validLinesAsText: validatedFile.validLines.text,
});
});
it('should handle "fileUploaded" action with errorMessage', () => {
const errorMessage = 'File upload failed';
const state: ValidationStepState = {
validatedFile,
isLoading: true,
step: FileUploaderSteps.VALIDATION,
};
const action: ReducerAction = { type: 'fileUploaded', payload: { errorMessage } };
const nextState = reducer(state, action);
expect(nextState).toEqual({
step: FileUploaderSteps.RESULT,
fileUploadResponse: undefined,
fileUploadError: errorMessage,
validLinesAsText: validatedFile.validLines.text,
});
});
it('should handle "loadingFile" action', () => {
const fileName = 'file.csv';
const action: ReducerAction = { type: 'loadingFile', payload: { fileName } };
const nextState = reducer(initialState, action);
expect(nextState).toEqual({
isLoading: true,
step: FileUploaderSteps.FILE_PICKER,
fileName,
});
});
it('should handle "fileValidated" action', () => {
const action: ReducerAction = {
type: 'fileValidated',
payload: { validatedFile },
};
const nextState = reducer({ ...initialState, isLoading: true }, action);
expect(nextState).toEqual({
isLoading: false,
step: FileUploaderSteps.VALIDATION,
validatedFile,
});
});
it('should handle "fileError" action', () => {
const message = 'File error';
const action: ReducerAction = { type: 'fileError', payload: { message } };
const nextState = reducer(
{
step: FileUploaderSteps.VALIDATION,
isLoading: true,
validatedFile: {
name: '',
size: 0,
validLines: { text: '', count: 0 },
invalidLines: { text: '', count: 0, errors: [] },
},
},
action
);
expect(nextState).toEqual({
isLoading: false,
step: FileUploaderSteps.FILE_PICKER,
fileError: message,
});
});
it('should handle "resetState" action', () => {
const action: ReducerAction = { type: 'resetState' };
const nextState = reducer(
{ step: FileUploaderSteps.VALIDATION, isLoading: true, validatedFile },
action
);
expect(nextState).toEqual(initialState);
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 { PrivmonBulkUploadUsersCSVResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import { FileUploaderSteps } from './types';
import type { ValidatedFile } from './types';
import { isFilePickerStep, isValidationStep } from './helpers';
export interface FilePickerState {
isLoading: boolean;
step: FileUploaderSteps.FILE_PICKER;
fileError?: string;
fileName?: string;
}
export interface ValidationStepState {
isLoading: boolean;
step: FileUploaderSteps.VALIDATION;
fileError?: string;
validatedFile: ValidatedFile;
}
export interface ErrorStepState {
step: FileUploaderSteps.ERROR;
fileUploadError?: string;
fileUploadResponse?: PrivmonBulkUploadUsersCSVResponse;
validLinesAsText: string;
}
export interface ResultStepState {
step: FileUploaderSteps.RESULT;
fileUploadResponse?: PrivmonBulkUploadUsersCSVResponse;
fileUploadError?: string;
validLinesAsText: string;
}
export type ReducerState = FilePickerState | ValidationStepState | ErrorStepState | ResultStepState;
export type ReducerAction =
| { type: 'loadingFile'; payload: { fileName: string } }
| { type: 'resetState' }
| {
type: 'fileValidated';
payload: {
validatedFile: ValidatedFile;
};
}
| { type: 'fileError'; payload: { message: string } }
| { type: 'uploadingFile' }
| {
type: 'fileUploadError';
payload: { errorMessage?: string; response?: PrivmonBulkUploadUsersCSVResponse };
}
| {
type: 'fileUploaded';
payload: { response?: PrivmonBulkUploadUsersCSVResponse; errorMessage?: string };
};
export const INITIAL_STATE: FilePickerState = {
isLoading: false,
step: FileUploaderSteps.FILE_PICKER,
};
export const reducer = (state: ReducerState, action: ReducerAction): ReducerState => {
switch (action.type) {
case 'resetState':
return INITIAL_STATE;
case 'loadingFile':
if (isFilePickerStep(state)) {
return {
...state,
isLoading: true,
fileName: action.payload.fileName,
};
}
break;
case 'fileError':
return {
isLoading: false,
step: FileUploaderSteps.FILE_PICKER,
fileError: action.payload.message,
};
case 'fileValidated':
return {
isLoading: false,
step: FileUploaderSteps.VALIDATION,
...action.payload,
};
case 'uploadingFile':
if (isValidationStep(state)) {
return {
...state,
isLoading: true,
};
}
break;
case 'fileUploaded':
if (isValidationStep(state)) {
return {
fileUploadResponse: action.payload.response,
fileUploadError: action.payload.errorMessage,
validLinesAsText: state.validatedFile.validLines.text,
step: FileUploaderSteps.RESULT,
};
}
break;
case 'fileUploadError':
if (isValidationStep(state)) {
return {
fileUploadError: action.payload.errorMessage,
step: FileUploaderSteps.ERROR,
fileUploadResponse: action.payload.response,
validLinesAsText: state.validatedFile.validLines.text,
};
}
break;
}
return state;
};

View file

@ -0,0 +1,36 @@
/*
* 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 { RowValidationErrors } from './validations';
export interface ValidatedFile {
name: string;
size: number;
validLines: {
text: string;
count: number;
};
invalidLines: {
text: string;
count: number;
errors: RowValidationErrors[];
};
}
export interface OnCompleteParams {
processingStartTime: string;
processingEndTime: string;
tookMs: number;
validatedFile: ValidatedFile;
}
export enum FileUploaderSteps {
FILE_PICKER = 1,
VALIDATION = 2,
RESULT = 3,
ERROR = 4,
}

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 React from 'react';
import { EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { PrivilegedUsersFileUploader } from './privileged_users_file_uploader';
interface ImportPrivilegedUsersModalProps {
onClose: () => void;
onImport: (userCount: number) => void;
}
export const UploadPrivilegedUsersModal: React.FC<ImportPrivilegedUsersModalProps> = ({
onClose,
onImport,
}) => {
// TODO handle missing permissions
return (
<EuiModal onClose={onClose}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.uploadPrivilegedUsersModal.title"
defaultMessage="Import file"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText>
<p>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.uploadPrivilegedUsersModal.description"
defaultMessage="Add your privileged users by importing a CSV file exported from your user management tool. This ensures data accuracy and reduces manual input errors."
/>
</p>
</EuiText>
<PrivilegedUsersFileUploader onFileUploaded={onImport} onClose={onClose} />
</EuiModalBody>
</EuiModal>
);
};

View file

@ -0,0 +1,86 @@
/*
* 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 { validateParsedContent, validateFile } from './validations';
const formatBytes = (bytes: number) => bytes.toString();
describe('validateParsedContent', () => {
it('should return empty arrays when data is empty', () => {
const result = validateParsedContent([]);
expect(result).toEqual({
valid: [],
invalid: [],
errors: [],
});
});
it('should return valid and invalid data based on row validation', () => {
const data = [
['user1', 'extra_field'], // invalid
['user2'], // valid
];
const result = validateParsedContent(data);
expect(result).toEqual({
valid: [data[1]],
invalid: [data[0]],
errors: [
{
message: 'Expected 1 column, got 2',
index: 1,
},
],
});
});
});
describe('validateFile', () => {
it('should return valid if the file is valid', () => {
const file = new File(['file content'], 'test.csv', { type: 'text/csv' });
const result = validateFile(file, formatBytes);
expect(result.valid).toBe(true);
});
it('should return valid if the mime type is empty (Windows)', () => {
const file = new File(['file content'], 'test.csv', { type: '' });
const result = validateFile(file, formatBytes);
expect(result.valid).toBe(true);
});
it('should return an error message if the file type is invalid', () => {
const file = new File(['file content'], 'test.txt', { type: 'invalid-type' });
const result = validateFile(file, formatBytes) as {
valid: false;
errorMessage: string;
};
expect(result.valid).toBe(false);
expect(result.errorMessage).toBe(
'Invalid file format selected. Please choose a CSV, TXT file and try again'
);
});
it('should return an error message if the file size is 0', () => {
const file = new File([], 'test.txt', { type: 'text/csv' });
const result = validateFile(file, formatBytes) as {
valid: false;
errorMessage: string;
};
expect(result.valid).toBe(false);
expect(result.errorMessage).toBe('The selected file is empty.');
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 { isRight } from 'fp-ts/Either';
import { parseMonitoredPrivilegedUserCsvRow } from '../../../../../../common/entity_analytics/privileged_user_monitoring/parse_privileged_user_monitoring_csv_row';
import {
PRIVMON_USERS_CSV_MAX_SIZE_BYTES,
PRIVMON_USERS_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE,
} from '../../../../../../common/entity_analytics/privileged_user_monitoring/constants';
import { SUPPORTED_FILE_EXTENSIONS, SUPPORTED_FILE_TYPES } from './constants';
export interface RowValidationErrors {
message: string;
index: number;
}
export const validateParsedContent = (
data: string[][]
): { valid: string[][]; invalid: string[][]; errors: RowValidationErrors[] } => {
if (data.length === 0) {
return { valid: [], invalid: [], errors: [] };
}
let errorIndex = 1; // Error index starts from 1 because EuiCodeBlock line numbers start from 1
const { valid, invalid, errors } = data.reduce<{
valid: string[][];
invalid: string[][];
errors: RowValidationErrors[];
}>(
(acc, row) => {
const parsedRow = parseMonitoredPrivilegedUserCsvRow(row);
// parsed row is a fp-ts/Either
// please add and if to check if the parsedRow is valid
// and consider that Property 'valid' does not exist on type 'Either<string, string>'.
// Property 'valid' does not exist on type 'Left<string>'.
if (isRight(parsedRow)) {
acc.valid.push(row);
} else {
acc.invalid.push(row);
acc.errors.push({ message: parsedRow.left, index: errorIndex });
errorIndex++;
}
return acc;
},
{ valid: [], invalid: [], errors: [] }
);
return { valid, invalid, errors };
};
export const validateFile = (
file: File,
formatBytes: (bytes: number) => string
): { valid: false; errorMessage: string; code: string } | { valid: true } => {
if (
file.type !== '' && // file.type might be an empty string on windows
!SUPPORTED_FILE_TYPES.includes(file.type)
) {
return {
valid: false,
code: 'unsupported_file_type',
errorMessage: i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validations.unsupportedFileTypeError',
{
defaultMessage: `Invalid file format selected. Please choose a {supportedFileExtensions} file and try again`,
values: { supportedFileExtensions: SUPPORTED_FILE_EXTENSIONS.join(', ') },
}
),
};
}
if (file.size === 0) {
return {
valid: false,
code: 'empty_file',
errorMessage: i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validations.emptyFileErrorMessage',
{
defaultMessage: `The selected file is empty.`,
}
),
};
}
if (file.size > PRIVMON_USERS_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE) {
return {
valid: false,
code: 'file_size_exceeds_limit',
errorMessage: i18n.translate(
'xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.validations.fileSizeExceedsLimitErrorMessage',
{
defaultMessage: 'File size {fileSize} exceeds maximum file size of {maxFileSize}',
values: {
fileSize: formatBytes(file.size),
maxFileSize: formatBytes(PRIVMON_USERS_CSV_MAX_SIZE_BYTES),
},
}
),
};
}
return { valid: true };
};

View file

@ -28,7 +28,13 @@ const VIDEO_TITLE = i18n.translate(
}
);
export const PrivilegedUserMonitoringOnboardingPanel = () => {
interface PrivilegedUserMonitoringOnboardingPanelProps {
onComplete: (userCount: number) => void;
}
export const PrivilegedUserMonitoringOnboardingPanel = ({
onComplete,
}: PrivilegedUserMonitoringOnboardingPanelProps) => {
return (
<EuiPanel paddingSize="none">
<EuiPanel paddingSize="xl" color="subdued" hasShadow={false} hasBorder={false}>
@ -112,7 +118,7 @@ export const PrivilegedUserMonitoringOnboardingPanel = () => {
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<AddDataSourcePanel />
<AddDataSourcePanel onComplete={onComplete} />
</EuiPanel>
);
};

View file

@ -4,14 +4,25 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
import React, { useCallback, useReducer } from 'react';
import {
EuiButtonEmpty,
EuiEmptyPrompt,
EuiFlexGroup,
EuiLoadingLogo,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import type { InitMonitoringEngineResponse } from '../../../common/api/entity_analytics/privilege_monitoring/engine/init.gen';
import { SecurityPageName } from '../../app/types';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { PrivilegedUserMonitoringSampleDashboardsPanel } from '../components/privileged_user_monitoring_onboarding/sample_dashboards_panel';
import { PrivilegedUserMonitoringOnboardingPanel } from '../components/privileged_user_monitoring_onboarding/onboarding_panel';
import type { OnboardingCallout } from '../components/privileged_user_monitoring';
import { PrivilegedUserMonitoring } from '../components/privileged_user_monitoring';
import { FiltersGlobal } from '../../common/components/filters_global';
import { SiemSearchBar } from '../../common/components/search_bar';
@ -20,10 +31,49 @@ import { useDataViewSpec } from '../../data_view_manager/hooks/use_data_view_spe
import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features';
import { useSourcererDataView } from '../../sourcerer/containers';
import { HeaderPage } from '../../common/components/header_page';
import { useEntityAnalyticsRoutes } from '../api/api';
type PageState =
| { type: 'onboarding' }
| { type: 'initializingEngine'; initResponse?: InitMonitoringEngineResponse; userCount: number }
| { type: 'dashboard'; onboardingCallout?: OnboardingCallout };
type Action =
| { type: 'INITIALIZING_ENGINE'; userCount: number; initResponse?: InitMonitoringEngineResponse }
| { type: 'UPDATE_INIT_ENGINE_RESPONSE'; initResponse: InitMonitoringEngineResponse }
| {
type: 'SHOW_DASHBOARD';
onboardingCallout?: OnboardingCallout;
};
const initialState: PageState = { type: 'onboarding' };
function reducer(state: PageState, action: Action): PageState {
switch (action.type) {
case 'INITIALIZING_ENGINE':
return {
type: 'initializingEngine',
userCount: action.userCount,
initResponse: action.initResponse,
};
case 'SHOW_DASHBOARD':
return { type: 'dashboard', onboardingCallout: action.onboardingCallout };
case 'UPDATE_INIT_ENGINE_RESPONSE':
if (state.type === 'initializingEngine') {
return {
...state,
initResponse: action.initResponse,
};
}
return state;
default:
return state;
}
}
export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
// TODO Delete-me when the onboarding flow is implemented
const [isOnboardingVisible, setIsOnboardingVisible] = useState(true);
const { initPrivilegedMonitoringEngine } = useEntityAnalyticsRoutes();
const [state, dispatch] = useReducer(reducer, initialState);
const { sourcererDataView: oldSourcererDataView } = useSourcererDataView();
const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled');
@ -31,29 +81,110 @@ export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
const sourcererDataView = newDataViewPickerEnabled ? dataViewSpec : oldSourcererDataView;
const initEngineCallBack = useCallback(
async (userCount: number) => {
dispatch({ type: 'INITIALIZING_ENGINE', userCount });
const response = await initPrivilegedMonitoringEngine();
dispatch({ type: 'UPDATE_INIT_ENGINE_RESPONSE', initResponse: response });
// TODO add status polling when BE API supports it
if (response.status === 'started') {
dispatch({
type: 'SHOW_DASHBOARD',
onboardingCallout: { userCount },
});
}
},
[initPrivilegedMonitoringEngine]
);
const onManageUserClicked = useCallback(() => {}, []);
return (
<>
{isOnboardingVisible && (
<SecuritySolutionPageWrapper>
<EuiButtonEmpty
onClick={() => {
setIsOnboardingVisible(false);
}}
>
{'Go to dashboards =>'}
</EuiButtonEmpty>
<PrivilegedUserMonitoringOnboardingPanel />
<EuiSpacer size="l" />
<PrivilegedUserMonitoringSampleDashboardsPanel />
</SecuritySolutionPageWrapper>
{state.type === 'dashboard' && (
<FiltersGlobal>
<SiemSearchBar id={InputsModelId.global} sourcererDataView={sourcererDataView} />
</FiltersGlobal>
)}
{!isOnboardingVisible && (
<>
<FiltersGlobal>
<SiemSearchBar id={InputsModelId.global} sourcererDataView={sourcererDataView} />
</FiltersGlobal>
<SecuritySolutionPageWrapper>
<SecuritySolutionPageWrapper>
{state.type === 'onboarding' && (
<>
<EuiButtonEmpty onClick={() => dispatch({ type: 'SHOW_DASHBOARD' })}>
{'Go to dashboards =>'}
</EuiButtonEmpty>
<PrivilegedUserMonitoringOnboardingPanel onComplete={initEngineCallBack} />
<EuiSpacer size="l" />
<PrivilegedUserMonitoringSampleDashboardsPanel />
</>
)}
{state.type === 'initializingEngine' && (
<>
<HeaderPage
title={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboards.pageTitle"
defaultMessage="Privileged user monitoring"
/>
}
/>
<EuiFlexGroup
css={css`
min-height: calc(100vh - 240px);
`}
>
{state.initResponse?.status === 'error' ? (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.initEngine.error.title"
defaultMessage="Error initializing resources"
/>
</h2>
}
body={
<EuiText color="subdued" data-test-subj="bodyText">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.initEngine.error.body"
defaultMessage="Sorry, there was an error initializing the privileged monitoring resources. Contact your administrator for help."
/>
</EuiText>
}
/>
) : (
<EuiEmptyPrompt
paddingSize="l"
hasShadow
titleSize="l"
color="plain"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.initEngine.title"
defaultMessage="Setting up privileged user monitoring"
/>
</h2>
}
icon={<EuiLoadingLogo logo="logoSecurity" size="xl" />}
body={
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.initEngine.description"
defaultMessage="We're currently analyzing your connected data sources to set up a comprehensive Privileged user monitoring. This may take a few moments."
/>
}
/>
)}
</EuiFlexGroup>
</>
)}
{state.type === 'dashboard' && (
<>
<HeaderPage
title={
<FormattedMessage
@ -62,23 +193,23 @@ export const EntityAnalyticsPrivilegedUserMonitoringPage = () => {
/>
}
rightSideItems={[
<EuiButtonEmpty
onClick={() => {
// TODO Implement the settings page
}}
iconType="gear"
color="primary"
>
{'Manage users'}
<EuiButtonEmpty onClick={onManageUserClicked} iconType="gear" color="primary">
<FormattedMessage
id="xpack.securitySolution.entityAnalytics.privilegedUserMonitoring.dashboards.manageUsersButton"
defaultMessage="Manage users"
/>
</EuiButtonEmpty>,
]}
/>
<PrivilegedUserMonitoring />
</SecuritySolutionPageWrapper>
</>
)}
<PrivilegedUserMonitoring
callout={state.onboardingCallout}
onManageUserClicked={onManageUserClicked}
/>
</>
)}
<SpyRoute pageName={SecurityPageName.entityAnalyticsPrivilegedUserMonitoring} />
<SpyRoute pageName={SecurityPageName.entityAnalyticsPrivilegedUserMonitoring} />
</SecuritySolutionPageWrapper>
</>
);
};

View file

@ -52,6 +52,16 @@ export const createMockConfig = (): ConfigType => {
pipelineDebugMode: false,
},
},
monitoring: {
privileges: {
users: {
csvUpload: {
errorRetries: 3,
maxBulkRequestBodySizeBytes: 10_485_760,
},
},
},
},
},
};
};

View file

@ -182,6 +182,16 @@ export const configSchema = schema.object({
pipelineDebugMode: schema.boolean({ defaultValue: false }),
}),
}),
monitoring: schema.object({
privileges: schema.object({
users: schema.object({
csvUpload: schema.object({
errorRetries: schema.number({ defaultValue: 1 }),
maxBulkRequestBodySizeBytes: schema.number({ defaultValue: 100_000 }), // 100KB
}),
}),
}),
}),
}),
siemRuleMigrations: schema.maybe(
schema.object({

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 type { Logger } from '@kbn/core/server';
import type { SecuritySolutionRequestHandlerContext } from '../../../types';
/**
* As internal user we check for existence of privilege monitoring resources.
* and initialise it if it does not exist
* @param context
* @param logger
*/
export const checkAndInitPrivilegedMonitoringResources = async (
context: SecuritySolutionRequestHandlerContext,
logger: Logger
) => {
const secSol = await context.securitySolution;
const privMonDataClient = await secSol.getPrivilegeMonitoringDataClient();
const doesIndexExist = await privMonDataClient.doesIndexExist();
if (!doesIndexExist) {
logger.info('Privilege monitoring resources are not installed, initialising...');
await privMonDataClient.createOrUpdateIndex().catch((e) => {
if (e.meta.body.error.type === 'resource_already_exists_exception') {
logger.info('Privilege monitoring index already exists');
}
});
logger.info('Privilege monitoring resources installed');
}
};

View file

@ -13,9 +13,11 @@ export const TIMEOUT = '10m';
export const INTERVAL = '10m';
export const PRIVILEGE_MONITORING_ENGINE_STATUS = {
INSTALLING: 'installing',
// TODO Make the engine initialization async before uncommenting these lines
// Also implement a status API for FE to poll
// INSTALLING: 'installing',
// STOPPED: 'stopped',
STARTED: 'started',
STOPPED: 'stopped',
ERROR: 'error',
} as const;

View file

@ -19,7 +19,12 @@ import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server';
import moment from 'moment';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import { merge } from 'lodash';
import Papa from 'papaparse';
import { Readable } from 'stream';
import { getPrivilegedMonitorUsersIndex } from '../../../../common/entity_analytics/privilege_monitoring/constants';
import type { PrivmonBulkUploadUsersCSVResponse } from '../../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import type { HapiReadableStream } from '../../../types';
import type { UpdatePrivMonUserRequestBody } from '../../../../common/api/entity_analytics/privilege_monitoring/users/update.gen';
import type {
@ -50,6 +55,13 @@ import {
} from '../../telemetry/event_based/events';
import type { PrivMonUserSource } from './types';
import { batchPartitions } from '../shared/streams/batching';
import { queryExistingUsers } from './users/query_existing_users';
import { bulkBatchUpsertFromCSV } from './users/bulk/update_from_csv';
import type { SoftDeletionResults } from './users/bulk/soft_delete_omitted_usrs';
import { softDeleteOmittedUsers } from './users/bulk/soft_delete_omitted_usrs';
import { privilegedUserParserTransform } from './users/privileged_user_parse_transform';
interface PrivilegeMonitoringClientOpts {
logger: Logger;
clusterClient: IScopedClusterClient;
@ -97,6 +109,8 @@ export class PrivilegeMonitoringDataClient {
await this.createOrUpdateIndex().catch((e) => {
if (e.meta.body.error.type === 'resource_already_exists_exception') {
this.opts.logger.info('Privilege monitoring index already exists');
} else {
throw e;
}
});
@ -156,6 +170,16 @@ export class PrivilegeMonitoringDataClient {
});
}
public async doesIndexExist() {
try {
return await this.internalUserClient.indices.exists({
index: this.getIndex(),
});
} catch (e) {
return false;
}
}
public async searchPrivilegesIndices(query: string | undefined) {
const { indices } = await this.esClient.fieldCaps({
index: [query ? `*${query}*` : '*', ...PRE_EXCLUDE_INDICES],
@ -238,6 +262,7 @@ export class PrivilegeMonitoringDataClient {
public async listUsers(kuery?: string): Promise<MonitoredUserDoc[]> {
const query = kuery ? toElasticsearchQuery(fromKueryExpression(kuery)) : { match_all: {} };
const response = await this.esClient.search({
size: 10000,
index: this.getIndex(),
query,
});
@ -247,6 +272,40 @@ export class PrivilegeMonitoringDataClient {
})) as MonitoredUserDoc[];
}
public async uploadUsersCSV(
stream: HapiReadableStream,
{ retries, flushBytes }: { retries: number; flushBytes: number }
): Promise<PrivmonBulkUploadUsersCSVResponse> {
const csvStream = Papa.parse(Papa.NODE_STREAM_INPUT, {
header: false,
dynamicTyping: true,
skipEmptyLines: true,
});
return Readable.from(stream.pipe(csvStream))
.pipe(privilegedUserParserTransform())
.pipe(batchPartitions(100)) // we cant use .map() because we need to hook into the stream flush to finish the last batch
.map(queryExistingUsers(this.esClient, this.getIndex()))
.map(bulkBatchUpsertFromCSV(this.esClient, this.getIndex(), { flushBytes, retries }))
.map(softDeleteOmittedUsers(this.esClient, this.getIndex(), { flushBytes, retries }))
.reduce(
(
{ errors, stats }: PrivmonBulkUploadUsersCSVResponse,
batch: SoftDeletionResults
): PrivmonBulkUploadUsersCSVResponse => {
return {
errors: errors.concat(batch.updated.errors),
stats: {
failed: stats.failed + batch.updated.failed,
successful: stats.successful + batch.updated.successful,
total: stats.total + batch.updated.failed + batch.updated.successful,
},
};
},
{ errors: [], stats: { failed: 0, successful: 0, total: 0 } }
);
}
private log(level: Exclude<keyof Logger, 'get' | 'log' | 'isLevelEnabled'>, msg: string) {
this.opts.logger[level](
`[Privileged Monitoring Engine][namespace: ${this.opts.namespace}] ${msg}`

View file

@ -18,7 +18,6 @@ import {
listUsersRoute,
updateUserRoute,
uploadUsersCSVRoute,
uploadUsersJSONRoute,
} from './users';
export const registerPrivilegeMonitoringRoutes = ({
@ -35,6 +34,5 @@ export const registerPrivilegeMonitoringRoutes = ({
deleteUserRoute(router, logger);
listUsersRoute(router, logger);
updateUserRoute(router, logger);
uploadUsersCSVRoute(router, logger);
uploadUsersJSONRoute(router, logger);
uploadUsersCSVRoute(router, logger, config);
};

View file

@ -10,5 +10,4 @@ export * from './get';
export * from './list';
export * from './update';
export * from './delete';
export * from './upload_json';
export * from './upload_csv';

View file

@ -9,13 +9,19 @@ import type { IKibanaResponse, Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { BulkUploadUsersCSVResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import { schema } from '@kbn/config-schema';
import { PRIVMON_USERS_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE } from '../../../../../../common/entity_analytics/privileged_user_monitoring/constants';
import type { HapiReadableStream } from '../../../../../types';
import type { ConfigType } from '../../../../../config';
import type { PrivmonBulkUploadUsersCSVResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/users/upload_csv.gen';
import { API_VERSIONS, APP_ID } from '../../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../../types';
import { checkAndInitPrivilegedMonitoringResources } from '../../check_and_init_prvileged_monitoring_resources';
export const uploadUsersCSVRoute = (
router: EntityAnalyticsRoutesDeps['router'],
logger: Logger
logger: Logger,
config: ConfigType
) => {
router.versioned
.post({
@ -26,21 +32,49 @@ export const uploadUsersCSVRoute = (
requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`],
},
},
options: {
body: {
output: 'stream',
accepts: 'multipart/form-data',
maxBytes: PRIVMON_USERS_CSV_MAX_SIZE_BYTES_WITH_TOLERANCE,
},
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: {},
request: {
body: schema.object({
file: schema.stream(),
}),
},
},
},
async (context, request, response): Promise<IKibanaResponse<BulkUploadUsersCSVResponse>> => {
async (
context,
request,
response
): Promise<IKibanaResponse<PrivmonBulkUploadUsersCSVResponse>> => {
const { errorRetries, maxBulkRequestBodySizeBytes } =
config.entityAnalytics.monitoring.privileges.users.csvUpload;
const siemResponse = buildSiemResponse(response);
try {
// Placeholder for actual implementation
return response.ok({ body: { upserted_count: 15 } });
await checkAndInitPrivilegedMonitoringResources(context, logger);
const secSol = await context.securitySolution;
const fileStream = request.body.file as HapiReadableStream;
const body = await secSol.getPrivilegeMonitoringDataClient().uploadUsersCSV(fileStream, {
retries: errorRetries,
flushBytes: maxBulkRequestBodySizeBytes,
});
return response.ok({ body });
} catch (e) {
// TODO TEST THIS ERROR SCENARIO
const error = transformError(e);
logger.error(`Error uploading users via CSV: ${error.message}`);
return siemResponse.error({

View file

@ -1,53 +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 type { IKibanaResponse, Logger } from '@kbn/core/server';
import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { BulkUploadUsersJSONResponse } from '../../../../../../common/api/entity_analytics/privilege_monitoring/users/upload_json.gen';
import { API_VERSIONS, APP_ID } from '../../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../../types';
export const uploadUsersJSONRoute = (
router: EntityAnalyticsRoutesDeps['router'],
logger: Logger
) => {
router.versioned
.post({
access: 'public',
path: '/api/entity_analytics/monitoring/users/_json',
security: {
authz: {
requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`],
},
},
})
.addVersion(
{
version: API_VERSIONS.public.v1,
validate: {
request: {},
},
},
async (context, request, response): Promise<IKibanaResponse<BulkUploadUsersJSONResponse>> => {
const siemResponse = buildSiemResponse(response);
try {
// Placeholder for actual implementation
return response.ok({ body: { upserted_count: 10 } });
} catch (e) {
const error = transformError(e);
logger.error(`Error uploading users via JSON: ${error.message}`);
return siemResponse.error({
statusCode: error.statusCode,
body: error.message,
});
}
}
);
};

View file

@ -51,7 +51,7 @@ describe('PrivilegeMonitoringEngineDescriptorClient', () => {
expect(soClient.create).toHaveBeenCalledWith(
privilegeMonitoringTypeName,
{ status: PRIVILEGE_MONITORING_ENGINE_STATUS.INSTALLING },
{ status: PRIVILEGE_MONITORING_ENGINE_STATUS.STARTED },
{ id: `privilege-monitoring-${namespace}` }
);
expect(result).toEqual({ status: 'installing' });
@ -78,10 +78,10 @@ describe('PrivilegeMonitoringEngineDescriptorClient', () => {
expect(soClient.update).toHaveBeenCalledWith(
privilegeMonitoringTypeName,
`privilege-monitoring-${namespace}`,
{ status: PRIVILEGE_MONITORING_ENGINE_STATUS.INSTALLING, apiKey: '', error: undefined },
{ status: PRIVILEGE_MONITORING_ENGINE_STATUS.STARTED, apiKey: '', error: undefined },
{ refresh: 'wait_for' }
);
expect(result).toEqual({ status: 'installing', apiKey: '' });
expect(result).toEqual({ status: 'started', apiKey: '', error: undefined });
});
it('should update the descriptor', async () => {

View file

@ -36,7 +36,7 @@ export class PrivilegeMonitoringEngineDescriptorClient {
const { attributes } = await this.deps.soClient.create<PrivilegedMonitoringEngineDescriptor>(
privilegeMonitoringTypeName,
{
status: PRIVILEGE_MONITORING_ENGINE_STATUS.INSTALLING,
status: PRIVILEGE_MONITORING_ENGINE_STATUS.STARTED,
},
{ id: this.getSavedObjectId() }
);
@ -50,7 +50,7 @@ export class PrivilegeMonitoringEngineDescriptorClient {
const update = {
...old,
error: undefined,
status: PRIVILEGE_MONITORING_ENGINE_STATUS.INSTALLING,
status: PRIVILEGE_MONITORING_ENGINE_STATUS.STARTED,
apiKey: '',
};
await this.deps.soClient.update<PrivilegedMonitoringEngineDescriptor>(

View file

@ -0,0 +1,78 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import { isRight } from 'fp-ts/Either';
import type { MonitoredUserDoc } from '../../../../../../common/api/entity_analytics/privilege_monitoring/users/common.gen';
import type { BulkProcessingResults, Options } from './types';
export interface SoftDeletionResults {
updated: BulkProcessingResults;
deleted: {
successful: number;
failed: number;
errors: BulkProcessingResults['errors'];
users: string[];
};
}
export const softDeleteOmittedUsers =
(esClient: ElasticsearchClient, index: string, { flushBytes, retries }: Options) =>
async (processed: BulkProcessingResults) => {
const uploaded = processed.batch.uploaded.reduce((acc: string[], either) => {
if (!isRight(either)) {
return acc;
}
acc.push(either.right.username);
return acc;
}, []);
const res = await esClient.helpers.search<MonitoredUserDoc>({
index,
query: {
bool: {
must: [
{ term: { 'labels.monitoring.privileged_users': 'monitored' } },
{ term: { 'labels.sources': 'csv' } },
],
must_not: [{ terms: { 'user.name': uploaded } }],
},
},
});
const usersToDelete = res.map((hit) => hit._id);
const errors: BulkProcessingResults['errors'] = [];
const { failed, successful } = await esClient.helpers.bulk<string>({
index,
datasource: usersToDelete,
flushBytes,
retries,
refreshOnCompletion: index,
onDocument: (id) => {
return [
{ update: { _id: id } },
{
doc: {
labels: { monitoring: { privileged_users: 'deleted' } },
},
},
];
},
onDrop: ({ error, document }) => {
errors.push({
message: error?.message || 'Unknown error',
username: document,
index: null, // The error is not related to a specific row in a CSV
});
},
});
return {
updated: processed,
deleted: { failed, successful, errors, users: usersToDelete },
} satisfies SoftDeletionResults;
};

View file

@ -0,0 +1,36 @@
/*
* 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 { Either } from 'fp-ts/Either';
export interface BulkPrivMonUser {
username: string;
index: number;
}
export interface Batch {
uploaded: Array<Either<BulkProcessingError, BulkPrivMonUser>>;
existingUsers: Record<string, string>;
}
export interface Options {
flushBytes: number;
retries: number;
}
export interface BulkProcessingError {
message: string;
username: string | null;
index: number | null;
}
export interface BulkProcessingResults {
failed: number;
successful: number;
errors: BulkProcessingError[];
batch: Batch;
}

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ElasticsearchClient } from '@kbn/core/server';
import { Readable } from 'stream';
import type { Either } from 'fp-ts/Either';
import { isRight } from 'fp-ts/Either';
import type {
Batch,
BulkPrivMonUser,
BulkProcessingError,
BulkProcessingResults,
Options,
} from './types';
export const bulkBatchUpsertFromCSV =
(esClient: ElasticsearchClient, index: string, { flushBytes, retries }: Options) =>
async (batch: Batch) => {
const errors: BulkProcessingError[] = [];
let parsingFailures = 0;
const { failed, successful } = await esClient.helpers.bulk<BulkPrivMonUser>({
index,
flushBytes,
retries,
datasource: Readable.from(batch.uploaded)
.filter((either: Either<BulkProcessingError, BulkPrivMonUser>) => {
if (isRight(either)) {
return true;
}
errors.push(either.left);
parsingFailures++;
return false;
})
.map((e) => e.right),
refreshOnCompletion: index,
onDrop: ({ error, document }) => {
errors.push({
message: error?.message || 'Unknown error',
username: document.username,
index: document.index,
});
},
onDocument: (row) => {
const id = batch.existingUsers[row.username];
const labels = {
monitoring: { privileged_users: 'monitored' },
sources: ['csv'],
};
if (!id) {
return [
{ create: {} },
{
user: { name: row.username },
labels,
},
];
}
return [
{ update: { _id: id } },
{
doc: {
user: { name: row.username },
labels,
},
},
];
},
});
return {
failed: failed + parsingFailures,
successful,
errors,
batch,
} satisfies BulkProcessingResults;
};

View file

@ -0,0 +1,36 @@
/*
* 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 { Transform } from 'stream';
import { right, left, isRight } from 'fp-ts/Either';
import type { Either } from 'fp-ts/Either';
import { parseMonitoredPrivilegedUserCsvRow } from '../../../../../common/entity_analytics/privileged_user_monitoring/parse_privileged_user_monitoring_csv_row';
import type { BulkPrivMonUser, BulkProcessingError } from './bulk/types';
/**
* Transform stream that processes rows of a CSV file containing privileged user data.
* It parses each row, extracting the username and returning it in a structured format.
* @param {number} initialRowIndex - The starting index for the rows, defaults to 1, since CSV file are not zero indexed.
*/
export const privilegedUserParserTransform = (initialRowIndex = 1) => {
let index = initialRowIndex;
return new Transform({
objectMode: true,
transform(row: string[], _encoding, callback) {
const result = parseMonitoredPrivilegedUserCsvRow(row);
const formattedResult: Either<BulkProcessingError, BulkPrivMonUser> = isRight(result)
? right({ username: result.right, index })
: left({ index, message: result.left, username: null }); // The username could not be found in the row
index++;
callback(null, formattedResult);
},
});
};

View file

@ -0,0 +1,51 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import { isRight, type Either } from 'fp-ts/Either';
import type { MonitoredUserDoc } from '../../../../../common/api/entity_analytics/privilege_monitoring/users/common.gen';
import type { Batch, BulkPrivMonUser, BulkProcessingError } from './bulk/types';
export const queryExistingUsers =
(esClient: ElasticsearchClient, index: string) =>
(batch: Array<Either<BulkProcessingError, BulkPrivMonUser>>) =>
esClient
.search<MonitoredUserDoc>({
index,
query: {
bool: {
must: [
{
terms: {
'user.name': Array.from(batch)
.filter(isRight)
.map((e) => e.right.username),
},
},
],
},
},
})
.then((response) =>
response.hits.hits.reduce<Record<string, string>>((users, hit) => {
if (!hit._source?.user?.name) {
throw new Error('User name is missing');
}
if (!hit._id) {
throw new Error('User ID is missing');
}
users[hit._source.user.name as string] = hit._id;
return users;
}, {})
)
.then(
(existingUsers): Batch => ({
existingUsers,
uploaded: batch,
})
);

View file

@ -0,0 +1,40 @@
/*
* 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 { Transform } from 'stream';
/**
* Creates a Transform stream that batches incoming data into arrays of a specified size.
* When the buffer reaches the specified batch size, it emits the batch and resets the buffer.
* If there are remaining items in the buffer when the stream ends, it emits them as a final batch via the `flush` method.
*
* @param batchSize - The size of each batch to emit.
* @returns A Transform stream that batches incoming data.
*/
export function batchPartitions<T>(batchSize: number) {
let buffer: T[] = [];
return new Transform({
objectMode: true,
transform(chunk: T, _encoding, callback) {
buffer.push(chunk);
if (buffer.length >= batchSize) {
const batch = buffer;
buffer = [];
this.push(batch);
}
callback();
},
flush(callback) {
if (buffer.length > 0) {
this.push(buffer); // Emit final partial batch
}
callback();
},
});
}

View file

@ -225,20 +225,6 @@ after 30 days. It also deletes other artifacts specific to the migration impleme
.set(ELASTIC_HTTP_VERSION_HEADER, '1')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
bulkUploadUsersCsv(kibanaSpace: string = 'default') {
return supertest
.post(routeWithNamespace('/api/entity_analytics/monitoring/users/_csv', kibanaSpace))
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
bulkUploadUsersJson(kibanaSpace: string = 'default') {
return supertest
.post(routeWithNamespace('/api/entity_analytics/monitoring/users/_json', kibanaSpace))
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
/**
* Bulk upsert up to 1000 asset criticality records.
@ -1489,6 +1475,13 @@ The edit action is idempotent, meaning that if you add a tag to a rule that alre
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana')
.send(props.body as object);
},
privmonBulkUploadUsersCsv(kibanaSpace: string = 'default') {
return supertest
.post(routeWithNamespace('/api/entity_analytics/monitoring/users/_csv', kibanaSpace))
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana');
},
privMonHealth(kibanaSpace: string = 'default') {
return supertest
.get(routeWithNamespace('/api/entity_analytics/monitoring/privileges/health', kibanaSpace))