[Audit Logging] Add AuditTrail service (#69278)

* add generic audit_trail service in core

* expose auditTraik service to plugins

* add auditTrail x-pack plugin

* fix type errors

* update mocks

* expose asScoped interface via start. auditor via  request context

* use type from audit trail service

* wrap getActiveSpace in safeCall only. it throws exception for non-authz

* pass message to log explicitly

* update docs

* create one auditor per request

* wire es client up to auditor

* update docs

* withScope accepts only one scope

* use scoped client in context for callAsInternalUser

* use auditor in scoped cluster client

* adopt auditTrail plugin to new interface. configure log from config

* do not log audit events in console by default

* add audit trail functional tests

* cleanup

* add example

* add mocks for spaces plugin

* add unit tests

* update docs

* test description

* Apply suggestions from code review

apply @jportner suggestions

Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>

* add unit tests

* more robust tests

* make spaces optional

* address comments

* update docs

* fix WebStorm refactoring

Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
Mikhail Shustov 2020-07-07 22:16:39 +03:00 committed by GitHub
parent 3884a3c494
commit aeff8c154b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 1625 additions and 76 deletions

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditableEvent](./kibana-plugin-core-server.auditableevent.md)
## AuditableEvent interface
Event to audit.
<b>Signature:</b>
```typescript
export interface AuditableEvent
```
## Remarks
Not a complete interface.
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [message](./kibana-plugin-core-server.auditableevent.message.md) | <code>string</code> | |
| [type](./kibana-plugin-core-server.auditableevent.type.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) &gt; [message](./kibana-plugin-core-server.auditableevent.message.md)
## AuditableEvent.message property
<b>Signature:</b>
```typescript
message: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) &gt; [type](./kibana-plugin-core-server.auditableevent.type.md)
## AuditableEvent.type property
<b>Signature:</b>
```typescript
type: string;
```

View file

@ -0,0 +1,36 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [Auditor](./kibana-plugin-core-server.auditor.md) &gt; [add](./kibana-plugin-core-server.auditor.add.md)
## Auditor.add() method
Add a record to audit log. Service attaches to a log record: - metadata about an end-user initiating an operation - scope name, if presents
<b>Signature:</b>
```typescript
add(event: AuditableEvent): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| event | <code>AuditableEvent</code> | |
<b>Returns:</b>
`void`
## Example
How to add a record in audit log:
```typescript
router.get({ path: '/my_endpoint', validate: false }, async (context, request, response) => {
context.core.auditor.withAuditScope('my_plugin_operation');
const value = await context.core.elasticsearch.legacy.client.callAsCurrentUser('...');
context.core.add({ type: 'operation.type', message: 'perform an operation in ... endpoint' });
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [Auditor](./kibana-plugin-core-server.auditor.md)
## Auditor interface
Provides methods to log user actions and access events.
<b>Signature:</b>
```typescript
export interface Auditor
```
## Methods
| Method | Description |
| --- | --- |
| [add(event)](./kibana-plugin-core-server.auditor.add.md) | Add a record to audit log. Service attaches to a log record: - metadata about an end-user initiating an operation - scope name, if presents |
| [withAuditScope(name)](./kibana-plugin-core-server.auditor.withauditscope.md) | Add a high-level scope name for logged events. It helps to identify the root cause of low-level events. |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [Auditor](./kibana-plugin-core-server.auditor.md) &gt; [withAuditScope](./kibana-plugin-core-server.auditor.withauditscope.md)
## Auditor.withAuditScope() method
Add a high-level scope name for logged events. It helps to identify the root cause of low-level events.
<b>Signature:</b>
```typescript
withAuditScope(name: string): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| name | <code>string</code> | |
<b>Returns:</b>
`void`

View file

@ -0,0 +1,22 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) &gt; [asScoped](./kibana-plugin-core-server.auditorfactory.asscoped.md)
## AuditorFactory.asScoped() method
<b>Signature:</b>
```typescript
asScoped(request: KibanaRequest): Auditor;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| request | <code>KibanaRequest</code> | |
<b>Returns:</b>
`Auditor`

View file

@ -0,0 +1,20 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md)
## AuditorFactory interface
Creates [Auditor](./kibana-plugin-core-server.auditor.md) instance bound to the current user credentials.
<b>Signature:</b>
```typescript
export interface AuditorFactory
```
## Methods
| Method | Description |
| --- | --- |
| [asScoped(request)](./kibana-plugin-core-server.auditorfactory.asscoped.md) | |

View file

@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md)
## AuditTrailSetup interface
<b>Signature:</b>
```typescript
export interface AuditTrailSetup
```
## Methods
| Method | Description |
| --- | --- |
| [register(auditor)](./kibana-plugin-core-server.audittrailsetup.register.md) | Register a custom [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) implementation. |

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) &gt; [register](./kibana-plugin-core-server.audittrailsetup.register.md)
## AuditTrailSetup.register() method
Register a custom [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) implementation.
<b>Signature:</b>
```typescript
register(auditor: AuditorFactory): void;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| auditor | <code>AuditorFactory</code> | |
<b>Returns:</b>
`void`

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [AuditTrailStart](./kibana-plugin-core-server.audittrailstart.md)
## AuditTrailStart type
<b>Signature:</b>
```typescript
export declare type AuditTrailStart = AuditorFactory;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [CoreSetup](./kibana-plugin-core-server.coresetup.md) &gt; [auditTrail](./kibana-plugin-core-server.coresetup.audittrail.md)
## CoreSetup.auditTrail property
[AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md)
<b>Signature:</b>
```typescript
auditTrail: AuditTrailSetup;
```

View file

@ -16,6 +16,7 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
| Property | Type | Description |
| --- | --- | --- |
| [auditTrail](./kibana-plugin-core-server.coresetup.audittrail.md) | <code>AuditTrailSetup</code> | [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) |
| [capabilities](./kibana-plugin-core-server.coresetup.capabilities.md) | <code>CapabilitiesSetup</code> | [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md) |
| [context](./kibana-plugin-core-server.coresetup.context.md) | <code>ContextSetup</code> | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) |
| [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | <code>ElasticsearchServiceSetup</code> | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [CoreStart](./kibana-plugin-core-server.corestart.md) &gt; [auditTrail](./kibana-plugin-core-server.corestart.audittrail.md)
## CoreStart.auditTrail property
[AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md)
<b>Signature:</b>
```typescript
auditTrail: AuditTrailStart;
```

View file

@ -16,6 +16,7 @@ export interface CoreStart
| Property | Type | Description |
| --- | --- | --- |
| [auditTrail](./kibana-plugin-core-server.corestart.audittrail.md) | <code>AuditTrailStart</code> | [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) |
| [capabilities](./kibana-plugin-core-server.corestart.capabilities.md) | <code>CapabilitiesStart</code> | [CapabilitiesStart](./kibana-plugin-core-server.capabilitiesstart.md) |
| [elasticsearch](./kibana-plugin-core-server.corestart.elasticsearch.md) | <code>ElasticsearchServiceStart</code> | [ElasticsearchServiceStart](./kibana-plugin-core-server.elasticsearchservicestart.md) |
| [http](./kibana-plugin-core-server.corestart.http.md) | <code>HttpServiceStart</code> | [HttpServiceStart](./kibana-plugin-core-server.httpservicestart.md) |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [HttpServerInfo](./kibana-plugin-core-server.httpserverinfo.md) &gt; [hostname](./kibana-plugin-core-server.httpserverinfo.hostname.md)
## HttpServerInfo.hostname property
The hostname of the server
<b>Signature:</b>
```typescript
hostname: string;
```

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `LegacyClusterClient` class
<b>Signature:</b>
```typescript
constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders);
constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuditorFactory: () => AuditorFactory, getAuthHeaders?: GetAuthHeaders);
```
## Parameters
@ -18,5 +18,6 @@ constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders
| --- | --- | --- |
| config | <code>LegacyElasticsearchClientConfig</code> | |
| log | <code>Logger</code> | |
| getAuditorFactory | <code>() =&gt; AuditorFactory</code> | |
| getAuthHeaders | <code>GetAuthHeaders</code> | |

View file

@ -15,7 +15,7 @@ export declare class LegacyClusterClient implements ILegacyClusterClient
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(config, log, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the <code>LegacyClusterClient</code> class |
| [(constructor)(config, log, getAuditorFactory, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the <code>LegacyClusterClient</code> class |
## Properties

View file

@ -9,7 +9,7 @@ Constructs a new instance of the `LegacyScopedClusterClient` class
<b>Signature:</b>
```typescript
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined);
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined, auditor?: Auditor | undefined);
```
## Parameters
@ -19,4 +19,5 @@ constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller
| internalAPICaller | <code>LegacyAPICaller</code> | |
| scopedAPICaller | <code>LegacyAPICaller</code> | |
| headers | <code>Headers &#124; undefined</code> | |
| auditor | <code>Auditor &#124; undefined</code> | |

View file

@ -15,7 +15,7 @@ export declare class LegacyScopedClusterClient implements ILegacyScopedClusterCl
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(internalAPICaller, scopedAPICaller, headers)](./kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md) | | Constructs a new instance of the <code>LegacyScopedClusterClient</code> class |
| [(constructor)(internalAPICaller, scopedAPICaller, headers, auditor)](./kibana-plugin-core-server.legacyscopedclusterclient._constructor_.md) | | Constructs a new instance of the <code>LegacyScopedClusterClient</code> class |
## Methods

View file

@ -56,6 +56,10 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| --- | --- |
| [AssistanceAPIResponse](./kibana-plugin-core-server.assistanceapiresponse.md) | |
| [AssistantAPIClientParams](./kibana-plugin-core-server.assistantapiclientparams.md) | |
| [AuditableEvent](./kibana-plugin-core-server.auditableevent.md) | Event to audit. |
| [Auditor](./kibana-plugin-core-server.auditor.md) | Provides methods to log user actions and access events. |
| [AuditorFactory](./kibana-plugin-core-server.auditorfactory.md) | Creates [Auditor](./kibana-plugin-core-server.auditor.md) instance bound to the current user credentials. |
| [AuditTrailSetup](./kibana-plugin-core-server.audittrailsetup.md) | |
| [Authenticated](./kibana-plugin-core-server.authenticated.md) | |
| [AuthNotHandled](./kibana-plugin-core-server.authnothandled.md) | |
| [AuthRedirected](./kibana-plugin-core-server.authredirected.md) | |
@ -212,6 +216,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| Type Alias | Description |
| --- | --- |
| [AppenderConfigType](./kibana-plugin-core-server.appenderconfigtype.md) | |
| [AuditTrailStart](./kibana-plugin-core-server.audittrailstart.md) | |
| [AuthenticationHandler](./kibana-plugin-core-server.authenticationhandler.md) | See [AuthToolkit](./kibana-plugin-core-server.authtoolkit.md)<!-- -->. |
| [AuthHeaders](./kibana-plugin-core-server.authheaders.md) | Auth Headers map |
| [AuthResult](./kibana-plugin-core-server.authresult.md) | |

View file

@ -20,5 +20,6 @@ core: {
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
};
```

View file

@ -18,5 +18,5 @@ export interface RequestHandlerContext
| Property | Type | Description |
| --- | --- | --- |
| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | <code>{</code><br/><code> savedObjects: {</code><br/><code> client: SavedObjectsClientContract;</code><br/><code> typeRegistry: ISavedObjectTypeRegistry;</code><br/><code> };</code><br/><code> elasticsearch: {</code><br/><code> legacy: {</code><br/><code> client: ILegacyScopedClusterClient;</code><br/><code> };</code><br/><code> };</code><br/><code> uiSettings: {</code><br/><code> client: IUiSettingsClient;</code><br/><code> };</code><br/><code> }</code> | |
| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | <code>{</code><br/><code> savedObjects: {</code><br/><code> client: SavedObjectsClientContract;</code><br/><code> typeRegistry: ISavedObjectTypeRegistry;</code><br/><code> };</code><br/><code> elasticsearch: {</code><br/><code> legacy: {</code><br/><code> client: ILegacyScopedClusterClient;</code><br/><code> };</code><br/><code> };</code><br/><code> uiSettings: {</code><br/><code> client: IUiSettingsClient;</code><br/><code> };</code><br/><code> auditor: Auditor;</code><br/><code> }</code> | |

View file

@ -4,7 +4,7 @@
## Comparator type
Used to compare state. see [useContainerSelector](./kibana-plugin-plugins-kibana_utils-common-state_containers.usecontainerselector.md)
Used to compare state, see [useContainerSelector](./kibana-plugin-plugins-kibana_utils-common-state_containers.usecontainerselector.md)<!-- -->.
<b>Signature:</b>

View file

@ -4,7 +4,7 @@
## Connect type
Similar to `connect` from react-redux, allows to map state from state container to component's props
Similar to `connect` from react-redux, allows to map state from state container to component's props.
<b>Signature:</b>

View file

@ -4,7 +4,7 @@
## createStateContainer() function
Creates a state container with transitions, but without selectors
Creates a state container with transitions, but without selectors.
<b>Signature:</b>

View file

@ -4,7 +4,7 @@
## createStateContainer() function
Creates a state container with transitions and selectors
Creates a state container with transitions and selectors.
<b>Signature:</b>

View file

@ -4,7 +4,7 @@
## CreateStateContainerOptions.freeze property
Function to use when freezing state. Supply identity function. If not provided, default deepFreeze is use.
Function to use when freezing state. Supply identity function. If not provided, default `deepFreeze` is used.
<b>Signature:</b>

View file

@ -16,5 +16,5 @@ export interface CreateStateContainerOptions
| Property | Type | Description |
| --- | --- | --- |
| [freeze](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontaineroptions.freeze.md) | <code>&lt;T&gt;(state: T) =&gt; T</code> | Function to use when freezing state. Supply identity function. If not provided, default deepFreeze is use. |
| [freeze](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontaineroptions.freeze.md) | <code>&lt;T&gt;(state: T) =&gt; T</code> | Function to use when freezing state. Supply identity function. If not provided, default <code>deepFreeze</code> is used. |

View file

@ -11,8 +11,8 @@ State containers are Redux-store-like objects meant to help you manage state in
| Function | Description |
| --- | --- |
| [createStateContainer(defaultState)](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontainer.md) | Creates a state container without transitions and without selectors. |
| [createStateContainer(defaultState, pureTransitions)](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontainer_1.md) | Creates a state container with transitions, but without selectors |
| [createStateContainer(defaultState, pureTransitions, pureSelectors, options)](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontainer_2.md) | Creates a state container with transitions and selectors |
| [createStateContainer(defaultState, pureTransitions)](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontainer_1.md) | Creates a state container with transitions, but without selectors. |
| [createStateContainer(defaultState, pureTransitions, pureSelectors, options)](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontainer_2.md) | Creates a state container with transitions and selectors. |
## Interfaces
@ -20,8 +20,8 @@ State containers are Redux-store-like objects meant to help you manage state in
| --- | --- |
| [BaseStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.basestatecontainer.md) | Base state container shape without transitions or selectors |
| [CreateStateContainerOptions](./kibana-plugin-plugins-kibana_utils-common-state_containers.createstatecontaineroptions.md) | State container options |
| [ReduxLikeStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.reduxlikestatecontainer.md) | Fully featured state container which matches Redux store interface. Extends [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md) Allows to use state container with redux libraries |
| [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md) | Fully featured state container with [Selectors](./kibana-plugin-plugins-kibana_utils-common-state_containers.selector.md) and . Extends [BaseStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.basestatecontainer.md) |
| [ReduxLikeStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.reduxlikestatecontainer.md) | Fully featured state container which matches Redux store interface. Extends [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md)<!-- -->. Allows to use state container with redux libraries. |
| [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md) | Fully featured state container with [Selectors](./kibana-plugin-plugins-kibana_utils-common-state_containers.selector.md) and . Extends [BaseStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.basestatecontainer.md)<!-- -->. |
## Variables
@ -36,8 +36,8 @@ State containers are Redux-store-like objects meant to help you manage state in
| Type Alias | Description |
| --- | --- |
| [BaseState](./kibana-plugin-plugins-kibana_utils-common-state_containers.basestate.md) | Base [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md) state shape |
| [Comparator](./kibana-plugin-plugins-kibana_utils-common-state_containers.comparator.md) | Used to compare state. see [useContainerSelector](./kibana-plugin-plugins-kibana_utils-common-state_containers.usecontainerselector.md) |
| [Connect](./kibana-plugin-plugins-kibana_utils-common-state_containers.connect.md) | Similar to <code>connect</code> from react-redux, allows to map state from state container to component's props |
| [Comparator](./kibana-plugin-plugins-kibana_utils-common-state_containers.comparator.md) | Used to compare state, see [useContainerSelector](./kibana-plugin-plugins-kibana_utils-common-state_containers.usecontainerselector.md)<!-- -->. |
| [Connect](./kibana-plugin-plugins-kibana_utils-common-state_containers.connect.md) | Similar to <code>connect</code> from react-redux, allows to map state from state container to component's props. |
| [Dispatch](./kibana-plugin-plugins-kibana_utils-common-state_containers.dispatch.md) | Redux like dispatch |
| [EnsurePureSelector](./kibana-plugin-plugins-kibana_utils-common-state_containers.ensurepureselector.md) | |
| [EnsurePureTransition](./kibana-plugin-plugins-kibana_utils-common-state_containers.ensurepuretransition.md) | |

View file

@ -4,7 +4,7 @@
## ReduxLikeStateContainer interface
Fully featured state container which matches Redux store interface. Extends [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md) Allows to use state container with redux libraries
Fully featured state container which matches Redux store interface. Extends [StateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.statecontainer.md)<!-- -->. Allows to use state container with redux libraries.
<b>Signature:</b>

View file

@ -4,7 +4,7 @@
## StateContainer interface
Fully featured state container with [Selectors](./kibana-plugin-plugins-kibana_utils-common-state_containers.selector.md) and . Extends [BaseStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.basestatecontainer.md)
Fully featured state container with [Selectors](./kibana-plugin-plugins-kibana_utils-common-state_containers.selector.md) and . Extends [BaseStateContainer](./kibana-plugin-plugins-kibana_utils-common-state_containers.basestatecontainer.md)<!-- -->.
<b>Signature:</b>

View file

@ -8,5 +8,5 @@
| Package | Description |
| --- | --- |
| [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) | State syncing utilities are a set of helpers for syncing your application state with URL or browser storage.<!-- -->They are designed to work together with state containers (<!-- -->). But state containers are not required.<!-- -->State syncing utilities include:<!-- -->- util which: - Subscribes to state changes and pushes them to state storage. - Optionally subscribes to state storage changes and pushes them to state. - Two types of storage compatible with <code>syncState</code>: - - Serializes state and persists it to URL's query param in rison or hashed format. Listens for state updates in the URL and pushes them back to state. - - Serializes state and persists it to browser storage.<!-- -->Refer [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples |
| [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) | State syncing utilities are a set of helpers for syncing your application state with browser URL or browser storage.<!-- -->They are designed to work together with [state containers](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_containers)<!-- -->. But state containers are not required.<!-- -->State syncing utilities include:<!-- -->\* util which: \* Subscribes to state changes and pushes them to state storage. \* Optionally subscribes to state storage changes and pushes them to state. \* Two types of storages compatible with <code>syncState</code>: \* - Serializes state and persists it to URL's query param in rison or hashed format. Listens for state updates in the URL and pushes them back to state. \* - Serializes state and persists it to browser storage.<!-- -->Refer [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples. |

View file

@ -4,7 +4,7 @@
## IKbnUrlStateStorage.flush property
synchronously runs any pending url updates returned boolean indicates if change occurred
Synchronously runs any pending url updates, returned boolean indicates if change occurred.
<b>Signature:</b>

View file

@ -4,7 +4,11 @@
## IKbnUrlStateStorage interface
KbnUrlStateStorage is a state storage for [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) utility which: 1. Keeps state in sync with the URL. 2. Serializes data and stores it in the URL in one of the supported formats: \* Rison encoded. \* Hashed URL: In URL we store only the hash from the serialized state, but the state itself is stored in sessionStorage. See kibana's advanced option for more context state:storeInSessionStorage 3. Takes care of listening to the URL updates and notifies state about the updates. 4. Takes care of batching URL updates to prevent redundant browser history records. [GUIDE](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md)
KbnUrlStateStorage is a state storage for [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) utility which:
1. Keeps state in sync with the URL. 2. Serializes data and stores it in the URL in one of the supported formats: \* Rison encoded. \* Hashed URL: In URL we store only the hash from the serialized state, but the state itself is stored in sessionStorage. See Kibana's `state:storeInSessionStorage` advanced option for more context. 3. Takes care of listening to the URL updates and notifies state about the updates. 4. Takes care of batching URL updates to prevent redundant browser history records.
[Refer to this guide for more info](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md)
<b>Signature:</b>
@ -18,7 +22,7 @@ export interface IKbnUrlStateStorage extends IStateStorage
| --- | --- | --- |
| [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md) | <code>() =&gt; void</code> | cancels any pending url updates |
| [change$](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.change_.md) | <code>&lt;State = unknown&gt;(key: string) =&gt; Observable&lt;State &#124; null&gt;</code> | |
| [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) | <code>(opts?: {</code><br/><code> replace?: boolean;</code><br/><code> }) =&gt; boolean</code> | synchronously runs any pending url updates returned boolean indicates if change occurred |
| [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) | <code>(opts?: {</code><br/><code> replace?: boolean;</code><br/><code> }) =&gt; boolean</code> | Synchronously runs any pending url updates, returned boolean indicates if change occurred. |
| [get](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.get.md) | <code>&lt;State = unknown&gt;(key: string) =&gt; State &#124; null</code> | |
| [set](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.set.md) | <code>&lt;State&gt;(key: string, state: State, opts?: {</code><br/><code> replace: boolean;</code><br/><code> }) =&gt; Promise&lt;string &#124; undefined&gt;</code> | |

View file

@ -14,7 +14,7 @@ export interface INullableBaseStateContainer<State extends BaseState> extends Ba
## Remarks
State container for stateSync() have to accept "null" for example, set() implementation could handle null and fallback to some default state this is required to handle edge case, when state in storage becomes empty and syncing is in progress. state container will be notified about about storage becoming empty with null passed in
State container for `stateSync()` have to accept `null` for example, `set()` implementation could handle null and fallback to some default state this is required to handle edge case, when state in storage becomes empty and syncing is in progress. State container will be notified about about storage becoming empty with null passed in.
## Properties

View file

@ -4,7 +4,7 @@
## IStateStorage.cancel property
Optional method to cancel any pending activity syncState() will call it, if it is provided by IStateStorage
Optional method to cancel any pending activity [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) will call it during destroy, if it is provided by IStateStorage
<b>Signature:</b>

View file

@ -18,7 +18,7 @@ export interface IStateStorage
| Property | Type | Description |
| --- | --- | --- |
| [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.cancel.md) | <code>() =&gt; void</code> | Optional method to cancel any pending activity syncState() will call it, if it is provided by IStateStorage |
| [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.cancel.md) | <code>() =&gt; void</code> | Optional method to cancel any pending activity [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) will call it during destroy, if it is provided by IStateStorage |
| [change$](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.change_.md) | <code>&lt;State = unknown&gt;(key: string) =&gt; Observable&lt;State &#124; null&gt;</code> | Should notify when the stored state has changed |
| [get](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.get.md) | <code>&lt;State = unknown&gt;(key: string) =&gt; State &#124; null</code> | Should retrieve state from the storage and deserialize it |
| [set](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.set.md) | <code>&lt;State&gt;(key: string, state: State) =&gt; any</code> | Take in a state object, should serialise and persist |

View file

@ -4,28 +4,28 @@
## kibana-plugin-plugins-kibana\_utils-public-state\_sync package
State syncing utilities are a set of helpers for syncing your application state with URL or browser storage.
State syncing utilities are a set of helpers for syncing your application state with browser URL or browser storage.
They are designed to work together with state containers (<!-- -->). But state containers are not required.
They are designed to work together with [state containers](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_containers)<!-- -->. But state containers are not required.
State syncing utilities include:
- [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) util which: - Subscribes to state changes and pushes them to state storage. - Optionally subscribes to state storage changes and pushes them to state. - Two types of storage compatible with `syncState`<!-- -->: - [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) - Serializes state and persists it to URL's query param in rison or hashed format. Listens for state updates in the URL and pushes them back to state. - [ISessionStorageStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.isessionstoragestatestorage.md) - Serializes state and persists it to browser storage.
\*[syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) util which: \* Subscribes to state changes and pushes them to state storage. \* Optionally subscribes to state storage changes and pushes them to state. \* Two types of storages compatible with `syncState`<!-- -->: \* [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) - Serializes state and persists it to URL's query param in rison or hashed format. Listens for state updates in the URL and pushes them back to state. \* [ISessionStorageStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.isessionstoragestatestorage.md) - Serializes state and persists it to browser storage.
Refer [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples
Refer [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples.
## Functions
| Function | Description |
| --- | --- |
| [syncState({ storageKey, stateStorage, stateContainer, })](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) | Utility for syncing application state wrapped in state container with some kind of storage (e.g. URL) Refer [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples |
| [syncState({ storageKey, stateStorage, stateContainer, })](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) | Utility for syncing application state wrapped in state container with some kind of storage (e.g. URL)<!-- -->Go [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples. |
| [syncStates(stateSyncConfigs)](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstates.md) | |
## Interfaces
| Interface | Description |
| --- | --- |
| [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) | KbnUrlStateStorage is a state storage for [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) utility which: 1. Keeps state in sync with the URL. 2. Serializes data and stores it in the URL in one of the supported formats: \* Rison encoded. \* Hashed URL: In URL we store only the hash from the serialized state, but the state itself is stored in sessionStorage. See kibana's advanced option for more context state:storeInSessionStorage 3. Takes care of listening to the URL updates and notifies state about the updates. 4. Takes care of batching URL updates to prevent redundant browser history records. [GUIDE](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md) |
| [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) | KbnUrlStateStorage is a state storage for [syncState()](./kibana-plugin-plugins-kibana_utils-public-state_sync.syncstate.md) utility which:<!-- -->1. Keeps state in sync with the URL. 2. Serializes data and stores it in the URL in one of the supported formats: \* Rison encoded. \* Hashed URL: In URL we store only the hash from the serialized state, but the state itself is stored in sessionStorage. See Kibana's <code>state:storeInSessionStorage</code> advanced option for more context. 3. Takes care of listening to the URL updates and notifies state about the updates. 4. Takes care of batching URL updates to prevent redundant browser history records.[Refer to this guide for more info](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md) |
| [INullableBaseStateContainer](./kibana-plugin-plugins-kibana_utils-public-state_sync.inullablebasestatecontainer.md) | Extension of with one constraint: set state should handle <code>null</code> as incoming state |
| [ISessionStorageStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.isessionstoragestatestorage.md) | [IStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.md) for storing state in browser [guide](https://github.com/elastic/kibana/blob/master/src/plugins/kibana_utils/docs/state_sync/storages/session_storage.md) |
| [IStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.istatestorage.md) | Any StateStorage have to implement IStateStorage interface StateStorage is responsible for: \* state serialisation / deserialization \* persisting to and retrieving from storage<!-- -->For an example take a look at already implemented [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) and [ISessionStorageStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.isessionstoragestatestorage.md) state storages |

View file

@ -4,7 +4,9 @@
## syncState() function
Utility for syncing application state wrapped in state container with some kind of storage (e.g. URL) Refer [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples
Utility for syncing application state wrapped in state container with some kind of storage (e.g. URL)
Go [here](https://github.com/elastic/kibana/tree/master/src/plugins/kibana_utils/docs/state_sync) for a complete guide and examples.
<b>Signature:</b>
@ -24,13 +26,9 @@ export declare function syncState<State extends BaseState, StateStorage extends
- [ISyncStateRef](./kibana-plugin-plugins-kibana_utils-public-state_sync.isyncstateref.md)
## Remarks
1. It is responsibility of consumer to make sure that initial app state and storage are in sync before starting syncing No initial sync happens when syncState() is called
## Example 1
1. the simplest use case
the simplest use case
```ts
const stateStorage = createKbnUrlStateStorage();
@ -44,7 +42,7 @@ syncState({
## Example 2
2. conditionally configuring sync strategy
conditionally configuring sync strategy
```ts
const stateStorage = createKbnUrlStateStorage({useHash: config.get('state:stateContainerInSessionStorage')})
@ -58,7 +56,7 @@ syncState({
## Example 3
3. implementing custom sync strategy
implementing custom sync strategy
```ts
const localStorageStateStorage = {
@ -75,7 +73,7 @@ syncState({
## Example 4
4. Transform state before serialising Useful for: \* Migration / backward compatibility \* Syncing part of state \* Providing default values
transforming state before serialising Useful for: \* Migration / backward compatibility \* Syncing part of state \* Providing default values
```ts
const stateToStorage = (s) => ({ tab: s.tab });

View file

@ -0,0 +1,58 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { AuditTrailSetup, AuditTrailStart, Auditor } from './types';
import { AuditTrailService } from './audit_trail_service';
const createSetupContractMock = () => {
const mocked: jest.Mocked<AuditTrailSetup> = {
register: jest.fn(),
};
return mocked;
};
const createAuditorMock = () => {
const mocked: jest.Mocked<Auditor> = {
add: jest.fn(),
withAuditScope: jest.fn(),
};
return mocked;
};
const createStartContractMock = () => {
const mocked: jest.Mocked<AuditTrailStart> = {
asScoped: jest.fn(),
};
mocked.asScoped.mockReturnValue(createAuditorMock());
return mocked;
};
const createServiceMock = (): jest.Mocked<PublicMethodsOf<AuditTrailService>> => ({
setup: jest.fn().mockResolvedValue(createSetupContractMock()),
start: jest.fn().mockResolvedValue(createStartContractMock()),
stop: jest.fn(),
});
export const auditTrailServiceMock = {
create: createServiceMock,
createSetupContract: createSetupContractMock,
createStartContract: createStartContractMock,
createAuditorFactory: createStartContractMock,
createAuditor: createAuditorMock,
};

View file

@ -0,0 +1,99 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { AuditTrailService } from './audit_trail_service';
import { AuditorFactory } from './types';
import { mockCoreContext } from '../core_context.mock';
import { httpServerMock } from '../http/http_server.mocks';
describe('AuditTrailService', () => {
const coreContext = mockCoreContext.create();
describe('#setup', () => {
describe('register', () => {
it('throws if registered the same auditor factory twice', () => {
const auditTrail = new AuditTrailService(coreContext);
const { register } = auditTrail.setup();
const auditorFactory: AuditorFactory = {
asScoped() {
return { add: () => undefined, withAuditScope: (() => {}) as any };
},
};
register(auditorFactory);
expect(() => register(auditorFactory)).toThrowErrorMatchingInlineSnapshot(
`"An auditor factory has been already registered"`
);
});
});
});
describe('#start', () => {
describe('asScoped', () => {
it('initialize every auditor with a request', () => {
const scopedMock = jest.fn(() => ({ add: jest.fn(), withAuditScope: jest.fn() }));
const auditorFactory = { asScoped: scopedMock };
const auditTrail = new AuditTrailService(coreContext);
const { register } = auditTrail.setup();
register(auditorFactory);
const { asScoped } = auditTrail.start();
const kibanaRequest = httpServerMock.createKibanaRequest();
asScoped(kibanaRequest);
expect(scopedMock).toHaveBeenCalledWith(kibanaRequest);
});
it('passes auditable event to an auditor', () => {
const addEventMock = jest.fn();
const auditorFactory = {
asScoped() {
return { add: addEventMock, withAuditScope: jest.fn() };
},
};
const auditTrail = new AuditTrailService(coreContext);
const { register } = auditTrail.setup();
register(auditorFactory);
const { asScoped } = auditTrail.start();
const kibanaRequest = httpServerMock.createKibanaRequest();
const auditor = asScoped(kibanaRequest);
const message = {
type: 'foo',
message: 'bar',
};
auditor.add(message);
expect(addEventMock).toHaveBeenLastCalledWith(message);
});
describe('return the same auditor instance for the same KibanaRequest', () => {
const auditTrail = new AuditTrailService(coreContext);
auditTrail.setup();
const { asScoped } = auditTrail.start();
const rawRequest1 = httpServerMock.createKibanaRequest();
const rawRequest2 = httpServerMock.createKibanaRequest();
expect(asScoped(rawRequest1)).toBe(asScoped(rawRequest1));
expect(asScoped(rawRequest1)).not.toBe(asScoped(rawRequest2));
});
});
});
});

View file

@ -0,0 +1,69 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { KibanaRequest, LegacyRequest } from '../http';
import { ensureRawRequest } from '../http/router';
import { Auditor, AuditorFactory, AuditTrailSetup, AuditTrailStart } from './types';
const defaultAuditorFactory: AuditorFactory = {
asScoped() {
return {
add() {},
withAuditScope() {},
};
},
};
export class AuditTrailService implements CoreService<AuditTrailSetup, AuditTrailStart> {
private readonly log: Logger;
private auditor: AuditorFactory = defaultAuditorFactory;
private readonly auditors = new WeakMap<LegacyRequest, Auditor>();
constructor(core: CoreContext) {
this.log = core.logger.get('audit_trail');
}
setup() {
return {
register: (auditor: AuditorFactory) => {
if (this.auditor !== defaultAuditorFactory) {
throw new Error('An auditor factory has been already registered');
}
this.auditor = auditor;
this.log.debug('An auditor factory has been registered');
},
};
}
start() {
return {
asScoped: (request: KibanaRequest) => {
const key = ensureRawRequest(request);
if (!this.auditors.has(key)) {
this.auditors.set(key, this.auditor!.asScoped(request));
}
return this.auditors.get(key)!;
},
};
}
stop() {}
}

View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { AuditTrailService } from './audit_trail_service';
export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup, AuditTrailStart } from './types';

View file

@ -0,0 +1,76 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { KibanaRequest } from '../http';
/**
* Event to audit.
* @public
*
* @remarks
* Not a complete interface.
*/
export interface AuditableEvent {
message: string;
type: string;
}
/**
* Provides methods to log user actions and access events.
* @public
*/
export interface Auditor {
/**
* Add a record to audit log.
* Service attaches to a log record:
* - metadata about an end-user initiating an operation
* - scope name, if presents
*
* @example
* How to add a record in audit log:
* ```typescript
* router.get({ path: '/my_endpoint', validate: false }, async (context, request, response) => {
* context.core.auditor.withAuditScope('my_plugin_operation');
* const value = await context.core.elasticsearch.legacy.client.callAsCurrentUser('...');
* context.core.add({ type: 'operation.type', message: 'perform an operation in ... endpoint' });
* ```
*/
add(event: AuditableEvent): void;
/**
* Add a high-level scope name for logged events.
* It helps to identify the root cause of low-level events.
*/
withAuditScope(name: string): void;
}
/**
* Creates {@link Auditor} instance bound to the current user credentials.
* @public
*/
export interface AuditorFactory {
asScoped(request: KibanaRequest): Auditor;
}
export interface AuditTrailSetup {
/**
* Register a custom {@link AuditorFactory} implementation.
*/
register(auditor: AuditorFactory): void;
}
export type AuditTrailStart = AuditorFactory;

View file

@ -101,6 +101,7 @@ describe('#setup', () => {
expect(MockClusterClient).toHaveBeenCalledWith(
expect.objectContaining(customConfig),
expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }),
expect.any(Function),
expect.any(Function)
);
});

View file

@ -42,6 +42,7 @@ import {
} from './legacy';
import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config';
import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/';
import { AuditTrailStart, AuditorFactory } from '../audit_trail';
import {
InternalElasticsearchServiceSetup,
ElasticsearchServiceStart,
@ -60,12 +61,17 @@ interface SetupDeps {
http: InternalHttpServiceSetup;
}
interface StartDeps {
auditTrail: AuditTrailStart;
}
/** @internal */
export class ElasticsearchService
implements CoreService<InternalElasticsearchServiceSetup, ElasticsearchServiceStart> {
private readonly log: Logger;
private readonly config$: Observable<ElasticsearchConfig>;
private subscription?: Subscription;
private auditorFactory?: AuditorFactory;
private stop$ = new Subject();
private kibanaVersion: string;
private createClient?: (
@ -132,14 +138,24 @@ export class ElasticsearchService
return await _client.callAsInternalUser(endpoint, clientParams, options);
},
asScoped(request: ScopeableRequest) {
const _clientPromise = client$.pipe(take(1)).toPromise();
return {
callAsInternalUser: client.callAsInternalUser,
async callAsInternalUser(
endpoint: string,
clientParams: Record<string, any> = {},
options?: LegacyCallAPIOptions
) {
const _client = await _clientPromise;
return await _client
.asScoped(request)
.callAsInternalUser(endpoint, clientParams, options);
},
async callAsCurrentUser(
endpoint: string,
clientParams: Record<string, any> = {},
options?: LegacyCallAPIOptions
) {
const _client = await client$.pipe(take(1)).toPromise();
const _client = await _clientPromise;
return await _client
.asScoped(request)
.callAsCurrentUser(endpoint, clientParams, options);
@ -176,7 +192,8 @@ export class ElasticsearchService
status$: calculateStatus$(esNodesCompatibility$),
};
}
public async start() {
public async start({ auditTrail }: StartDeps) {
this.auditorFactory = auditTrail;
if (typeof this.client === 'undefined' || typeof this.createClient === 'undefined') {
throw new Error('ElasticsearchService needs to be setup before calling start');
} else {
@ -205,7 +222,15 @@ export class ElasticsearchService
return new LegacyClusterClient(
config,
this.coreContext.logger.get('elasticsearch', type),
this.getAuditorFactory,
getAuthHeaders
);
}
private getAuditorFactory = () => {
if (!this.auditorFactory) {
throw new Error('auditTrail has not been initialized');
}
return this.auditorFactory;
};
}

View file

@ -27,6 +27,7 @@ import {
import { errors } from 'elasticsearch';
import { get } from 'lodash';
import { auditTrailServiceMock } from '../../audit_trail/audit_trail_service.mock';
import { Logger } from '../../logging';
import { loggingSystemMock } from '../../logging/logging_system.mock';
import { httpServerMock } from '../../http/http_server.mocks';
@ -42,7 +43,11 @@ test('#constructor creates client with parsed config', () => {
const mockEsConfig = { apiVersion: 'es-version' } as any;
const mockLogger = logger.get();
const clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
const clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
expect(clusterClient).toBeDefined();
expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1);
@ -68,7 +73,11 @@ describe('#callAsInternalUser', () => {
};
MockClient.mockImplementation(() => mockEsClientInstance);
clusterClient = new LegacyClusterClient({ apiVersion: 'es-version' } as any, logger.get());
clusterClient = new LegacyClusterClient(
{ apiVersion: 'es-version' } as any,
logger.get(),
auditTrailServiceMock.createAuditorFactory
);
});
test('fails if cluster client is closed', async () => {
@ -237,7 +246,11 @@ describe('#asScoped', () => {
requestHeadersWhitelist: ['one', 'two'],
} as any;
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
jest.clearAllMocks();
});
@ -272,7 +285,11 @@ describe('#asScoped', () => {
test('properly configures `ignoreCertAndKey` for various configurations', () => {
// Config without SSL.
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
@ -285,7 +302,11 @@ describe('#asScoped', () => {
// Config ssl.alwaysPresentCertificate === false
mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: false } } as any;
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
@ -298,7 +319,11 @@ describe('#asScoped', () => {
// Config ssl.alwaysPresentCertificate === true
mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: true } } as any;
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
@ -319,7 +344,8 @@ describe('#asScoped', () => {
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: '1', two: '2' }
{ one: '1', two: '2' },
expect.any(Object)
);
});
@ -341,71 +367,142 @@ describe('#asScoped', () => {
});
test('does not fail when scope to not defined request', async () => {
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient.asScoped();
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{}
{},
undefined
);
});
test('does not fail when scope to a request without headers', async () => {
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient.asScoped({} as any);
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{}
{},
undefined
);
});
test('calls getAuthHeaders and filters results for a real request', async () => {
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({
one: '1',
three: '3',
}));
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory,
() => ({
one: '1',
three: '3',
})
);
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { two: '2' } }));
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: '1', two: '2' }
{ one: '1', two: '2' },
expect.any(Object)
);
});
test('getAuthHeaders results rewrite extends a request headers', async () => {
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({ one: 'foo' }));
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory,
() => ({ one: 'foo' })
);
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1', two: '2' } }));
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: 'foo', two: '2' }
{ one: 'foo', two: '2' },
expect.any(Object)
);
});
test("doesn't call getAuthHeaders for a fake request", async () => {
const getAuthHeaders = jest.fn();
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, getAuthHeaders);
clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } });
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory,
() => ({})
);
clusterClient.asScoped({ headers: { one: 'foo' } });
expect(getAuthHeaders).not.toHaveBeenCalled();
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: 'foo' },
undefined
);
});
test('filters a fake request headers', async () => {
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } });
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: '1', two: '2' }
{ one: '1', two: '2' },
undefined
);
});
describe('Auditor', () => {
it('creates Auditor for KibanaRequest', async () => {
const auditor = auditTrailServiceMock.createAuditor();
const auditorFactory = auditTrailServiceMock.createAuditorFactory();
auditorFactory.asScoped.mockReturnValue(auditor);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => auditorFactory);
clusterClient.asScoped(httpServerMock.createKibanaRequest());
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{},
auditor
);
});
it("doesn't create Auditor for a fake request", async () => {
const getAuthHeaders = jest.fn();
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, getAuthHeaders);
clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } });
expect(getAuthHeaders).not.toHaveBeenCalled();
});
it("doesn't create Auditor when no request passed", async () => {
const getAuthHeaders = jest.fn();
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, getAuthHeaders);
clusterClient.asScoped();
expect(getAuthHeaders).not.toHaveBeenCalled();
});
});
});
describe('#close', () => {
@ -423,7 +520,8 @@ describe('#close', () => {
clusterClient = new LegacyClusterClient(
{ apiVersion: 'es-version', requestHeadersWhitelist: [] } as any,
logger.get()
logger.get(),
auditTrailServiceMock.createAuditorFactory
);
});

View file

@ -20,7 +20,8 @@ import { Client } from 'elasticsearch';
import { get } from 'lodash';
import { LegacyElasticsearchErrorHelpers } from './errors';
import { GetAuthHeaders, isRealRequest } from '../../http';
import { GetAuthHeaders, isRealRequest, KibanaRequest } from '../../http';
import { AuditorFactory } from '../../audit_trail';
import { filterHeaders, ensureRawRequest } from '../../http/router';
import { Logger } from '../../logging';
import { ScopeableRequest } from '../types';
@ -129,6 +130,7 @@ export class LegacyClusterClient implements ILegacyClusterClient {
constructor(
private readonly config: LegacyElasticsearchClientConfig,
private readonly log: Logger,
private readonly getAuditorFactory: () => AuditorFactory,
private readonly getAuthHeaders: GetAuthHeaders = noop
) {
this.client = new Client(parseElasticsearchClientConfig(config, log));
@ -203,10 +205,21 @@ export class LegacyClusterClient implements ILegacyClusterClient {
return new LegacyScopedClusterClient(
this.callAsInternalUser,
this.callAsCurrentUser,
filterHeaders(this.getHeaders(request), this.config.requestHeadersWhitelist)
filterHeaders(this.getHeaders(request), this.config.requestHeadersWhitelist),
this.getScopedAuditor(request)
);
}
private getScopedAuditor(request?: ScopeableRequest) {
// TODO: support alternative credential owners from outside of Request context in #39430
if (request && isRealRequest(request)) {
const kibanaRequest =
request instanceof KibanaRequest ? request : KibanaRequest.from(request);
const auditorFactory = this.getAuditorFactory();
return auditorFactory.asScoped(kibanaRequest);
}
}
/**
* Calls specified endpoint with provided clientParams on behalf of the
* user initiated request to the Kibana server (via HTTP request headers).

View file

@ -18,6 +18,7 @@
*/
import { LegacyScopedClusterClient } from './scoped_cluster_client';
import { auditTrailServiceMock } from '../../audit_trail/audit_trail_service.mock';
let internalAPICaller: jest.Mock;
let scopedAPICaller: jest.Mock;
@ -83,6 +84,28 @@ describe('#callAsInternalUser', () => {
expect(scopedAPICaller).not.toHaveBeenCalled();
});
describe('Auditor', () => {
it('does not fail when no auditor provided', () => {
const clusterClientWithoutAuditor = new LegacyScopedClusterClient(jest.fn(), jest.fn());
expect(() => clusterClientWithoutAuditor.callAsInternalUser('endpoint')).not.toThrow();
});
it('creates an audit record if auditor provided', () => {
const auditor = auditTrailServiceMock.createAuditor();
const clusterClientWithoutAuditor = new LegacyScopedClusterClient(
jest.fn(),
jest.fn(),
{},
auditor
);
clusterClientWithoutAuditor.callAsInternalUser('endpoint');
expect(auditor.add).toHaveBeenCalledTimes(1);
expect(auditor.add).toHaveBeenLastCalledWith({
message: 'endpoint',
type: 'elasticsearch.call.internalUser',
});
});
});
});
describe('#callAsCurrentUser', () => {
@ -206,4 +229,26 @@ describe('#callAsCurrentUser', () => {
expect(internalAPICaller).not.toHaveBeenCalled();
});
describe('Auditor', () => {
it('does not fail when no auditor provided', () => {
const clusterClientWithoutAuditor = new LegacyScopedClusterClient(jest.fn(), jest.fn());
expect(() => clusterClientWithoutAuditor.callAsCurrentUser('endpoint')).not.toThrow();
});
it('creates an audit record if auditor provided', () => {
const auditor = auditTrailServiceMock.createAuditor();
const clusterClientWithoutAuditor = new LegacyScopedClusterClient(
jest.fn(),
jest.fn(),
{},
auditor
);
clusterClientWithoutAuditor.callAsCurrentUser('endpoint');
expect(auditor.add).toHaveBeenCalledTimes(1);
expect(auditor.add).toHaveBeenLastCalledWith({
message: 'endpoint',
type: 'elasticsearch.call.currentUser',
});
});
});
});

View file

@ -18,6 +18,7 @@
*/
import { intersection, isObject } from 'lodash';
import { Auditor } from '../../audit_trail';
import { Headers } from '../../http/router';
import { LegacyAPICaller, LegacyCallAPIOptions } from './api_types';
@ -44,7 +45,8 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient {
constructor(
private readonly internalAPICaller: LegacyAPICaller,
private readonly scopedAPICaller: LegacyAPICaller,
private readonly headers?: Headers
private readonly headers?: Headers,
private readonly auditor?: Auditor
) {
this.callAsCurrentUser = this.callAsCurrentUser.bind(this);
this.callAsInternalUser = this.callAsInternalUser.bind(this);
@ -64,6 +66,13 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient {
clientParams: Record<string, any> = {},
options?: LegacyCallAPIOptions
) {
if (this.auditor) {
this.auditor.add({
message: endpoint,
type: 'elasticsearch.call.internalUser',
});
}
return this.internalAPICaller(endpoint, clientParams, options);
}
@ -95,6 +104,14 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient {
clientParams.headers = Object.assign({}, clientParams.headers, this.headers);
}
if (this.auditor) {
this.auditor.add({
message: endpoint,
type: 'elasticsearch.call.currentUser',
});
}
return this.scopedAPICaller(endpoint, clientParams, options);
}
}

View file

@ -62,6 +62,7 @@ import { CapabilitiesSetup, CapabilitiesStart } from './capabilities';
import { UuidServiceSetup } from './uuid';
import { MetricsServiceStart } from './metrics';
import { StatusServiceSetup } from './status';
import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail';
import {
LoggingServiceSetup,
appendersSchema,
@ -69,6 +70,7 @@ import {
loggerSchema,
} from './logging';
export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup } from './audit_trail';
export { bootstrap } from './bootstrap';
export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities';
export {
@ -376,6 +378,7 @@ export interface RequestHandlerContext {
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
};
}
@ -412,6 +415,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
uuid: UuidServiceSetup;
/** {@link StartServicesAccessor} */
getStartServices: StartServicesAccessor<TPluginsStart, TStart>;
/** {@link AuditTrailSetup} */
auditTrail: AuditTrailSetup;
}
/**
@ -445,6 +450,8 @@ export interface CoreStart {
savedObjects: SavedObjectsServiceStart;
/** {@link UiSettingsServiceStart} */
uiSettings: UiSettingsServiceStart;
/** {@link AuditTrailSetup} */
auditTrail: AuditTrailStart;
}
export {
@ -456,6 +463,7 @@ export {
PluginsServiceStart,
PluginOpaqueId,
UuidServiceSetup,
AuditTrailStart,
};
/**

View file

@ -34,6 +34,7 @@ import { InternalMetricsServiceStart } from './metrics';
import { InternalRenderingServiceSetup } from './rendering';
import { InternalHttpResourcesSetup } from './http_resources';
import { InternalStatusServiceSetup } from './status';
import { AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { InternalLoggingServiceSetup } from './logging';
/** @internal */
@ -48,6 +49,7 @@ export interface InternalCoreSetup {
uuid: UuidServiceSetup;
rendering: InternalRenderingServiceSetup;
httpResources: InternalHttpResourcesSetup;
auditTrail: AuditTrailSetup;
logging: InternalLoggingServiceSetup;
}
@ -61,6 +63,7 @@ export interface InternalCoreStart {
metrics: InternalMetricsServiceStart;
savedObjects: InternalSavedObjectsServiceStart;
uiSettings: InternalUiSettingsServiceStart;
auditTrail: AuditTrailStart;
}
/**

View file

@ -51,6 +51,7 @@ import { LegacyVars, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './ty
import { LegacyService } from './legacy_service';
import { coreMock } from '../mocks';
import { statusServiceMock } from '../status/status_service.mock';
import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock';
import { loggingServiceMock } from '../logging/logging_service.mock';
const MockKbnServer: jest.Mock<KbnServer> = KbnServer as any;
@ -98,6 +99,7 @@ beforeEach(() => {
rendering: renderingServiceMock,
uuid: uuidSetup,
status: statusServiceMock.createInternalSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
},
plugins: { 'plugin-id': 'plugin-value' },
@ -119,7 +121,6 @@ beforeEach(() => {
startDeps = {
core: {
...coreMock.createInternalStart(),
savedObjects: savedObjectsServiceMock.createInternalStartContract(),
plugins: { contracts: new Map() },
},
plugins: {},

View file

@ -280,6 +280,7 @@ export class LegacyService implements CoreService {
getOpsMetrics$: startDeps.core.metrics.getOpsMetrics$,
},
uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient },
auditTrail: startDeps.core.auditTrail,
};
const router = setupDeps.core.http.createRouter('', this.legacyId);
@ -330,6 +331,7 @@ export class LegacyService implements CoreService {
uuid: {
getInstanceUuid: setupDeps.core.uuid.getInstanceUuid,
},
auditTrail: setupDeps.core.auditTrail,
getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]),
};

View file

@ -36,6 +36,7 @@ import { capabilitiesServiceMock } from './capabilities/capabilities_service.moc
import { metricsServiceMock } from './metrics/metrics_service.mock';
import { uuidServiceMock } from './uuid/uuid_service.mock';
import { statusServiceMock } from './status/status_service.mock';
import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock';
export { httpServerMock } from './http/http_server.mocks';
export { httpResourcesMock } from './http_resources/http_resources_service.mock';
@ -131,6 +132,7 @@ function createCoreSetupMock({
status: statusServiceMock.createSetupContract(),
uiSettings: uiSettingsMock,
uuid: uuidServiceMock.createSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createSetupContract(),
getStartServices: jest
.fn<Promise<[ReturnType<typeof createCoreStartMock>, object, any]>, []>()
@ -142,6 +144,7 @@ function createCoreSetupMock({
function createCoreStartMock() {
const mock: MockedKeys<CoreStart> = {
auditTrail: auditTrailServiceMock.createStartContract(),
capabilities: capabilitiesServiceMock.createStartContract(),
elasticsearch: elasticsearchServiceMock.createStart(),
http: httpServiceMock.createStartContract(),
@ -165,6 +168,7 @@ function createInternalCoreSetupMock() {
httpResources: httpResourcesMock.createSetupContract(),
rendering: renderingMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
};
return setupDeps;
@ -178,6 +182,7 @@ function createInternalCoreStartMock() {
metrics: metricsServiceMock.createStartContract(),
savedObjects: savedObjectsServiceMock.createInternalStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
auditTrail: auditTrailServiceMock.createStartContract(),
};
return startDeps;
}
@ -196,6 +201,7 @@ function createCoreRequestHandlerContextMock() {
uiSettings: {
client: uiSettingsServiceMock.createClient(),
},
auditor: auditTrailServiceMock.createAuditor(),
};
}

View file

@ -185,6 +185,7 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
getInstanceUuid: deps.uuid.getInstanceUuid,
},
getStartServices: () => plugin.startDependencies,
auditTrail: deps.auditTrail,
};
}
@ -228,5 +229,6 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>(
uiSettings: {
asScopedToClient: deps.uiSettings.asScopedToClient,
},
auditTrail: deps.auditTrail,
};
}

View file

@ -170,6 +170,38 @@ export interface AssistantAPIClientParams extends GenericParams {
path: '/_migration/assistance';
}
// @public
export interface AuditableEvent {
// (undocumented)
message: string;
// (undocumented)
type: string;
}
// @public
export interface Auditor {
add(event: AuditableEvent): void;
withAuditScope(name: string): void;
}
// @public
export interface AuditorFactory {
// (undocumented)
asScoped(request: KibanaRequest): Auditor;
}
// Warning: (ae-missing-release-tag) "AuditTrailSetup" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface AuditTrailSetup {
register(auditor: AuditorFactory): void;
}
// Warning: (ae-missing-release-tag) "AuditTrailStart" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AuditTrailStart = AuditorFactory;
// @public (undocumented)
export interface Authenticated extends AuthResultParams {
// (undocumented)
@ -439,6 +471,8 @@ export type CoreId = symbol;
// @public
export interface CoreSetup<TPluginsStart extends object = object, TStart = unknown> {
// (undocumented)
auditTrail: AuditTrailSetup;
// (undocumented)
capabilities: CapabilitiesSetup;
// (undocumented)
@ -465,6 +499,8 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
// @public
export interface CoreStart {
// (undocumented)
auditTrail: AuditTrailStart;
// (undocumented)
capabilities: CapabilitiesStart;
// (undocumented)
@ -1221,7 +1257,7 @@ export interface LegacyCallAPIOptions {
//
// @public (undocumented)
export class LegacyClusterClient implements ILegacyClusterClient {
constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders);
constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuditorFactory: () => AuditorFactory, getAuthHeaders?: GetAuthHeaders);
asScoped(request?: ScopeableRequest): ILegacyScopedClusterClient;
callAsInternalUser: LegacyAPICaller;
close(): void;
@ -1286,7 +1322,7 @@ export interface LegacyRequest extends Request {
//
// @public (undocumented)
export class LegacyScopedClusterClient implements ILegacyScopedClusterClient {
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined);
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined, auditor?: Auditor | undefined);
callAsCurrentUser(endpoint: string, clientParams?: Record<string, any>, options?: LegacyCallAPIOptions): Promise<any>;
callAsInternalUser(endpoint: string, clientParams?: Record<string, any>, options?: LegacyCallAPIOptions): Promise<any>;
}
@ -1689,6 +1725,7 @@ export interface RequestHandlerContext {
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
};
}

View file

@ -97,3 +97,9 @@ export const mockLoggingService = loggingServiceMock.create();
jest.doMock('./logging/logging_service', () => ({
LoggingService: jest.fn(() => mockLoggingService),
}));
import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock';
export const mockAuditTrailService = auditTrailServiceMock.create();
jest.doMock('./audit_trail/audit_trail_service', () => ({
AuditTrailService: jest.fn(() => mockAuditTrailService),
}));

View file

@ -31,6 +31,7 @@ import {
mockMetricsService,
mockStatusService,
mockLoggingService,
mockAuditTrailService,
} from './server.test.mocks';
import { BehaviorSubject } from 'rxjs';
@ -70,6 +71,7 @@ test('sets up services on "setup"', async () => {
expect(mockMetricsService.setup).not.toHaveBeenCalled();
expect(mockStatusService.setup).not.toHaveBeenCalled();
expect(mockLoggingService.setup).not.toHaveBeenCalled();
expect(mockAuditTrailService.setup).not.toHaveBeenCalled();
await server.setup();
@ -83,6 +85,7 @@ test('sets up services on "setup"', async () => {
expect(mockMetricsService.setup).toHaveBeenCalledTimes(1);
expect(mockStatusService.setup).toHaveBeenCalledTimes(1);
expect(mockLoggingService.setup).toHaveBeenCalledTimes(1);
expect(mockAuditTrailService.setup).toHaveBeenCalledTimes(1);
});
test('injects legacy dependency to context#setup()', async () => {
@ -123,6 +126,7 @@ test('runs services on "start"', async () => {
expect(mockSavedObjectsService.start).not.toHaveBeenCalled();
expect(mockUiSettingsService.start).not.toHaveBeenCalled();
expect(mockMetricsService.start).not.toHaveBeenCalled();
expect(mockAuditTrailService.start).not.toHaveBeenCalled();
await server.start();
@ -131,6 +135,7 @@ test('runs services on "start"', async () => {
expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1);
expect(mockUiSettingsService.start).toHaveBeenCalledTimes(1);
expect(mockMetricsService.start).toHaveBeenCalledTimes(1);
expect(mockAuditTrailService.start).toHaveBeenCalledTimes(1);
});
test('does not fail on "setup" if there are unused paths detected', async () => {
@ -155,6 +160,7 @@ test('stops services on "stop"', async () => {
expect(mockMetricsService.stop).not.toHaveBeenCalled();
expect(mockStatusService.stop).not.toHaveBeenCalled();
expect(mockLoggingService.stop).not.toHaveBeenCalled();
expect(mockAuditTrailService.stop).not.toHaveBeenCalled();
await server.stop();
@ -167,6 +173,7 @@ test('stops services on "stop"', async () => {
expect(mockMetricsService.stop).toHaveBeenCalledTimes(1);
expect(mockStatusService.stop).toHaveBeenCalledTimes(1);
expect(mockLoggingService.stop).toHaveBeenCalledTimes(1);
expect(mockAuditTrailService.stop).toHaveBeenCalledTimes(1);
});
test(`doesn't setup core services if config validation fails`, async () => {

View file

@ -27,6 +27,7 @@ import {
coreDeprecationProvider,
} from './config';
import { CoreApp } from './core_app';
import { AuditTrailService } from './audit_trail';
import { ElasticsearchService } from './elasticsearch';
import { HttpService } from './http';
import { HttpResourcesService } from './http_resources';
@ -76,6 +77,7 @@ export class Server {
private readonly status: StatusService;
private readonly logging: LoggingService;
private readonly coreApp: CoreApp;
private readonly auditTrail: AuditTrailService;
#pluginsInitialized?: boolean;
private coreStart?: InternalCoreStart;
@ -105,6 +107,7 @@ export class Server {
this.status = new StatusService(core);
this.coreApp = new CoreApp(core);
this.httpResources = new HttpResourcesService(core);
this.auditTrail = new AuditTrailService(core);
this.logging = new LoggingService(core);
}
@ -127,6 +130,7 @@ export class Server {
pluginDependencies: new Map([...pluginTree, [this.legacy.legacyId, [...pluginTree.keys()]]]),
});
const auditTrailSetup = this.auditTrail.setup();
const uuidSetup = await this.uuid.setup();
const httpSetup = await this.http.setup({
@ -183,6 +187,7 @@ export class Server {
uuid: uuidSetup,
rendering: renderingSetup,
httpResources: httpResourcesSetup,
auditTrail: auditTrailSetup,
logging: loggingSetup,
};
@ -203,7 +208,11 @@ export class Server {
public async start() {
this.log.debug('starting server');
const elasticsearchStart = await this.elasticsearch.start();
const auditTrailStart = this.auditTrail.start();
const elasticsearchStart = await this.elasticsearch.start({
auditTrail: auditTrailStart,
});
const savedObjectsStart = await this.savedObjects.start({
elasticsearch: elasticsearchStart,
pluginsInitialized: this.#pluginsInitialized,
@ -220,6 +229,7 @@ export class Server {
metrics: metricsStart,
savedObjects: savedObjectsStart,
uiSettings: uiSettingsStart,
auditTrail: auditTrailStart,
};
const pluginsStart = await this.plugins.start(this.coreStart);
@ -254,6 +264,7 @@ export class Server {
await this.metrics.stop();
await this.status.stop();
await this.logging.stop();
await this.auditTrail.stop();
}
private registerCoreContext(coreSetup: InternalCoreSetup) {
@ -277,6 +288,7 @@ export class Server {
uiSettings: {
client: coreStart.uiSettings.asScopedToClient(savedObjectsClient),
},
auditor: coreStart.auditTrail.asScoped(req),
};
}
);

View file

@ -0,0 +1,10 @@
{
"id": "auditTrail",
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "audit_trail"],
"server": true,
"ui": false,
"requiredPlugins": ["licensing", "security"],
"optionalPlugins": ["spaces"]
}

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Subject } from 'rxjs';
import { AuditTrailClient } from './audit_trail_client';
import { AuditEvent } from '../types';
import { httpServerMock } from '../../../../../src/core/server/mocks';
import { securityMock } from '../../../security/server/mocks';
import { spacesMock } from '../../../spaces/server/mocks';
describe('AuditTrailClient', () => {
let client: AuditTrailClient;
let event$: Subject<AuditEvent>;
const deps = {
getCurrentUser: securityMock.createSetup().authc.getCurrentUser,
getSpaceId: spacesMock.createSetup().spacesService.getSpaceId,
};
beforeEach(() => {
event$ = new Subject();
client = new AuditTrailClient(httpServerMock.createKibanaRequest(), event$, deps);
});
afterEach(() => {
event$.complete();
});
describe('#withAuditScope', () => {
it('registers upper level scope', (done) => {
client.withAuditScope('scope_name');
event$.subscribe((event) => {
expect(event.scope).toBe('scope_name');
done();
});
client.add({ message: 'message', type: 'type' });
});
it('throws an exception if tries to re-write a scope', () => {
client.withAuditScope('scope_name');
expect(() => client.withAuditScope('another_scope_name')).toThrowErrorMatchingInlineSnapshot(
`"Audit scope is already set to: scope_name"`
);
});
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Subject } from 'rxjs';
import { KibanaRequest, Auditor, AuditableEvent } from 'src/core/server';
import { AuditEvent } from '../types';
import { SecurityPluginSetup } from '../../../security/server';
import { SpacesPluginSetup } from '../../../spaces/server';
interface Deps {
getCurrentUser: SecurityPluginSetup['authc']['getCurrentUser'];
getSpaceId?: SpacesPluginSetup['spacesService']['getSpaceId'];
}
export class AuditTrailClient implements Auditor {
private scope?: string;
constructor(
private readonly request: KibanaRequest,
private readonly event$: Subject<AuditEvent>,
private readonly deps: Deps
) {}
public withAuditScope(name: string) {
if (this.scope !== undefined) {
throw new Error(`Audit scope is already set to: ${this.scope}`);
}
this.scope = name;
}
public add(event: AuditableEvent) {
const user = this.deps.getCurrentUser(this.request);
// doesn't use getSpace since it's async operation calling ES
const spaceId = this.deps.getSpaceId ? this.deps.getSpaceId(this.request) : undefined;
this.event$.next({
message: event.message,
type: event.type,
user: user?.username,
space: spaceId,
scope: this.scope,
});
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { config } from './config';
describe('config schema', () => {
it('generates proper defaults', () => {
expect(config.schema.validate({})).toEqual({
enabled: false,
logger: {
enabled: false,
},
});
});
it('accepts an appender', () => {
const appender = config.schema.validate({
appender: {
kind: 'file',
path: '/path/to/file.txt',
layout: {
kind: 'json',
},
},
logger: {
enabled: false,
},
}).appender;
expect(appender).toEqual({
kind: 'file',
path: '/path/to/file.txt',
layout: {
kind: 'json',
},
});
});
it('rejects an appender if not fully configured', () => {
expect(() =>
config.schema.validate({
// no layout configured
appender: {
kind: 'file',
path: '/path/to/file.txt',
},
logger: {
enabled: false,
},
})
).toThrow();
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor, config as coreConfig } from '../../../../src/core/server';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
appender: schema.maybe(coreConfig.logging.appenders),
logger: schema.object({
enabled: schema.boolean({ defaultValue: false }),
}),
});
export type AuditTrailConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<AuditTrailConfigType> = {
schema: configSchema,
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from 'src/core/server';
import { AuditTrailPlugin } from './plugin';
export { config } from './config';
export const plugin = (initializerContext: PluginInitializerContext) => {
return new AuditTrailPlugin(initializerContext);
};

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { first } from 'rxjs/operators';
import { AuditTrailPlugin } from './plugin';
import { coreMock } from '../../../../src/core/server/mocks';
import { securityMock } from '../../security/server/mocks';
import { spacesMock } from '../../spaces/server/mocks';
describe('AuditTrail plugin', () => {
describe('#setup', () => {
let plugin: AuditTrailPlugin;
let pluginInitContextMock: ReturnType<typeof coreMock.createPluginInitializerContext>;
let coreSetup: ReturnType<typeof coreMock.createSetup>;
const deps = {
security: securityMock.createSetup(),
spaces: spacesMock.createSetup(),
};
beforeEach(() => {
pluginInitContextMock = coreMock.createPluginInitializerContext();
plugin = new AuditTrailPlugin(pluginInitContextMock);
coreSetup = coreMock.createSetup();
});
afterEach(async () => {
await plugin.stop();
});
it('registers AuditTrail factory', async () => {
pluginInitContextMock = coreMock.createPluginInitializerContext();
plugin = new AuditTrailPlugin(pluginInitContextMock);
plugin.setup(coreSetup, deps);
expect(coreSetup.auditTrail.register).toHaveBeenCalledTimes(1);
});
describe('logger', () => {
it('registers a custom logger', async () => {
pluginInitContextMock = coreMock.createPluginInitializerContext();
plugin = new AuditTrailPlugin(pluginInitContextMock);
plugin.setup(coreSetup, deps);
expect(coreSetup.logging.configure).toHaveBeenCalledTimes(1);
});
it('disables logging if config.logger.enabled: false', async () => {
const config = {
logger: {
enabled: false,
},
};
pluginInitContextMock = coreMock.createPluginInitializerContext(config);
plugin = new AuditTrailPlugin(pluginInitContextMock);
plugin.setup(coreSetup, deps);
const args = coreSetup.logging.configure.mock.calls[0][0];
const value = await args.pipe(first()).toPromise();
expect(value.loggers?.every((l) => l.level === 'off')).toBe(true);
});
it('logs with DEBUG level if config.logger.enabled: true', async () => {
const config = {
logger: {
enabled: true,
},
};
pluginInitContextMock = coreMock.createPluginInitializerContext(config);
plugin = new AuditTrailPlugin(pluginInitContextMock);
plugin.setup(coreSetup, deps);
const args = coreSetup.logging.configure.mock.calls[0][0];
const value = await args.pipe(first()).toPromise();
expect(value.loggers?.every((l) => l.level === 'debug')).toBe(true);
});
it('uses appender adjusted via config', async () => {
const config = {
appender: {
kind: 'file',
path: '/path/to/file.txt',
},
logger: {
enabled: true,
},
};
pluginInitContextMock = coreMock.createPluginInitializerContext(config);
plugin = new AuditTrailPlugin(pluginInitContextMock);
plugin.setup(coreSetup, deps);
const args = coreSetup.logging.configure.mock.calls[0][0];
const value = await args.pipe(first()).toPromise();
expect(value.appenders).toEqual({ auditTrailAppender: config.appender });
});
it('falls back to the default appender if not configured', async () => {
const config = {
logger: {
enabled: true,
},
};
pluginInitContextMock = coreMock.createPluginInitializerContext(config);
plugin = new AuditTrailPlugin(pluginInitContextMock);
plugin.setup(coreSetup, deps);
const args = coreSetup.logging.configure.mock.calls[0][0];
const value = await args.pipe(first()).toPromise();
expect(value.appenders).toEqual({
auditTrailAppender: {
kind: 'console',
layout: {
kind: 'pattern',
highlight: true,
},
},
});
});
});
});
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Observable, Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import {
AppenderConfigType,
CoreSetup,
CoreStart,
KibanaRequest,
Logger,
LoggerContextConfigInput,
Plugin,
PluginInitializerContext,
} from 'src/core/server';
import { AuditEvent } from './types';
import { AuditTrailClient } from './client/audit_trail_client';
import { AuditTrailConfigType } from './config';
import { SecurityPluginSetup } from '../../security/server';
import { SpacesPluginSetup } from '../../spaces/server';
import { LicensingPluginStart } from '../../licensing/server';
interface DepsSetup {
security: SecurityPluginSetup;
spaces?: SpacesPluginSetup;
}
interface DepStart {
licensing: LicensingPluginStart;
}
export class AuditTrailPlugin implements Plugin {
private readonly logger: Logger;
private readonly config$: Observable<AuditTrailConfigType>;
private readonly event$ = new Subject<AuditEvent>();
constructor(private readonly context: PluginInitializerContext) {
this.logger = this.context.logger.get();
this.config$ = this.context.config.create();
}
public setup(core: CoreSetup, deps: DepsSetup) {
const depsApi = {
getCurrentUser: deps.security.authc.getCurrentUser,
getSpaceId: deps.spaces?.spacesService.getSpaceId,
};
this.event$.subscribe(({ message, ...other }) => this.logger.debug(message, other));
core.auditTrail.register({
asScoped: (request: KibanaRequest) => {
return new AuditTrailClient(request, this.event$, depsApi);
},
});
core.logging.configure(
this.config$.pipe<LoggerContextConfigInput>(
map((config) => ({
appenders: {
auditTrailAppender: this.getAppender(config),
},
loggers: [
{
// plugins.auditTrail prepended automatically
context: '',
// do not pipe in root log if disabled
level: config.logger.enabled ? 'debug' : 'off',
appenders: ['auditTrailAppender'],
},
],
}))
)
);
}
private getAppender(config: AuditTrailConfigType): AppenderConfigType {
return (
config.appender ?? {
kind: 'console',
layout: {
kind: 'pattern',
highlight: true,
},
}
);
}
public start(core: CoreStart, deps: DepStart) {}
public stop() {
this.event$.complete();
}
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Event enhanced with request context data. Provided to an external consumer.
* @public
*/
export interface AuditEvent {
message: string;
type: string;
scope?: string;
user?: string;
space?: string;
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { spacesServiceMock } from './spaces_service/spaces_service.mock';
function createSetupMock() {
return { spacesService: spacesServiceMock.createSetupContract() };
}
export const spacesMock = {
createSetup: createSetupMock,
};

View file

@ -28,6 +28,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
// list paths to the files that contain your plugins tests
testFiles: [
resolve(__dirname, './test_suites/audit_trail'),
resolve(__dirname, './test_suites/resolver'),
resolve(__dirname, './test_suites/global_search'),
],
@ -50,6 +51,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
)}`,
// Required to load new platform plugins via `--plugin-path` flag.
'--env.name=development',
'--xpack.audit_trail.enabled=true',
'--xpack.audit_trail.logger.enabled=true',
'--xpack.audit_trail.appender.kind=file',
'--xpack.audit_trail.appender.path=x-pack/test/plugin_functional/plugins/audit_trail_test/server/pattern_debug.log',
'--xpack.audit_trail.appender.layout.kind=json',
],
},
uiSettings: xpackFunctionalConfig.get('uiSettings'),

View file

@ -0,0 +1,9 @@
{
"id": "audit_trail_test",
"version": "1.0.0",
"kibanaVersion": "kibana",
"configPath": [],
"requiredPlugins": ["auditTrail"],
"server": true,
"ui": false
}

View file

@ -0,0 +1 @@
/*debug.log

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditTrailTestPlugin } from './plugin';
export const plugin = () => new AuditTrailTestPlugin();

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Plugin, CoreSetup } from 'src/core/server';
export class AuditTrailTestPlugin implements Plugin {
public setup(core: CoreSetup) {
core.savedObjects.registerType({
name: 'audit_trail_test',
hidden: false,
namespaceType: 'agnostic',
mappings: {
properties: {},
},
});
const router = core.http.createRouter();
router.get(
{ path: '/audit_trail_test/context/as_current_user', validate: false },
async (context, request, response) => {
context.core.auditor.withAuditScope('audit_trail_test/context/as_current_user');
await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping');
return response.noContent();
}
);
router.get(
{ path: '/audit_trail_test/context/as_internal_user', validate: false },
async (context, request, response) => {
context.core.auditor.withAuditScope('audit_trail_test/context/as_internal_user');
await context.core.elasticsearch.legacy.client.callAsInternalUser('ping');
return response.noContent();
}
);
router.get(
{ path: '/audit_trail_test/contract/as_current_user', validate: false },
async (context, request, response) => {
const [coreStart] = await core.getStartServices();
const auditor = coreStart.auditTrail.asScoped(request);
auditor.withAuditScope('audit_trail_test/contract/as_current_user');
await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping');
return response.noContent();
}
);
router.get(
{ path: '/audit_trail_test/contract/as_internal_user', validate: false },
async (context, request, response) => {
const [coreStart] = await core.getStartServices();
const auditor = coreStart.auditTrail.asScoped(request);
auditor.withAuditScope('audit_trail_test/contract/as_internal_user');
await context.core.elasticsearch.legacy.client.callAsInternalUser('ping');
return response.noContent();
}
);
}
public start() {}
}

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Path from 'path';
import Fs from 'fs';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
class FileWrapper {
constructor(private readonly path: string) {}
async reset() {
// "touch" each file to ensure it exists and is empty before each test
await Fs.promises.writeFile(this.path, '');
}
async read() {
const content = await Fs.promises.readFile(this.path, { encoding: 'utf8' });
return content.trim().split('\n');
}
async readJSON() {
const content = await this.read();
return content.map((l) => JSON.parse(l));
}
// writing in a file is an async operation. we use this method to make sure logs have been written.
async isNotEmpty() {
const content = await this.read();
const line = content[0];
return line.length > 0;
}
}
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const supertest = getService('supertest');
const retry = getService('retry');
describe('Audit trail service', function () {
this.tags('ciGroup7');
const logFilePath = Path.resolve(
__dirname,
'../../plugins/audit_trail_test/server/pattern_debug.log'
);
const logFile = new FileWrapper(logFilePath);
beforeEach(async () => {
await logFile.reset();
});
it('logs current user access to elasticsearch via RequestHandlerContext', async () => {
await supertest
.get('/audit_trail_test/context/as_current_user')
.set('kbn-xsrf', 'foo')
.expect(204);
await retry.waitFor('logs event in the dest file', async () => {
return await logFile.isNotEmpty();
});
const content = await logFile.readJSON();
const pingCall = content.find(
(c) => c.meta.scope === 'audit_trail_test/context/as_current_user'
);
expect(pingCall).to.be.ok();
expect(pingCall.meta.type).to.be('elasticsearch.call.currentUser');
expect(pingCall.meta.user).to.be('elastic');
expect(pingCall.meta.space).to.be('default');
});
it('logs internal user access to elasticsearch via RequestHandlerContext', async () => {
await supertest
.get('/audit_trail_test/context/as_internal_user')
.set('kbn-xsrf', 'foo')
.expect(204);
await retry.waitFor('logs event in the dest file', async () => {
return await logFile.isNotEmpty();
});
const content = await logFile.readJSON();
const pingCall = content.find(
(c) => c.meta.scope === 'audit_trail_test/context/as_internal_user'
);
expect(pingCall).to.be.ok();
expect(pingCall.meta.type).to.be('elasticsearch.call.internalUser');
expect(pingCall.meta.user).to.be('elastic');
expect(pingCall.meta.space).to.be('default');
});
it('logs current user access to elasticsearch via coreStart contract', async () => {
await supertest
.get('/audit_trail_test/contract/as_current_user')
.set('kbn-xsrf', 'foo')
.expect(204);
await retry.waitFor('logs event in the dest file', async () => {
return await logFile.isNotEmpty();
});
const content = await logFile.readJSON();
const pingCall = content.find(
(c) => c.meta.scope === 'audit_trail_test/contract/as_current_user'
);
expect(pingCall).to.be.ok();
expect(pingCall.meta.type).to.be('elasticsearch.call.currentUser');
expect(pingCall.meta.user).to.be('elastic');
expect(pingCall.meta.space).to.be('default');
});
it('logs internal user access to elasticsearch via coreStart contract', async () => {
await supertest
.get('/audit_trail_test/contract/as_internal_user')
.set('kbn-xsrf', 'foo')
.expect(204);
await retry.waitFor('logs event in the dest file', async () => {
return await logFile.isNotEmpty();
});
const content = await logFile.readJSON();
const pingCall = content.find(
(c) => c.meta.scope === 'audit_trail_test/contract/as_internal_user'
);
expect(pingCall).to.be.ok();
expect(pingCall.meta.type).to.be('elasticsearch.call.internalUser');
expect(pingCall.meta.user).to.be('elastic');
expect(pingCall.meta.space).to.be('default');
});
});
}