ECS audit logging (#74640)

* ECS audit logging

* Apply suggestions from code review

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>

* Update x-pack/plugins/security/server/authentication/audit_events.ts

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>

* Update docs/settings/security-settings.asciidoc

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>

* remove audit trail service from core

* fix test

* Updated docs and added beta warning

* Added dev docs

* Tweaks

* Plugin list changes

* Apply suggestions from technical writers

Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co>

* Added docs suggestion

* Added api integration tests

* Added suggestions from platform team

* Update x-pack/plugins/security/server/audit/audit_service.test.ts

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

* Update x-pack/plugins/security/server/audit/audit_service.test.ts

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

* Update x-pack/plugins/security/server/audit/audit_service.test.ts

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

* Update docs/user/security/audit-logging.asciidoc

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

* Update docs/settings/security-settings.asciidoc

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

* Update x-pack/plugins/security/server/config.ts

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

* Added suggestions from PR

* Grouped events table

* Update x-pack/plugins/security/server/audit/audit_events.ts

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>

* Update x-pack/plugins/security/server/audit/audit_events.ts

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>

* Fixed ECS version number in docs

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>

* Added suggestions from code review

* Removed beta

* Added suggestions from code review

Co-authored-by: Larry Gregory <larry.gregory@elastic.co>
Co-authored-by: Kaarina Tungseth <kaarina.tungseth@elastic.co>
Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
Thom Heymann 2020-10-16 20:40:38 +01:00 committed by GitHub
parent 403c4dac5e
commit bc8a1dac99
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
93 changed files with 2466 additions and 2039 deletions

View file

@ -286,10 +286,6 @@ which will load the visualization's editor.
|To access an elasticsearch instance that has live data you have two options:
|{kib-repo}blob/{branch}/x-pack/plugins/audit_trail[auditTrail]
|WARNING: Missing README.
|{kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement]
|Notes:
Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place
@ -469,7 +465,8 @@ Elastic.
|{kib-repo}blob/{branch}/x-pack/plugins/security/README.md[security]
|See Configuring security in Kibana.
|See Configuring security in
Kibana.
|{kib-repo}blob/{branch}/x-pack/plugins/security_solution/README.md[securitySolution]

View file

@ -1,25 +0,0 @@
<!-- 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

@ -1,11 +0,0 @@
<!-- 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

@ -1,11 +0,0 @@
<!-- 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

@ -1,36 +0,0 @@
<!-- 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

@ -1,21 +0,0 @@
<!-- 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

@ -1,24 +0,0 @@
<!-- 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

@ -1,22 +0,0 @@
<!-- 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

@ -1,20 +0,0 @@
<!-- 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

@ -1,18 +0,0 @@
<!-- 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

@ -1,24 +0,0 @@
<!-- 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

@ -1,11 +0,0 @@
<!-- 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

@ -1,13 +0,0 @@
<!-- 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,7 +16,6 @@ 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

@ -1,13 +0,0 @@
<!-- 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,7 +16,6 @@ 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

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

View file

@ -21,7 +21,7 @@ export declare class LegacyClusterClient implements ILegacyClusterClient
| Constructor | Modifiers | Description |
| --- | --- | --- |
| [(constructor)(config, log, getAuditorFactory, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the <code>LegacyClusterClient</code> class |
| [(constructor)(config, log, 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, auditor?: Auditor | undefined);
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined);
```
## Parameters
@ -19,5 +19,4 @@ 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

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

View file

@ -53,10 +53,6 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [AppCategory](./kibana-plugin-core-server.appcategory.md) | A category definition for nav links to know where to sort them in the left hand nav |
| [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) | |
@ -132,7 +128,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [PluginConfigDescriptor](./kibana-plugin-core-server.pluginconfigdescriptor.md) | Describes a plugin configuration properties. |
| [PluginInitializerContext](./kibana-plugin-core-server.plugininitializercontext.md) | Context that's available to plugins during initialization stage. |
| [PluginManifest](./kibana-plugin-core-server.pluginmanifest.md) | Describes the set of required and optional properties plugin can define in its mandatory JSON manifest file. |
| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.<!-- -->Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request - [uiSettings.auditor](./kibana-plugin-core-server.auditor.md) - AuditTrail client scoped to the incoming request |
| [RequestHandlerContext](./kibana-plugin-core-server.requesthandlercontext.md) | Plugin specific context passed to a route handler.<!-- -->Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request |
| [RouteConfig](./kibana-plugin-core-server.routeconfig.md) | Route specific configuration. |
| [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) | Additional route options. |
| [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md) | Additional body options for a route |
@ -223,7 +219,6 @@ 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

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

View file

@ -6,7 +6,7 @@
Plugin specific context passed to a route handler.
Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request - [uiSettings.auditor](./kibana-plugin-core-server.auditor.md) - AuditTrail client scoped to the incoming request
Provides the following clients and services: - [savedObjects.client](./kibana-plugin-core-server.savedobjectsclient.md) - Saved Objects client which uses the credentials of the incoming request - [savedObjects.typeRegistry](./kibana-plugin-core-server.isavedobjecttyperegistry.md) - Type registry containing all the registered types. - [elasticsearch.client](./kibana-plugin-core-server.iscopedclusterclient.md) - Elasticsearch data client which uses the credentials of the incoming request - [elasticsearch.legacy.client](./kibana-plugin-core-server.legacyscopedclusterclient.md) - The legacy Elasticsearch data client which uses the credentials of the incoming request - [uiSettings.client](./kibana-plugin-core-server.iuisettingsclient.md) - uiSettings client which uses the credentials of the incoming request
<b>Signature:</b>
@ -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> client: IScopedClusterClient;</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> | |
| [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> client: IScopedClusterClient;</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> | |

View file

@ -155,7 +155,7 @@ There is a very limited set of cases when you'd want to change these settings. F
| `xpack.security.authc.http.autoSchemesEnabled`
| Determines if HTTP authentication schemes used by the enabled authentication providers should be automatically supported during HTTP authentication. By default, this setting is set to `true`.
| `xpack.security.authc.http.schemes`
| `xpack.security.authc.http.schemes[]`
| List of HTTP authentication schemes that {kib} HTTP authentication should support. By default, this setting is set to `['apikey']` to support HTTP authentication with <<api-keys, `ApiKey`>> scheme.
|===
@ -240,7 +240,6 @@ The format is a string of `<count>[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h',
|===
[float]
[[security-encrypted-saved-objects-settings]]
==== Encrypted saved objects settings
@ -261,4 +260,112 @@ In high-availability deployments, make sure you use the same encryption and decr
`keyRotation.decryptionOnlyKeys`
| An optional list of previously used encryption keys. Like <<xpack-encryptedSavedObjects-encryptionKey, `xpack.encryptedSavedObjects.encryptionKey`>>, these must be at least 32 characters in length. {kib} doesn't use these keys for encryption, but may still require them to decrypt some existing saved objects. Use this setting if you wish to change your encryption key, but don't want to lose access to saved objects that were previously encrypted with a different key.
|===
|===
[float]
[[audit-logging-settings]]
===== Audit logging settings
You can enable audit logging to support compliance, accountability, and security. When enabled, {kib} will capture:
- Who performed an action
- What action was performed
- When the action occurred
For more details and a reference of audit events, refer to <<xpack-security-audit-logging>>.
[cols="2*<"]
|===
| `xpack.security.audit.enabled`
| Set to `true` to enable audit logging for security events. *Default:* `false`
|===
[float]
[[ecs-audit-logging-settings]]
===== ECS audit logging settings
To enable the <<xpack-security-ecs-audit-logging, ECS audit logger>>, specify where you want to write the audit events using `xpack.security.audit.appender`.
[cols="2*<"]
|===
| `xpack.security.audit.appender`
| Optional. Specifies where audit logs should be written to and how they should be formatted.
2+a| For example:
[source,yaml]
----------------------------------------
xpack.security.audit.appender:
kind: file
path: /path/to/audit.log
layout:
kind: json
----------------------------------------
| `xpack.security.audit.appender.kind`
| Required. Specifies where audit logs should be written to. Allowed values are `console` or `file`.
|===
[float]
[[audit-logging-file-appender]]
===== File appender
The file appender can be configured using the following settings:
[cols="2*<"]
|===
| `xpack.security.audit.appender.path`
| Required. Full file path the log file should be written to.
| `xpack.security.audit.appender.layout.kind`
| Required. Specifies how audit logs should be formatted. Allowed values are `json` or `pattern`.
|===
[float]
[[audit-logging-pattern-layout]]
===== Pattern layout
The pattern layout can be configured using the following settings:
[cols="2*<"]
|===
| `xpack.security.audit.appender.layout.highlight`
| Optional. Set to `true` to enable highlighting log messages with colors.
| `xpack.security.audit.appender.layout.pattern`
| Optional. Specifies how the log line should be formatted. *Default:* `[%date][%level][%logger]%meta %message`
|===
[float]
[[audit-logging-ignore-filters]]
===== Ignore filters
[cols="2*<"]
|===
| `xpack.security.audit.ignore_filters[]`
| List of filters that determine which events should be excluded from the audit log. An event will get filtered out if at least one of the provided filters matches.
2+a| For example:
[source,yaml]
----------------------------------------
xpack.security.audit.ignore_filters:
- actions: [http_request] <1>
- categories: [database]
types: [creation, change, deletion] <2>
----------------------------------------
<1> Filters out HTTP request events
<2> Filters out any data write events
| `xpack.security.audit.ignore_filters[].actions[]`
| List of values matched against the `event.action` field of an audit event. Refer to <<xpack-security-audit-logging>> for a list of available events.
| `xpack.security.audit.ignore_filters[].categories[]`
| List of values matched against the `event.category` field of an audit event. Refer to https://www.elastic.co/guide/en/ecs/1.5/ecs-allowed-values-event-category.html[ECS categorization field] for allowed values.
| `xpack.security.audit.ignore_filters[].types[]`
| List of values matched against the `event.type` field of an audit event. Refer to https://www.elastic.co/guide/en/ecs/1.5/ecs-allowed-values-event-type.html[ECS type field] for allowed values.
| `xpack.security.audit.ignore_filters[].outcomes[]`
| List of values matched against the `event.outcome` field of an audit event. Refer to https://www.elastic.co/guide/en/ecs/1.5/ecs-allowed-values-event-outcome.html[ECS outcome field] for allowed values.
|===

View file

@ -3,30 +3,30 @@
=== Audit logs
You can enable auditing to keep track of security-related events such as
authorization success and failures. Logging these events enables you
to monitor {kib} for suspicious activity and provides evidence in the
event of an attack.
authorization success and failures. Logging these events enables you to monitor
{kib} for suspicious activity and provides evidence in the event of an attack.
Use the {kib} audit logs in conjunction with {es}'s
audit logging to get a holistic view of all security related events.
{kib} defers to {es}'s security model for authentication, data
index authorization, and features that are driven by cluster-wide privileges.
For more information on enabling audit logging in {es}, see
{ref}/auditing.html[Auditing security events].
Use the {kib} audit logs in conjunction with {ref}/enable-audit-logging.html[{es} audit logging] to get a
holistic view of all security related events. {kib} defers to the {es} security
model for authentication, data index authorization, and features that are driven
by cluster-wide privileges. For more information on enabling audit logging in
{es}, refer to {ref}/auditing.html[Auditing security events].
[IMPORTANT]
============================================================================
Audit logs are **disabled** by default. To enable this functionality, you
must set `xpack.security.audit.enabled` to `true` in `kibana.yml`.
Audit logs are **disabled** by default. To enable this functionality, you must
set `xpack.security.audit.enabled` to `true` in `kibana.yml`.
============================================================================
Audit logging uses the standard {kib} logging output, which can be configured
in the `kibana.yml` and is discussed in <<settings>>.
The current version of the audit logger uses the standard {kib} logging output,
which can be configured in `kibana.yml`. For more information, refer to <<settings>>.
The audit logger uses a separate logger and can be configured using
the options in <<audit-logging-settings>>.
==== Audit event types
When you are auditing security events, each request can generate
multiple audit events. The following is a list of the events that can be generated:
When you are auditing security events, each request can generate multiple audit
events. The following is a list of the events that can be generated:
|======
| `saved_objects_authorization_success` | Logged when a user is authorized to access a saved
@ -34,3 +34,110 @@ multiple audit events. The following is a list of the events that can be generat
| `saved_objects_authorization_failure` | Logged when a user isn't authorized to access a saved
objects when using a role with <<kibana-privileges>>
|======
[[xpack-security-ecs-audit-logging]]
==== ECS audit events
[IMPORTANT]
============================================================================
The following events are only logged if the ECS audit logger is enabled.
For information on how to configure `xpack.security.audit.appender`, refer to
<<ecs-audit-logging-settings>>.
============================================================================
Refer to the table of events that can be logged for auditing purposes.
Each event is broken down into `category`, `type`, `action` and `outcome` fields
to make it easy to filter, query and aggregate the resulting logs.
[NOTE]
============================================================================
To ensure that a record of every operation is persisted even in case of an
unexpected error, asynchronous write operations are logged immediately after all
authorization checks have passed, but before the response from {es} is received.
Refer to the corresponding {es} logs for potential write errors.
============================================================================
[cols="3*<"]
|======
3+a|
===== Category: authentication
| *Action*
| *Outcome*
| *Description*
.2+| `user_login`
| `success` | User has logged in successfully.
| `failure` | Failed login attempt (e.g. due to invalid credentials).
3+a|
===== Category: database
====== Type: creation
| *Action*
| *Outcome*
| *Description*
.2+| `saved_object_create`
| `unknown` | User is creating a saved object.
| `failure` | User is not authorized to create a saved object.
3+a|
====== Type: change
| *Action*
| *Outcome*
| *Description*
.2+| `saved_object_update`
| `unknown` | User is updating a saved object.
| `failure` | User is not authorized to update a saved object.
.2+| `saved_object_add_to_spaces`
| `unknown` | User is adding a saved object to other spaces.
| `failure` | User is not authorized to add a saved object to other spaces.
.2+| `saved_object_delete_from_spaces`
| `unknown` | User is removing a saved object from other spaces.
| `failure` | User is not authorized to remove a saved object from other spaces.
3+a|
====== Type: deletion
| *Action*
| *Outcome*
| *Description*
.2+| `saved_object_delete`
| `unknown` | User is deleting a saved object.
| `failure` | User is not authorized to delete a saved object.
3+a|
====== Type: access
| *Action*
| *Outcome*
| *Description*
.2+| `saved_object_get`
| `success` | User has accessed a saved object.
| `failure` | User is not authorized to access a saved object.
.2+| `saved_object_find`
| `success` | User has accessed a saved object as part of a search operation.
| `failure` | User is not authorized to search for saved objects.
3+a|
===== Category: web
| *Action*
| *Outcome*
| *Description*
| `http_request`
| `unknown` | User is making an HTTP request.
|======

View file

@ -1,58 +0,0 @@
/*
* 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 type { PublicMethodsOf } from '@kbn/utility-types';
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

@ -1,99 +0,0 @@
/*
* 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

@ -1,69 +0,0 @@
/*
* 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

@ -1,21 +0,0 @@
/*
* 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

@ -1,76 +0,0 @@
/*
* 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

@ -19,41 +19,6 @@
import { CoreRouteHandlerContext } from './core_route_handler_context';
import { coreMock, httpServerMock } from './mocks';
describe('#auditor', () => {
test('returns the results of coreStart.audiTrail.asScoped', () => {
const request = httpServerMock.createKibanaRequest();
const coreStart = coreMock.createInternalStart();
const context = new CoreRouteHandlerContext(coreStart, request);
const auditor = context.auditor;
expect(auditor).toBe(coreStart.auditTrail.asScoped.mock.results[0].value);
});
test('lazily created', () => {
const request = httpServerMock.createKibanaRequest();
const coreStart = coreMock.createInternalStart();
const context = new CoreRouteHandlerContext(coreStart, request);
expect(coreStart.auditTrail.asScoped).not.toHaveBeenCalled();
const auditor = context.auditor;
expect(coreStart.auditTrail.asScoped).toHaveBeenCalled();
expect(auditor).toBeDefined();
});
test('only creates one instance', () => {
const request = httpServerMock.createKibanaRequest();
const coreStart = coreMock.createInternalStart();
const context = new CoreRouteHandlerContext(coreStart, request);
const auditor1 = context.auditor;
const auditor2 = context.auditor;
expect(coreStart.auditTrail.asScoped.mock.calls.length).toBe(1);
const mockResult = coreStart.auditTrail.asScoped.mock.results[0].value;
expect(auditor1).toBe(mockResult);
expect(auditor2).toBe(mockResult);
});
});
describe('#elasticsearch', () => {
describe('#client', () => {
test('returns the results of coreStart.elasticsearch.client.asScoped', () => {

View file

@ -27,7 +27,6 @@ import {
IScopedClusterClient,
LegacyScopedClusterClient,
} from './elasticsearch';
import { Auditor } from './audit_trail';
import { InternalUiSettingsServiceStart, IUiSettingsClient } from './ui_settings';
class CoreElasticsearchRouteHandlerContext {
@ -99,8 +98,6 @@ class CoreUiSettingsRouteHandlerContext {
}
export class CoreRouteHandlerContext {
#auditor?: Auditor;
readonly elasticsearch: CoreElasticsearchRouteHandlerContext;
readonly savedObjects: CoreSavedObjectsRouteHandlerContext;
readonly uiSettings: CoreUiSettingsRouteHandlerContext;
@ -122,11 +119,4 @@ export class CoreRouteHandlerContext {
this.savedObjects
);
}
public get auditor() {
if (this.#auditor == null) {
this.#auditor = this.coreStart.auditTrail.asScoped(this.request);
}
return this.#auditor;
}
}

View file

@ -26,7 +26,6 @@ import { configServiceMock, getEnvOptions } from '../config/mocks';
import { CoreContext } from '../core_context';
import { loggingSystemMock } from '../logging/logging_system.mock';
import { httpServiceMock } from '../http/http_service.mock';
import { auditTrailServiceMock } from '../audit_trail/audit_trail_service.mock';
import { ElasticsearchConfig } from './elasticsearch_config';
import { ElasticsearchService } from './elasticsearch_service';
import { elasticsearchServiceMock } from './elasticsearch_service.mock';
@ -41,9 +40,6 @@ const configService = configServiceMock.create();
const setupDeps = {
http: httpServiceMock.createInternalSetupContract(),
};
const startDeps = {
auditTrail: auditTrailServiceMock.createStartContract(),
};
configService.atPath.mockReturnValue(
new BehaviorSubject({
hosts: ['http://1.2.3.4'],
@ -113,7 +109,6 @@ describe('#setup', () => {
expect(MockLegacyClusterClient).toHaveBeenCalledWith(
expect.objectContaining(customConfig),
expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }),
expect.any(Function),
expect.any(Function)
);
});
@ -260,14 +255,14 @@ describe('#setup', () => {
describe('#start', () => {
it('throws if called before `setup`', async () => {
expect(() => elasticsearchService.start(startDeps)).rejects.toMatchInlineSnapshot(
expect(() => elasticsearchService.start()).rejects.toMatchInlineSnapshot(
`[Error: ElasticsearchService needs to be setup before calling start]`
);
});
it('returns elasticsearch client as a part of the contract', async () => {
await elasticsearchService.setup(setupDeps);
const startContract = await elasticsearchService.start(startDeps);
const startContract = await elasticsearchService.start();
const client = startContract.client;
expect(client.asInternalUser).toBe(mockClusterClientInstance.asInternalUser);
@ -276,7 +271,7 @@ describe('#start', () => {
describe('#createClient', () => {
it('allows to specify config properties', async () => {
await elasticsearchService.setup(setupDeps);
const startContract = await elasticsearchService.start(startDeps);
const startContract = await elasticsearchService.start();
// reset all mocks called during setup phase
MockClusterClient.mockClear();
@ -295,7 +290,7 @@ describe('#start', () => {
});
it('creates a new client on each call', async () => {
await elasticsearchService.setup(setupDeps);
const startContract = await elasticsearchService.start(startDeps);
const startContract = await elasticsearchService.start();
// reset all mocks called during setup phase
MockClusterClient.mockClear();
@ -310,7 +305,7 @@ describe('#start', () => {
it('falls back to elasticsearch default config values if property not specified', async () => {
await elasticsearchService.setup(setupDeps);
const startContract = await elasticsearchService.start(startDeps);
const startContract = await elasticsearchService.start();
// reset all mocks called during setup phase
MockClusterClient.mockClear();
@ -347,7 +342,7 @@ describe('#start', () => {
describe('#stop', () => {
it('stops both legacy and new clients', async () => {
await elasticsearchService.setup(setupDeps);
await elasticsearchService.start(startDeps);
await elasticsearchService.start();
await elasticsearchService.stop();
expect(mockLegacyClusterClientInstance.close).toHaveBeenCalledTimes(1);

View file

@ -32,7 +32,6 @@ import {
import { ClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from './client';
import { ElasticsearchConfig, ElasticsearchConfigType } from './elasticsearch_config';
import { InternalHttpServiceSetup, GetAuthHeaders } from '../http/';
import { AuditTrailStart, AuditorFactory } from '../audit_trail';
import { InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart } from './types';
import { pollEsNodesVersion } from './version_check/ensure_es_version';
import { calculateStatus$ } from './status';
@ -41,16 +40,11 @@ interface SetupDeps {
http: InternalHttpServiceSetup;
}
interface StartDeps {
auditTrail: AuditTrailStart;
}
/** @internal */
export class ElasticsearchService
implements CoreService<InternalElasticsearchServiceSetup, InternalElasticsearchServiceStart> {
private readonly log: Logger;
private readonly config$: Observable<ElasticsearchConfig>;
private auditorFactory?: AuditorFactory;
private stop$ = new Subject();
private kibanaVersion: string;
private getAuthHeaders?: GetAuthHeaders;
@ -103,8 +97,7 @@ export class ElasticsearchService
status$: calculateStatus$(esNodesCompatibility$),
};
}
public async start({ auditTrail }: StartDeps): Promise<InternalElasticsearchServiceStart> {
this.auditorFactory = auditTrail;
public async start(): Promise<InternalElasticsearchServiceStart> {
if (!this.legacyClient || !this.createLegacyCustomClient) {
throw new Error('ElasticsearchService needs to be setup before calling start');
}
@ -153,15 +146,7 @@ export class ElasticsearchService
return new LegacyClusterClient(
config,
this.coreContext.logger.get('elasticsearch', type),
this.getAuditorFactory,
this.getAuthHeaders
);
}
private getAuditorFactory = () => {
if (!this.auditorFactory) {
throw new Error('auditTrail has not been initialized');
}
return this.auditorFactory;
};
}

View file

@ -27,7 +27,6 @@ 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';
@ -43,11 +42,7 @@ test('#constructor creates client with parsed config', () => {
const mockEsConfig = { apiVersion: 'es-version' } as any;
const mockLogger = logger.get();
const clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
const clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
expect(clusterClient).toBeDefined();
expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1);
@ -73,11 +68,7 @@ describe('#callAsInternalUser', () => {
};
MockClient.mockImplementation(() => mockEsClientInstance);
clusterClient = new LegacyClusterClient(
{ apiVersion: 'es-version' } as any,
logger.get(),
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient({ apiVersion: 'es-version' } as any, logger.get());
});
test('fails if cluster client is closed', async () => {
@ -246,11 +237,7 @@ describe('#asScoped', () => {
requestHeadersWhitelist: ['one', 'two'],
} as any;
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
jest.clearAllMocks();
});
@ -285,11 +272,7 @@ describe('#asScoped', () => {
test('properly configures `ignoreCertAndKey` for various configurations', () => {
// Config without SSL.
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
@ -302,11 +285,7 @@ describe('#asScoped', () => {
// Config ssl.alwaysPresentCertificate === false
mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: false } } as any;
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
@ -319,11 +298,7 @@ describe('#asScoped', () => {
// Config ssl.alwaysPresentCertificate === true
mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: true } } as any;
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
mockParseElasticsearchClientConfig.mockClear();
clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } }));
@ -344,8 +319,7 @@ describe('#asScoped', () => {
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: '1', two: '2' },
expect.any(Object)
{ one: '1', two: '2' }
);
});
@ -360,8 +334,7 @@ describe('#asScoped', () => {
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ 'x-opaque-id': 'alpha' },
expect.any(Object)
{ 'x-opaque-id': 'alpha' }
);
});
@ -383,142 +356,75 @@ describe('#asScoped', () => {
});
test('does not fail when scope to not defined request', async () => {
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
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,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
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,
auditTrailServiceMock.createAuditorFactory,
() => ({
one: '1',
three: '3',
})
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({
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' },
expect.any(Object)
{ one: '1', two: '2' }
);
});
test('getAuthHeaders results rewrite extends a request headers', async () => {
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory,
() => ({ one: 'foo' })
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({ 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' },
expect.any(Object)
{ one: 'foo', two: '2' }
);
});
test("doesn't call getAuthHeaders for a fake request", async () => {
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory,
() => ({})
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({}));
clusterClient.asScoped({ headers: { one: 'foo' } });
expect(MockScopedClusterClient).toHaveBeenCalledTimes(1);
expect(MockScopedClusterClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
{ one: 'foo' },
undefined
{ one: 'foo' }
);
});
test('filters a fake request headers', async () => {
clusterClient = new LegacyClusterClient(
mockEsConfig,
mockLogger,
auditTrailServiceMock.createAuditorFactory
);
clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger);
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' },
undefined
{ one: '1', two: '2' }
);
});
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),
expect.objectContaining({ 'x-opaque-id': expect.any(String) }),
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', () => {
@ -536,8 +442,7 @@ describe('#close', () => {
clusterClient = new LegacyClusterClient(
{ apiVersion: 'es-version', requestHeadersWhitelist: [] } as any,
logger.get(),
auditTrailServiceMock.createAuditorFactory
logger.get()
);
});

View file

@ -20,8 +20,7 @@ import { Client } from 'elasticsearch';
import { get } from 'lodash';
import { LegacyElasticsearchErrorHelpers } from './errors';
import { GetAuthHeaders, KibanaRequest, isKibanaRequest, isRealRequest } from '../../http';
import { AuditorFactory } from '../../audit_trail';
import { GetAuthHeaders, isKibanaRequest, isRealRequest } from '../../http';
import { filterHeaders, ensureRawRequest } from '../../http/router';
import { Logger } from '../../logging';
import { ScopeableRequest } from '../types';
@ -132,7 +131,6 @@ 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));
@ -210,20 +208,10 @@ export class LegacyClusterClient implements ILegacyClusterClient {
filterHeaders(this.getHeaders(request), [
'x-opaque-id',
...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 = isKibanaRequest(request) ? 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,7 +18,6 @@
*/
import { LegacyScopedClusterClient } from './scoped_cluster_client';
import { auditTrailServiceMock } from '../../audit_trail/audit_trail_service.mock';
let internalAPICaller: jest.Mock;
let scopedAPICaller: jest.Mock;
@ -84,28 +83,6 @@ 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', () => {
@ -229,26 +206,4 @@ 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,7 +18,6 @@
*/
import { intersection, isObject } from 'lodash';
import { Auditor } from '../../audit_trail';
import { Headers } from '../../http/router';
import { LegacyAPICaller, LegacyCallAPIOptions } from './api_types';
@ -47,8 +46,7 @@ export class LegacyScopedClusterClient implements ILegacyScopedClusterClient {
constructor(
private readonly internalAPICaller: LegacyAPICaller,
private readonly scopedAPICaller: LegacyAPICaller,
private readonly headers?: Headers,
private readonly auditor?: Auditor
private readonly headers?: Headers
) {
this.callAsCurrentUser = this.callAsCurrentUser.bind(this);
this.callAsInternalUser = this.callAsInternalUser.bind(this);
@ -68,13 +66,6 @@ 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);
}
@ -107,13 +98,6 @@ 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,7 +62,6 @@ import {
import { CapabilitiesSetup, CapabilitiesStart } from './capabilities';
import { MetricsServiceSetup, MetricsServiceStart } from './metrics';
import { StatusServiceSetup } from './status';
import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging';
import { CoreUsageDataStart } from './core_usage_data';
@ -77,7 +76,6 @@ import {
export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData };
export { AuditableEvent, Auditor, AuditorFactory, AuditTrailSetup } from './audit_trail';
export { bootstrap } from './bootstrap';
export { Capabilities, CapabilitiesProvider, CapabilitiesSwitcher } from './capabilities';
export {
@ -378,7 +376,6 @@ export { CoreUsageDataStart } from './core_usage_data';
* data client which uses the credentials of the incoming request
* - {@link IUiSettingsClient | uiSettings.client} - uiSettings client
* which uses the credentials of the incoming request
* - {@link Auditor | uiSettings.auditor} - AuditTrail client scoped to the incoming request
*
* @public
*/
@ -397,7 +394,6 @@ export interface RequestHandlerContext {
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
};
}
@ -434,8 +430,6 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
uiSettings: UiSettingsServiceSetup;
/** {@link StartServicesAccessor} */
getStartServices: StartServicesAccessor<TPluginsStart, TStart>;
/** {@link AuditTrailSetup} */
auditTrail: AuditTrailSetup;
}
/**
@ -469,8 +463,6 @@ export interface CoreStart {
savedObjects: SavedObjectsServiceStart;
/** {@link UiSettingsServiceStart} */
uiSettings: UiSettingsServiceStart;
/** {@link AuditTrailSetup} */
auditTrail: AuditTrailStart;
/** @internal {@link CoreUsageDataStart} */
coreUsageData: CoreUsageDataStart;
}
@ -483,7 +475,6 @@ export {
PluginsServiceSetup,
PluginsServiceStart,
PluginOpaqueId,
AuditTrailStart,
};
/**

View file

@ -37,7 +37,6 @@ import { InternalMetricsServiceSetup, InternalMetricsServiceStart } from './metr
import { InternalRenderingServiceSetup } from './rendering';
import { InternalHttpResourcesSetup } from './http_resources';
import { InternalStatusServiceSetup } from './status';
import { AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { InternalLoggingServiceSetup } from './logging';
import { CoreUsageDataStart } from './core_usage_data';
@ -53,7 +52,6 @@ export interface InternalCoreSetup {
environment: InternalEnvironmentServiceSetup;
rendering: InternalRenderingServiceSetup;
httpResources: InternalHttpResourcesSetup;
auditTrail: AuditTrailSetup;
logging: InternalLoggingServiceSetup;
metrics: InternalMetricsServiceSetup;
}
@ -68,7 +66,6 @@ export interface InternalCoreStart {
metrics: InternalMetricsServiceStart;
savedObjects: InternalSavedObjectsServiceStart;
uiSettings: InternalUiSettingsServiceStart;
auditTrail: AuditTrailStart;
coreUsageData: CoreUsageDataStart;
}

View file

@ -44,7 +44,6 @@ import { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './types';
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';
import { metricsServiceMock } from '../metrics/metrics_service.mock';
@ -92,7 +91,6 @@ beforeEach(() => {
rendering: renderingServiceMock,
environment: environmentSetup,
status: statusServiceMock.createInternalSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
},

View file

@ -216,7 +216,6 @@ export class LegacyService implements CoreService {
getOpsMetrics$: startDeps.core.metrics.getOpsMetrics$,
},
uiSettings: { asScopedToClient: startDeps.core.uiSettings.asScopedToClient },
auditTrail: startDeps.core.auditTrail,
coreUsageData: {
getCoreUsageData: () => {
throw new Error('core.start.coreUsageData.getCoreUsageData is unsupported in legacy');
@ -284,7 +283,6 @@ export class LegacyService implements CoreService {
uiSettings: {
register: setupDeps.core.uiSettings.register,
},
auditTrail: setupDeps.core.auditTrail,
getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]),
};

View file

@ -36,7 +36,6 @@ import { capabilitiesServiceMock } from './capabilities/capabilities_service.moc
import { metricsServiceMock } from './metrics/metrics_service.mock';
import { environmentServiceMock } from './environment/environment_service.mock';
import { statusServiceMock } from './status/status_service.mock';
import { auditTrailServiceMock } from './audit_trail/audit_trail_service.mock';
import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_service.mock';
export { configServiceMock } from './config/mocks';
@ -139,7 +138,6 @@ function createCoreSetupMock({
savedObjects: savedObjectsServiceMock.createInternalSetupContract(),
status: statusServiceMock.createSetupContract(),
uiSettings: uiSettingsMock,
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createSetupContract(),
metrics: metricsServiceMock.createSetupContract(),
getStartServices: jest
@ -152,7 +150,6 @@ function createCoreSetupMock({
function createCoreStartMock() {
const mock: MockedKeys<CoreStart> = {
auditTrail: auditTrailServiceMock.createStartContract(),
capabilities: capabilitiesServiceMock.createStartContract(),
elasticsearch: elasticsearchServiceMock.createStart(),
http: httpServiceMock.createStartContract(),
@ -177,7 +174,6 @@ function createInternalCoreSetupMock() {
httpResources: httpResourcesMock.createSetupContract(),
rendering: renderingMock.createSetupContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
auditTrail: auditTrailServiceMock.createSetupContract(),
logging: loggingServiceMock.createInternalSetupContract(),
metrics: metricsServiceMock.createInternalSetupContract(),
};
@ -192,7 +188,6 @@ function createInternalCoreStartMock() {
metrics: metricsServiceMock.createInternalStartContract(),
savedObjects: savedObjectsServiceMock.createInternalStartContract(),
uiSettings: uiSettingsServiceMock.createStartContract(),
auditTrail: auditTrailServiceMock.createStartContract(),
coreUsageData: coreUsageDataServiceMock.createStartContract(),
};
return startDeps;
@ -213,7 +208,6 @@ function createCoreRequestHandlerContextMock() {
uiSettings: {
client: uiSettingsServiceMock.createClient(),
},
auditor: auditTrailServiceMock.createAuditor(),
};
}

View file

@ -201,7 +201,6 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
register: deps.uiSettings.register,
},
getStartServices: () => plugin.startDependencies,
auditTrail: deps.auditTrail,
};
}
@ -250,7 +249,6 @@ export function createPluginStartContext<TPlugin, TPluginDependencies>(
uiSettings: {
asScopedToClient: deps.uiSettings.asScopedToClient,
},
auditTrail: deps.auditTrail,
coreUsageData: deps.coreUsageData,
};
}

View file

@ -198,38 +198,6 @@ 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)
@ -499,8 +467,6 @@ export interface CoreServicesUsageData {
// @public
export interface CoreSetup<TPluginsStart extends object = object, TStart = unknown> {
// (undocumented)
auditTrail: AuditTrailSetup;
// (undocumented)
capabilities: CapabilitiesSetup;
// (undocumented)
@ -527,8 +493,6 @@ export interface CoreSetup<TPluginsStart extends object = object, TStart = unkno
// @public
export interface CoreStart {
// (undocumented)
auditTrail: AuditTrailStart;
// (undocumented)
capabilities: CapabilitiesStart;
// @internal (undocumented)
@ -1348,7 +1312,7 @@ export interface LegacyCallAPIOptions {
// @public @deprecated
export class LegacyClusterClient implements ILegacyClusterClient {
constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuditorFactory: () => AuditorFactory, getAuthHeaders?: GetAuthHeaders);
constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders);
asScoped(request?: ScopeableRequest): ILegacyScopedClusterClient;
callAsInternalUser: LegacyAPICaller;
close(): void;
@ -1396,7 +1360,7 @@ export interface LegacyRequest extends Request {
// @public @deprecated
export class LegacyScopedClusterClient implements ILegacyScopedClusterClient {
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined, auditor?: Auditor | undefined);
constructor(internalAPICaller: LegacyAPICaller, scopedAPICaller: LegacyAPICaller, headers?: Headers | undefined);
callAsCurrentUser(endpoint: string, clientParams?: Record<string, any>, options?: LegacyCallAPIOptions): Promise<any>;
callAsInternalUser(endpoint: string, clientParams?: Record<string, any>, options?: LegacyCallAPIOptions): Promise<any>;
}
@ -1738,7 +1702,6 @@ export interface RequestHandlerContext {
uiSettings: {
client: IUiSettingsClient;
};
auditor: Auditor;
};
}

View file

@ -100,9 +100,3 @@ 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,7 +31,6 @@ import {
mockMetricsService,
mockStatusService,
mockLoggingService,
mockAuditTrailService,
} from './server.test.mocks';
import { BehaviorSubject } from 'rxjs';
@ -71,7 +70,6 @@ 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();
@ -85,7 +83,6 @@ 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 () => {
@ -126,7 +123,6 @@ 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();
@ -135,7 +131,6 @@ 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 () => {
@ -160,7 +155,6 @@ 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();
@ -173,7 +167,6 @@ 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 () => {
@ -227,7 +220,6 @@ test(`doesn't validate config if env.isDevClusterMaster is true`, async () => {
expect(mockEnsureValidConfiguration).not.toHaveBeenCalled();
expect(mockContextService.setup).toHaveBeenCalled();
expect(mockAuditTrailService.setup).toHaveBeenCalled();
expect(mockHttpService.setup).toHaveBeenCalled();
expect(mockElasticsearchService.setup).toHaveBeenCalled();
expect(mockSavedObjectsService.setup).toHaveBeenCalled();

View file

@ -21,7 +21,6 @@ import { config as pathConfig } from '@kbn/utils';
import { mapToObject } from '@kbn/std';
import { ConfigService, Env, RawConfigurationProvider, 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';
@ -72,7 +71,6 @@ export class Server {
private readonly status: StatusService;
private readonly logging: LoggingService;
private readonly coreApp: CoreApp;
private readonly auditTrail: AuditTrailService;
private readonly coreUsageData: CoreUsageDataService;
#pluginsInitialized?: boolean;
@ -103,7 +101,6 @@ 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);
this.coreUsageData = new CoreUsageDataService(core);
}
@ -139,8 +136,6 @@ export class Server {
]),
});
const auditTrailSetup = this.auditTrail.setup();
const httpSetup = await this.http.setup({
context: contextServiceSetup,
});
@ -200,7 +195,6 @@ export class Server {
uiSettings: uiSettingsSetup,
rendering: renderingSetup,
httpResources: httpResourcesSetup,
auditTrail: auditTrailSetup,
logging: loggingSetup,
metrics: metricsSetup,
};
@ -225,11 +219,7 @@ export class Server {
this.log.debug('starting server');
const startTransaction = apm.startTransaction('server_start', 'kibana_platform');
const auditTrailStart = this.auditTrail.start();
const elasticsearchStart = await this.elasticsearch.start({
auditTrail: auditTrailStart,
});
const elasticsearchStart = await this.elasticsearch.start();
const soStartSpan = startTransaction?.startSpan('saved_objects.migration', 'migration');
const savedObjectsStart = await this.savedObjects.start({
elasticsearch: elasticsearchStart,
@ -252,7 +242,6 @@ export class Server {
metrics: metricsStart,
savedObjects: savedObjectsStart,
uiSettings: uiSettingsStart,
auditTrail: auditTrailStart,
coreUsageData: coreUsageDataStart,
};
@ -285,7 +274,6 @@ export class Server {
await this.metrics.stop();
await this.status.stop();
await this.logging.stop();
await this.auditTrail.stop();
}
private registerCoreContext(coreSetup: InternalCoreSetup) {

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditLogger } from '../../../security/server';
import { LegacyAuditLogger } from '../../../security/server';
export enum AuthorizationResult {
Unauthorized = 'Unauthorized',
@ -12,9 +12,9 @@ export enum AuthorizationResult {
}
export class ActionsAuthorizationAuditLogger {
private readonly auditLogger: AuditLogger;
private readonly auditLogger: LegacyAuditLogger;
constructor(auditLogger: AuditLogger = { log() {} }) {
constructor(auditLogger: LegacyAuditLogger = { log() {} }) {
this.auditLogger = auditLogger;
}

View file

@ -20,7 +20,7 @@ import { securityMock } from '../../security/server/mocks';
import { PluginStartContract as ActionsStartContract } from '../../actions/server';
import { actionsMock, actionsAuthorizationMock } from '../../actions/server/mocks';
import { featuresPluginMock } from '../../features/server/mocks';
import { AuditLogger } from '../../security/server';
import { LegacyAuditLogger } from '../../security/server';
import { ALERTS_FEATURE_ID } from '../common';
import { eventLogMock } from '../../event_log/server/mocks';
@ -85,7 +85,7 @@ test('creates an alerts client with proper constructor arguments when security i
const logger = {
log: jest.fn(),
} as jest.Mocked<AuditLogger>;
} as jest.Mocked<LegacyAuditLogger>;
securityPluginSetup.audit.getLogger.mockReturnValue(logger);
factory.create(request, savedObjectsService);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditLogger } from '../../../security/server';
import { LegacyAuditLogger } from '../../../security/server';
export enum ScopeType {
Consumer,
@ -17,9 +17,9 @@ export enum AuthorizationResult {
}
export class AlertsAuthorizationAuditLogger {
private readonly auditLogger: AuditLogger;
private readonly auditLogger: LegacyAuditLogger;
constructor(auditLogger: AuditLogger = { log() {} }) {
constructor(auditLogger: LegacyAuditLogger = { log() {} }) {
this.auditLogger = auditLogger;
}

View file

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

View file

@ -1,65 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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({
kibanaRequestState: { requestId: 'request id alpha', requestUuid: 'ignore-me' },
}),
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('populates requestId', (done) => {
client.withAuditScope('scope_name');
event$.subscribe((event) => {
expect(event.requestId).toBe('request id alpha');
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

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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,
requestId: this.request.id,
});
}
}

View file

@ -1,56 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -1,125 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -1,97 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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;
requestId?: string;
}

View file

@ -4,14 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditLogger, AuthenticatedUser } from '../../../security/server';
import { LegacyAuditLogger, AuthenticatedUser } from '../../../security/server';
import { SavedObjectDescriptor, descriptorToArray } from '../crypto';
/**
* Represents all audit events the plugin can log.
*/
export class EncryptedSavedObjectsAuditLogger {
constructor(private readonly logger: AuditLogger = { log() {} }) {}
constructor(private readonly logger: LegacyAuditLogger = { log() {} }) {}
public encryptAttributeFailure(
attributeName: string,

View file

@ -1,3 +1,92 @@
# Kibana Security Plugin
See [Configuring security in Kibana](https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html).
See [Configuring security in
Kibana](https://www.elastic.co/guide/en/kibana/current/using-kibana-with-security.html).
## Audit logging
### Example
```typescript
const auditLogger = securitySetup.audit.asScoped(request);
auditLogger.log({
message: 'User is updating dashboard [id=123]',
event: {
action: 'saved_object_update',
category: EventCategory.DATABASE,
type: EventType.CHANGE,
outcome: EventOutcome.UNKNOWN,
},
kibana: {
saved_object: { type: 'dashboard', id: '123' },
},
});
```
### What events should be logged?
The purpose of an audit log is to support compliance, accountability and
security by capturing who performed an action, what action was performed and
when it occurred. It is not the purpose of an audit log to aid with debugging
the system or provide usage statistics.
**Kibana guidelines:**
Each API call to Kibana will result in a record in the audit log that captures
general information about the request (`http_request` event).
In addition to that, any operation that is performed on a resource owned by
Kibana (e.g. saved objects) and that falls in the following categories, should
be included in the audit log:
- System access (incl. failed attempts due to authentication errors)
- Data reads (incl. failed attempts due to authorisation errors)
- Data writes (incl. failed attempts due to authorisation errors)
If Kibana does not own the resource (e.g. when running queries against user
indices), then auditing responsibilities are deferred to Elasticsearch and no
additional events will be logged.
**Examples:**
For a list of audit events that Kibana currently logs see:
`docs/user/security/audit-logging.asciidoc`
### When should an event be logged?
Due to the asynchronous nature of most operations in Kibana, there is an
inherent tradeoff between the following logging approaches:
- Logging the **intention** before performing an operation, leading to false
positives if the operation fails downstream.
- Logging the **outcome** after completing an operation, leading to missing
records if Kibana crashes before the response is received.
- Logging **both**, intention and outcome, leading to unnecessary duplication
and noisy/difficult to analyse logs.
**Kibana guidelines:**
- **Write operations** should be logged immediately after all authorisation
checks have passed, but before the response is received (logging the
intention). This ensures that a record of every operation is persisted even in
case of an unexpected error.
- **Read operations**, on the other hand, should be logged after the operation
completed (logging the outcome) since we won't know what resources were
accessed before receiving the response.
- Be explicit about the timing and outcome of an action in your messaging. (e.g.
"User has logged in" vs. "User is creating dashboard")
### Can an action trigger multiple events?
- A request to Kibana can perform a combination of different operations, each of
which should be captured as separate events.
- Operations that are performed on multiple resources (**bulk operations**)
should be logged as separate events, one for each resource.
- Actions that kick off **background tasks** should be logged as separate
events, one for creating the task and another one for executing it.
- **Internal checks**, which have been carried out in order to perform an
operation, or **errors** that occured as a result of an operation should be
logged as an outcome of the operation itself, using the ECS `event.outcome`
and `error` fields, instead of logging a separate event.
- Multiple events that were part of the same request can be correlated in the
audit log using the ECS `trace.id` property.

View file

@ -0,0 +1,204 @@
/*
* 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 {
EventOutcome,
SavedObjectAction,
savedObjectEvent,
userLoginEvent,
httpRequestEvent,
} from './audit_events';
import { AuthenticationResult } from '../authentication';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import { httpServerMock } from 'src/core/server/mocks';
describe('#savedObjectEvent', () => {
test('creates event with `unknown` outcome', () => {
expect(
savedObjectEvent({
action: SavedObjectAction.CREATE,
outcome: EventOutcome.UNKNOWN,
savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' },
})
).toMatchInlineSnapshot(`
Object {
"error": undefined,
"event": Object {
"action": "saved_object_create",
"category": "database",
"outcome": "unknown",
"type": "creation",
},
"kibana": Object {
"add_to_spaces": undefined,
"delete_from_spaces": undefined,
"saved_object": Object {
"id": "SAVED_OBJECT_ID",
"type": "dashboard",
},
},
"message": "User is creating dashboard [id=SAVED_OBJECT_ID]",
}
`);
});
test('creates event with `success` outcome', () => {
expect(
savedObjectEvent({
action: SavedObjectAction.CREATE,
savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' },
})
).toMatchInlineSnapshot(`
Object {
"error": undefined,
"event": Object {
"action": "saved_object_create",
"category": "database",
"outcome": "success",
"type": "creation",
},
"kibana": Object {
"add_to_spaces": undefined,
"delete_from_spaces": undefined,
"saved_object": Object {
"id": "SAVED_OBJECT_ID",
"type": "dashboard",
},
},
"message": "User has created dashboard [id=SAVED_OBJECT_ID]",
}
`);
});
test('creates event with `failure` outcome', () => {
expect(
savedObjectEvent({
action: SavedObjectAction.CREATE,
savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' },
error: new Error('ERROR_MESSAGE'),
})
).toMatchInlineSnapshot(`
Object {
"error": Object {
"code": "Error",
"message": "ERROR_MESSAGE",
},
"event": Object {
"action": "saved_object_create",
"category": "database",
"outcome": "failure",
"type": "creation",
},
"kibana": Object {
"add_to_spaces": undefined,
"delete_from_spaces": undefined,
"saved_object": Object {
"id": "SAVED_OBJECT_ID",
"type": "dashboard",
},
},
"message": "Failed attempt to create dashboard [id=SAVED_OBJECT_ID]",
}
`);
});
});
describe('#userLoginEvent', () => {
test('creates event with `success` outcome', () => {
expect(
userLoginEvent({
authenticationResult: AuthenticationResult.succeeded(mockAuthenticatedUser()),
authenticationProvider: 'basic1',
authenticationType: 'basic',
})
).toMatchInlineSnapshot(`
Object {
"error": undefined,
"event": Object {
"action": "user_login",
"category": "authentication",
"outcome": "success",
},
"kibana": Object {
"authentication_provider": "basic1",
"authentication_realm": "native1",
"authentication_type": "basic",
"lookup_realm": "native1",
"space_id": undefined,
},
"message": "User [user] has logged in using basic provider [name=basic1]",
"user": Object {
"name": "user",
"roles": Array [
"user-role",
],
},
}
`);
});
test('creates event with `failure` outcome', () => {
expect(
userLoginEvent({
authenticationResult: AuthenticationResult.failed(new Error('Not Authorized')),
authenticationProvider: 'basic1',
authenticationType: 'basic',
})
).toMatchInlineSnapshot(`
Object {
"error": Object {
"code": "Error",
"message": "Not Authorized",
},
"event": Object {
"action": "user_login",
"category": "authentication",
"outcome": "failure",
},
"kibana": Object {
"authentication_provider": "basic1",
"authentication_realm": undefined,
"authentication_type": "basic",
"lookup_realm": undefined,
"space_id": undefined,
},
"message": "Failed attempt to login using basic provider [name=basic1]",
"user": undefined,
}
`);
});
});
describe('#httpRequestEvent', () => {
test('creates event with `unknown` outcome', () => {
expect(
httpRequestEvent({
request: httpServerMock.createKibanaRequest(),
})
).toMatchInlineSnapshot(`
Object {
"event": Object {
"action": "http_request",
"category": "web",
"outcome": "unknown",
},
"http": Object {
"request": Object {
"method": "get",
},
},
"message": "User is requesting [/path] endpoint",
"url": Object {
"domain": undefined,
"path": "/path",
"port": undefined,
"query": undefined,
"scheme": undefined,
},
}
`);
});
});

View file

@ -0,0 +1,244 @@
/*
* 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 { KibanaRequest } from 'src/core/server';
import { AuthenticationResult } from '../authentication/authentication_result';
/**
* Audit event schema using ECS format.
* https://www.elastic.co/guide/en/ecs/1.5/index.html
* @public
*/
export interface AuditEvent {
/**
* Human readable message describing action, outcome and user.
*
* @example
* User [jdoe] logged in using basic provider [name=basic1]
*/
message: string;
event: {
action: string;
category?: EventCategory;
type?: EventType;
outcome?: EventOutcome;
module?: string;
dataset?: string;
};
user?: {
name: string;
email?: string;
full_name?: string;
hash?: string;
roles?: readonly string[];
};
kibana?: {
/**
* Current space id of the request.
*/
space_id?: string;
/**
* Saved object that was created, changed, deleted or accessed as part of the action.
*/
saved_object?: {
type: string;
id?: string;
};
/**
* Any additional event specific fields.
*/
[x: string]: any;
};
error?: {
code?: string;
message?: string;
};
http?: {
request?: {
method?: string;
body?: {
content: string;
};
};
response?: {
status_code?: number;
};
};
url?: {
domain?: string;
full?: string;
path?: string;
port?: number;
query?: string;
scheme?: string;
};
}
export enum EventCategory {
DATABASE = 'database',
WEB = 'web',
IAM = 'iam',
AUTHENTICATION = 'authentication',
PROCESS = 'process',
}
export enum EventType {
USER = 'user',
GROUP = 'group',
CREATION = 'creation',
ACCESS = 'access',
CHANGE = 'change',
DELETION = 'deletion',
}
export enum EventOutcome {
SUCCESS = 'success',
FAILURE = 'failure',
UNKNOWN = 'unknown',
}
export interface HttpRequestParams {
request: KibanaRequest;
}
export function httpRequestEvent({ request }: HttpRequestParams): AuditEvent {
const { pathname, search } = request.url;
return {
message: `User is requesting [${pathname}] endpoint`,
event: {
action: 'http_request',
category: EventCategory.WEB,
outcome: EventOutcome.UNKNOWN,
},
http: {
request: {
method: request.route.method,
},
},
url: {
domain: request.url.hostname,
path: pathname,
port: request.url.port ? parseInt(request.url.port, 10) : undefined,
query: search?.slice(1) || undefined,
scheme: request.url.protocol,
},
};
}
export interface UserLoginParams {
authenticationResult: AuthenticationResult;
authenticationProvider?: string;
authenticationType?: string;
}
export function userLoginEvent({
authenticationResult,
authenticationProvider,
authenticationType,
}: UserLoginParams): AuditEvent {
return {
message: authenticationResult.user
? `User [${authenticationResult.user.username}] has logged in using ${authenticationType} provider [name=${authenticationProvider}]`
: `Failed attempt to login using ${authenticationType} provider [name=${authenticationProvider}]`,
event: {
action: 'user_login',
category: EventCategory.AUTHENTICATION,
outcome: authenticationResult.user ? EventOutcome.SUCCESS : EventOutcome.FAILURE,
},
user: authenticationResult.user && {
name: authenticationResult.user.username,
roles: authenticationResult.user.roles,
},
kibana: {
space_id: undefined, // Ensure this does not get populated by audit service
authentication_provider: authenticationProvider,
authentication_type: authenticationType,
authentication_realm: authenticationResult.user?.authentication_realm.name,
lookup_realm: authenticationResult.user?.lookup_realm.name,
},
error: authenticationResult.error && {
code: authenticationResult.error.name,
message: authenticationResult.error.message,
},
};
}
export enum SavedObjectAction {
CREATE = 'saved_object_create',
GET = 'saved_object_get',
UPDATE = 'saved_object_update',
DELETE = 'saved_object_delete',
FIND = 'saved_object_find',
ADD_TO_SPACES = 'saved_object_add_to_spaces',
DELETE_FROM_SPACES = 'saved_object_delete_from_spaces',
}
const eventVerbs = {
saved_object_create: ['create', 'creating', 'created'],
saved_object_get: ['access', 'accessing', 'accessed'],
saved_object_update: ['update', 'updating', 'updated'],
saved_object_delete: ['delete', 'deleting', 'deleted'],
saved_object_find: ['access', 'accessing', 'accessed'],
saved_object_add_to_spaces: ['update', 'updating', 'updated'],
saved_object_delete_from_spaces: ['update', 'updating', 'updated'],
};
const eventTypes = {
saved_object_create: EventType.CREATION,
saved_object_get: EventType.ACCESS,
saved_object_update: EventType.CHANGE,
saved_object_delete: EventType.DELETION,
saved_object_find: EventType.ACCESS,
saved_object_add_to_spaces: EventType.CHANGE,
saved_object_delete_from_spaces: EventType.CHANGE,
};
export interface SavedObjectParams {
action: SavedObjectAction;
outcome?: EventOutcome;
savedObject?: Required<Required<AuditEvent>['kibana']>['saved_object'];
addToSpaces?: readonly string[];
deleteFromSpaces?: readonly string[];
error?: Error;
}
export function savedObjectEvent({
action,
savedObject,
addToSpaces,
deleteFromSpaces,
outcome,
error,
}: SavedObjectParams): AuditEvent {
const doc = savedObject ? `${savedObject.type} [id=${savedObject.id}]` : 'saved objects';
const [present, progressive, past] = eventVerbs[action];
const message = error
? `Failed attempt to ${present} ${doc}`
: outcome === 'unknown'
? `User is ${progressive} ${doc}`
: `User has ${past} ${doc}`;
const type = eventTypes[action];
return {
message,
event: {
action,
category: EventCategory.DATABASE,
type,
outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS),
},
kibana: {
saved_object: savedObject,
add_to_spaces: addToSpaces,
delete_from_spaces: deleteFromSpaces,
},
error: error && {
code: error.name,
message: error.message,
},
};
}

View file

@ -3,163 +3,506 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditService } from './audit_service';
import { loggingSystemMock } from 'src/core/server/mocks';
import { AuditService, filterEvent, createLoggingConfig } from './audit_service';
import { AuditEvent, EventCategory, EventType, EventOutcome } from './audit_events';
import {
coreMock,
loggingSystemMock,
httpServiceMock,
httpServerMock,
} from 'src/core/server/mocks';
import { licenseMock } from '../../common/licensing/index.mock';
import { ConfigSchema, ConfigType } from '../config';
import { SecurityLicenseFeatures } from '../../common/licensing';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, Observable, of } from 'rxjs';
const createConfig = (settings: Partial<ConfigType['audit']>) => {
return ConfigSchema.validate(settings);
};
const config = createConfig({
enabled: true,
const logger = loggingSystemMock.createLogger();
const license = licenseMock.create();
const config = createConfig({ enabled: true });
const { logging } = coreMock.createSetup();
const http = httpServiceMock.createSetupContract();
const getCurrentUser = jest.fn().mockReturnValue({ username: 'jdoe', roles: ['admin'] });
const getSpaceId = jest.fn().mockReturnValue('default');
beforeEach(() => {
logger.info.mockClear();
logging.configure.mockClear();
http.registerOnPostAuth.mockClear();
});
describe('#setup', () => {
it('returns the expected contract', () => {
const logger = loggingSystemMock.createLogger();
const auditService = new AuditService(logger);
const license = licenseMock.create();
expect(auditService.setup({ license, config })).toMatchInlineSnapshot(`
expect(
auditService.setup({
license,
config,
logging,
http,
getCurrentUser,
getSpaceId,
})
).toMatchInlineSnapshot(`
Object {
"asScoped": [Function],
"getLogger": [Function],
}
`);
});
});
test(`calls the underlying logger with the provided message and requisite tags`, () => {
const pluginId = 'foo';
it('configures logging correctly when using ecs logger', async () => {
new AuditService(logger).setup({
license,
config: {
enabled: true,
appender: {
kind: 'console',
layout: {
kind: 'pattern',
},
},
},
logging,
http,
getCurrentUser,
getSpaceId,
});
expect(logging.configure).toHaveBeenCalledWith(expect.any(Observable));
});
const logger = loggingSystemMock.createLogger();
const license = licenseMock.create();
license.features$ = new BehaviorSubject({
allowAuditLogging: true,
} as SecurityLicenseFeatures).asObservable();
it('does not configure logging when using legacy logger', async () => {
new AuditService(logger).setup({
license,
config: {
enabled: true,
},
logging,
http,
getCurrentUser,
getSpaceId,
});
expect(logging.configure).not.toHaveBeenCalled();
});
const auditService = new AuditService(logger).setup({ license, config });
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(message, {
eventType,
tags: [pluginId, eventType],
it('registers post auth hook', () => {
new AuditService(logger).setup({
license,
config,
logging,
http,
getCurrentUser,
getSpaceId,
});
expect(http.registerOnPostAuth).toHaveBeenCalledWith(expect.any(Function));
});
});
test(`calls the underlying logger with the provided metadata`, () => {
const pluginId = 'foo';
describe('#asScoped', () => {
it('logs event enriched with meta data', async () => {
const audit = new AuditService(logger).setup({
license,
config,
logging,
http,
getCurrentUser,
getSpaceId,
});
const request = httpServerMock.createKibanaRequest({
kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' },
});
const logger = loggingSystemMock.createLogger();
const license = licenseMock.create();
license.features$ = new BehaviorSubject({
allowAuditLogging: true,
} as SecurityLicenseFeatures).asObservable();
const auditService = new AuditService(logger).setup({ license, config });
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
const metadata = Object.freeze({
property1: 'value1',
property2: false,
property3: 123,
audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } });
expect(logger.info).toHaveBeenCalledWith('MESSAGE', {
event: { action: 'ACTION' },
kibana: { space_id: 'default' },
message: 'MESSAGE',
trace: { id: 'REQUEST_ID' },
user: { name: 'jdoe', roles: ['admin'] },
});
});
auditLogger.log(eventType, message, metadata);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(message, {
eventType,
tags: [pluginId, eventType],
property1: 'value1',
property2: false,
property3: 123,
it('does not log to audit logger if event matches ignore filter', async () => {
const audit = new AuditService(logger).setup({
license,
config: {
enabled: true,
ignore_filters: [{ actions: ['ACTION'] }],
},
logging,
http,
getCurrentUser,
getSpaceId,
});
const request = httpServerMock.createKibanaRequest({
kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' },
});
audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } });
expect(logger.info).not.toHaveBeenCalled();
});
});
test(`does not call the underlying logger if license does not support audit logging`, () => {
const pluginId = 'foo';
describe('#createLoggingConfig', () => {
test('sets log level to `info` when audit logging is enabled and appender is defined', async () => {
const features$ = of({
allowAuditLogging: true,
});
const logger = loggingSystemMock.createLogger();
const license = licenseMock.create();
license.features$ = new BehaviorSubject({
allowAuditLogging: false,
} as SecurityLicenseFeatures).asObservable();
const loggingConfig = await features$
.pipe(
createLoggingConfig({
enabled: true,
appender: {
kind: 'console',
layout: {
kind: 'pattern',
},
},
})
)
.toPromise();
const auditService = new AuditService(logger).setup({ license, config });
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).not.toHaveBeenCalled();
});
test(`does not call the underlying logger if security audit logging is not enabled`, () => {
const pluginId = 'foo';
const logger = loggingSystemMock.createLogger();
const license = licenseMock.create();
license.features$ = new BehaviorSubject({
allowAuditLogging: true,
} as SecurityLicenseFeatures).asObservable();
const auditService = new AuditService(logger).setup({
license,
config: createConfig({
enabled: false,
}),
expect(loggingConfig).toMatchInlineSnapshot(`
Object {
"appenders": Object {
"auditTrailAppender": Object {
"kind": "console",
"layout": Object {
"kind": "pattern",
},
},
},
"loggers": Array [
Object {
"appenders": Array [
"auditTrailAppender",
],
"context": "audit",
"level": "info",
},
],
}
`);
});
const auditLogger = auditService.getLogger(pluginId);
test('sets log level to `off` when audit logging is disabled', async () => {
const features$ = of({
allowAuditLogging: true,
});
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
const loggingConfig = await features$
.pipe(
createLoggingConfig({
enabled: false,
appender: {
kind: 'console',
layout: {
kind: 'pattern',
},
},
})
)
.toPromise();
expect(logger.info).not.toHaveBeenCalled();
expect(loggingConfig.loggers![0].level).toEqual('off');
});
test('sets log level to `off` when appender is not defined', async () => {
const features$ = of({
allowAuditLogging: true,
});
const loggingConfig = await features$
.pipe(
createLoggingConfig({
enabled: true,
})
)
.toPromise();
expect(loggingConfig.loggers![0].level).toEqual('off');
});
test('sets log level to `off` when license does not allow audit logging', async () => {
const features$ = of({
allowAuditLogging: false,
});
const loggingConfig = await features$
.pipe(
createLoggingConfig({
enabled: true,
appender: {
kind: 'console',
layout: {
kind: 'pattern',
},
},
})
)
.toPromise();
expect(loggingConfig.loggers![0].level).toEqual('off');
});
});
test(`calls the underlying logger after license upgrade`, () => {
const pluginId = 'foo';
describe('#filterEvent', () => {
const event: AuditEvent = {
message: 'this is my audit message',
event: {
action: 'http_request',
category: EventCategory.WEB,
type: EventType.ACCESS,
outcome: EventOutcome.SUCCESS,
},
user: {
name: 'jdoe',
},
kibana: {
space_id: 'default',
},
};
const logger = loggingSystemMock.createLogger();
const license = licenseMock.create();
test('keeps event when ignore filters are undefined or empty', () => {
expect(filterEvent(event, undefined)).toBeTruthy();
expect(filterEvent(event, [])).toBeTruthy();
});
const features$ = new BehaviorSubject({
allowAuditLogging: false,
} as SecurityLicenseFeatures);
test('filters event correctly when a single match is found per criteria', () => {
expect(filterEvent(event, [{ actions: ['NO_MATCH'] }])).toBeTruthy();
expect(filterEvent(event, [{ actions: ['NO_MATCH', 'http_request'] }])).toBeFalsy();
expect(filterEvent(event, [{ categories: ['NO_MATCH', 'web'] }])).toBeFalsy();
expect(filterEvent(event, [{ types: ['NO_MATCH', 'access'] }])).toBeFalsy();
expect(filterEvent(event, [{ outcomes: ['NO_MATCH', 'success'] }])).toBeFalsy();
expect(filterEvent(event, [{ spaces: ['NO_MATCH', 'default'] }])).toBeFalsy();
});
license.features$ = features$.asObservable();
test('keeps event when one criteria per rule does not match', () => {
expect(
filterEvent(event, [
{
actions: ['NO_MATCH'],
categories: ['web'],
types: ['access'],
outcomes: ['success'],
spaces: ['default'],
},
{
actions: ['http_request'],
categories: ['NO_MATCH'],
types: ['access'],
outcomes: ['success'],
spaces: ['default'],
},
{
actions: ['http_request'],
categories: ['web'],
types: ['NO_MATCH'],
outcomes: ['success'],
spaces: ['default'],
},
{
actions: ['http_request'],
categories: ['web'],
types: ['access'],
outcomes: ['NO_MATCH'],
spaces: ['default'],
},
{
actions: ['http_request'],
categories: ['web'],
types: ['access'],
outcomes: ['success'],
spaces: ['NO_MATCH'],
},
])
).toBeTruthy();
});
const auditService = new AuditService(logger).setup({ license, config });
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).not.toHaveBeenCalled();
// perform license upgrade
features$.next({
allowAuditLogging: true,
} as SecurityLicenseFeatures);
auditLogger.log(eventType, message);
expect(logger.info).toHaveBeenCalledTimes(1);
test('filters out event when all criteria in a single rule match', () => {
expect(
filterEvent(event, [
{
actions: ['NO_MATCH'],
categories: ['NO_MATCH'],
types: ['NO_MATCH'],
outcomes: ['NO_MATCH'],
spaces: ['NO_MATCH'],
},
{
actions: ['http_request'],
categories: ['web'],
types: ['access'],
outcomes: ['success'],
spaces: ['default'],
},
])
).toBeFalsy();
});
});
describe('#getLogger', () => {
test('calls the underlying logger with the provided message and requisite tags', () => {
const pluginId = 'foo';
const licenseWithFeatures = licenseMock.create();
licenseWithFeatures.features$ = new BehaviorSubject({
allowAuditLogging: true,
} as SecurityLicenseFeatures).asObservable();
const auditService = new AuditService(logger).setup({
license: licenseWithFeatures,
config,
logging,
http,
getCurrentUser,
getSpaceId,
});
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(message, {
eventType,
tags: [pluginId, eventType],
});
});
test('calls the underlying logger with the provided metadata', () => {
const pluginId = 'foo';
const licenseWithFeatures = licenseMock.create();
licenseWithFeatures.features$ = new BehaviorSubject({
allowAuditLogging: true,
} as SecurityLicenseFeatures).asObservable();
const auditService = new AuditService(logger).setup({
license: licenseWithFeatures,
config,
logging,
http,
getCurrentUser,
getSpaceId,
});
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
const metadata = Object.freeze({
property1: 'value1',
property2: false,
property3: 123,
});
auditLogger.log(eventType, message, metadata);
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(message, {
eventType,
tags: [pluginId, eventType],
property1: 'value1',
property2: false,
property3: 123,
});
});
test('does not call the underlying logger if license does not support audit logging', () => {
const pluginId = 'foo';
const licenseWithFeatures = licenseMock.create();
licenseWithFeatures.features$ = new BehaviorSubject({
allowAuditLogging: false,
} as SecurityLicenseFeatures).asObservable();
const auditService = new AuditService(logger).setup({
license: licenseWithFeatures,
config,
logging,
http,
getCurrentUser,
getSpaceId,
});
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).not.toHaveBeenCalled();
});
test('does not call the underlying logger if security audit logging is not enabled', () => {
const pluginId = 'foo';
const licenseWithFeatures = licenseMock.create();
licenseWithFeatures.features$ = new BehaviorSubject({
allowAuditLogging: true,
} as SecurityLicenseFeatures).asObservable();
const auditService = new AuditService(logger).setup({
license: licenseWithFeatures,
config: createConfig({
enabled: false,
}),
logging,
http,
getCurrentUser,
getSpaceId,
});
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).not.toHaveBeenCalled();
});
test('calls the underlying logger after license upgrade', () => {
const pluginId = 'foo';
const licenseWithFeatures = licenseMock.create();
const features$ = new BehaviorSubject({
allowAuditLogging: false,
} as SecurityLicenseFeatures);
licenseWithFeatures.features$ = features$.asObservable();
const auditService = new AuditService(logger).setup({
license: licenseWithFeatures,
config,
logging,
http,
getCurrentUser,
getSpaceId,
});
const auditLogger = auditService.getLogger(pluginId);
const eventType = 'bar';
const message = 'this is my audit message';
auditLogger.log(eventType, message);
expect(logger.info).not.toHaveBeenCalled();
// perform license upgrade
features$.next({
allowAuditLogging: true,
} as SecurityLicenseFeatures);
auditLogger.log(eventType, message);
expect(logger.info).toHaveBeenCalledTimes(1);
});
});

View file

@ -5,53 +5,181 @@
*/
import { Subscription } from 'rxjs';
import { Logger } from '../../../../../src/core/server';
import { SecurityLicense } from '../../common/licensing';
import { map, distinctUntilKeyChanged } from 'rxjs/operators';
import {
Logger,
LoggingServiceSetup,
KibanaRequest,
HttpServiceSetup,
LoggerContextConfigInput,
} from '../../../../../src/core/server';
import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing';
import { ConfigType } from '../config';
import { SpacesPluginSetup } from '../../../spaces/server';
import { AuditEvent, httpRequestEvent } from './audit_events';
import { SecurityPluginSetup } from '..';
export interface AuditLogger {
/**
* @deprecated
*/
export interface LegacyAuditLogger {
log: (eventType: string, message: string, data?: Record<string, any>) => void;
}
export interface AuditLogger {
log: (event: AuditEvent) => void;
}
interface AuditLogMeta extends AuditEvent {
session?: {
id: string;
};
trace: {
id: string;
};
}
export interface AuditServiceSetup {
getLogger: (id?: string) => AuditLogger;
asScoped: (request: KibanaRequest) => AuditLogger;
getLogger: (id?: string) => LegacyAuditLogger;
}
interface AuditServiceSetupParams {
license: SecurityLicense;
config: ConfigType['audit'];
logging: Pick<LoggingServiceSetup, 'configure'>;
http: Pick<HttpServiceSetup, 'registerOnPostAuth'>;
getCurrentUser(
request: KibanaRequest
): ReturnType<SecurityPluginSetup['authc']['getCurrentUser']> | undefined;
getSpaceId(
request: KibanaRequest
): ReturnType<SpacesPluginSetup['spacesService']['getSpaceId']> | undefined;
}
export class AuditService {
/**
* @deprecated
*/
private licenseFeaturesSubscription?: Subscription;
private auditLoggingEnabled = false;
/**
* @deprecated
*/
private allowAuditLogging = false;
constructor(private readonly logger: Logger) {}
setup({ license, config }: AuditServiceSetupParams): AuditServiceSetup {
if (config.enabled) {
setup({
license,
config,
logging,
http,
getCurrentUser,
getSpaceId,
}: AuditServiceSetupParams): AuditServiceSetup {
if (config.enabled && !config.appender) {
this.licenseFeaturesSubscription = license.features$.subscribe(({ allowAuditLogging }) => {
this.auditLoggingEnabled = allowAuditLogging;
this.allowAuditLogging = allowAuditLogging;
});
}
return {
getLogger: (id?: string): AuditLogger => {
return {
log: (eventType: string, message: string, data?: Record<string, any>) => {
if (!this.auditLoggingEnabled) {
return;
}
// Do not change logging for legacy logger
if (config.appender) {
// Configure logging during setup and when license changes
logging.configure(
license.features$.pipe(
distinctUntilKeyChanged('allowAuditLogging'),
createLoggingConfig(config)
)
);
}
this.logger.info(message, {
tags: id ? [id, eventType] : [eventType],
eventType,
...data,
});
/**
* Creates an {@link AuditLogger} scoped to the current request.
*
* @example
* ```typescript
* const auditLogger = securitySetup.audit.asScoped(request);
* auditLogger.log(event);
* ```
*/
const asScoped = (request: KibanaRequest): AuditLogger => {
/**
* Logs an {@link AuditEvent} and automatically adds meta data about the
* current user, space and correlation id.
*
* Guidelines around what events should be logged and how they should be
* structured can be found in: `/x-pack/plugins/security/README.md`
*
* @example
* ```typescript
* const auditLogger = securitySetup.audit.asScoped(request);
* auditLogger.log({
* message: 'User is updating dashboard [id=123]',
* event: {
* action: 'saved_object_update',
* outcome: 'unknown'
* },
* kibana: {
* saved_object: { type: 'dashboard', id: '123' }
* },
* });
* ```
*/
const log = (event: AuditEvent) => {
const user = getCurrentUser(request);
const spaceId = getSpaceId(request);
const meta: AuditLogMeta = {
...event,
user:
(user && {
name: user.username,
roles: user.roles,
}) ||
event.user,
kibana: {
space_id: spaceId,
...event.kibana,
},
trace: {
id: request.id,
},
};
},
if (filterEvent(meta, config.ignore_filters)) {
this.logger.info(event.message!, meta);
}
};
return { log };
};
/**
* @deprecated
* Use `audit.asScoped(request)` method instead to create an audit logger
*/
const getLogger = (id?: string): LegacyAuditLogger => {
return {
log: (eventType: string, message: string, data?: Record<string, any>) => {
if (!this.allowAuditLogging) {
return;
}
this.logger.info(message, {
tags: id ? [id, eventType] : [eventType],
eventType,
...data,
});
},
};
};
http.registerOnPostAuth((request, response, t) => {
if (request.auth.isAuthenticated) {
asScoped(request).log(httpRequestEvent({ request }));
}
return t.next();
});
return { asScoped, getLogger };
}
stop() {
@ -61,3 +189,40 @@ export class AuditService {
}
}
}
export const createLoggingConfig = (config: ConfigType['audit']) =>
map<Pick<SecurityLicenseFeatures, 'allowAuditLogging'>, LoggerContextConfigInput>((features) => ({
appenders: {
auditTrailAppender: config.appender ?? {
kind: 'console',
layout: {
kind: 'pattern',
highlight: true,
},
},
},
loggers: [
{
context: 'audit',
level: config.enabled && config.appender && features.allowAuditLogging ? 'info' : 'off',
appenders: ['auditTrailAppender'],
},
],
}));
export function filterEvent(
event: AuditEvent,
ignoreFilters: ConfigType['audit']['ignore_filters']
) {
if (ignoreFilters) {
return !ignoreFilters.some(
(rule) =>
(!rule.actions || rule.actions.includes(event.event.action)) &&
(!rule.categories || rule.categories.includes(event.event.category!)) &&
(!rule.types || rule.types.includes(event.event.type!)) &&
(!rule.outcomes || rule.outcomes.includes(event.event.outcome!)) &&
(!rule.spaces || rule.spaces.includes(event.kibana?.space_id!))
);
}
return true;
}

View file

@ -21,6 +21,9 @@ export const auditServiceMock = {
create() {
return {
getLogger: jest.fn(),
asScoped: jest.fn().mockReturnValue({
log: jest.fn(),
}),
} as jest.Mocked<ReturnType<AuditService['setup']>>;
},
};

View file

@ -4,5 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { AuditService, AuditServiceSetup, AuditLogger } from './audit_service';
export { AuditService, AuditServiceSetup, AuditLogger, LegacyAuditLogger } from './audit_service';
export {
AuditEvent,
EventCategory,
EventType,
EventOutcome,
userLoginEvent,
httpRequestEvent,
savedObjectEvent,
SavedObjectAction,
} from './audit_events';
export { SecurityAuditLogger } from './security_audit_logger';

View file

@ -5,11 +5,17 @@
*/
import { AuthenticationProvider } from '../../common/types';
import { AuditLogger } from './audit_service';
import { LegacyAuditLogger } from './audit_service';
/**
* @deprecated
*/
export class SecurityAuditLogger {
constructor(private readonly logger: AuditLogger) {}
constructor(private readonly logger: LegacyAuditLogger) {}
/**
* @deprecated
*/
savedObjectsAuthorizationFailure(
username: string,
action: string,
@ -37,6 +43,9 @@ export class SecurityAuditLogger {
);
}
/**
* @deprecated
*/
savedObjectsAuthorizationSuccess(
username: string,
action: string,
@ -59,6 +68,9 @@ export class SecurityAuditLogger {
);
}
/**
* @deprecated
*/
accessAgreementAcknowledged(username: string, provider: AuthenticationProvider) {
this.logger.log(
'access_agreement_acknowledged',

View file

@ -19,7 +19,7 @@ import {
} from '../../../../../src/core/server/mocks';
import { licenseMock } from '../../common/licensing/index.mock';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import { securityAuditLoggerMock } from '../audit/index.mock';
import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock';
import { sessionMock } from '../session_management/index.mock';
import { SecurityLicenseFeatures } from '../../common/licensing';
import { ConfigSchema, createConfig } from '../config';
@ -40,7 +40,8 @@ function getMockOptions({
selector?: AuthenticatorOptions['config']['authc']['selector'];
} = {}) {
return {
auditLogger: securityAuditLoggerMock.create(),
legacyAuditLogger: securityAuditLoggerMock.create(),
audit: auditServiceMock.create(),
getCurrentUser: jest.fn(),
clusterClient: elasticsearchServiceMock.createLegacyClusterClient(),
basePath: httpServiceMock.createSetupContract().basePath,
@ -215,9 +216,15 @@ describe('Authenticator', () => {
let authenticator: Authenticator;
let mockOptions: ReturnType<typeof getMockOptions>;
let mockSessVal: SessionValue;
const auditLogger = {
log: jest.fn(),
};
beforeEach(() => {
auditLogger.log.mockClear();
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
mockOptions.session.get.mockResolvedValue(null);
mockOptions.audit.asScoped.mockReturnValue(auditLogger);
mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } });
authenticator = new Authenticator(mockOptions);
@ -280,6 +287,49 @@ describe('Authenticator', () => {
);
});
it('adds audit event when successful.', async () => {
const request = httpServerMock.createKibanaRequest();
const user = mockAuthenticatedUser();
mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } })
);
await authenticator.login(request, { provider: { type: 'basic' }, value: {} });
expect(auditLogger.log).toHaveBeenCalledTimes(1);
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: { action: 'user_login', category: 'authentication', outcome: 'success' },
})
);
});
it('adds audit event when not successful.', async () => {
const request = httpServerMock.createKibanaRequest();
const failureReason = new Error('Not Authorized');
mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.failed(failureReason)
);
await authenticator.login(request, { provider: { type: 'basic' }, value: {} });
expect(auditLogger.log).toHaveBeenCalledTimes(1);
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: { action: 'user_login', category: 'authentication', outcome: 'failure' },
})
);
});
it('does not add audit event when not handled.', async () => {
const request = httpServerMock.createKibanaRequest();
await expect(
authenticator.login(request, { provider: { type: 'token' }, value: {} })
).resolves.toEqual(AuthenticationResult.notHandled());
await authenticator.login(request, { provider: { name: 'basic2' }, value: {} });
expect(auditLogger.log).not.toHaveBeenCalled();
});
it('creates session whenever authentication provider returns state', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
@ -1859,11 +1909,14 @@ describe('Authenticator', () => {
accessAgreementAcknowledged: true,
});
expect(mockOptions.auditLogger.accessAgreementAcknowledged).toHaveBeenCalledTimes(1);
expect(mockOptions.auditLogger.accessAgreementAcknowledged).toHaveBeenCalledWith('user', {
type: 'basic',
name: 'basic1',
});
expect(mockOptions.legacyAuditLogger.accessAgreementAcknowledged).toHaveBeenCalledTimes(1);
expect(mockOptions.legacyAuditLogger.accessAgreementAcknowledged).toHaveBeenCalledWith(
'user',
{
type: 'basic',
name: 'basic1',
}
);
expect(
mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage

View file

@ -13,7 +13,7 @@ import {
import { SecurityLicense } from '../../common/licensing';
import { AuthenticatedUser } from '../../common/model';
import { AuthenticationProvider } from '../../common/types';
import { SecurityAuditLogger } from '../audit';
import { SecurityAuditLogger, AuditServiceSetup, userLoginEvent } from '../audit';
import { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
@ -59,7 +59,8 @@ export interface ProviderLoginAttempt {
}
export interface AuthenticatorOptions {
auditLogger: SecurityAuditLogger;
legacyAuditLogger: SecurityAuditLogger;
audit: AuditServiceSetup;
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null;
config: Pick<ConfigType, 'authc'>;
@ -293,6 +294,20 @@ export class Authenticator {
existingSessionValue,
});
// Checking for presence of `user` object to determine success state rather than
// `success()` method since that indicates a successful authentication and `redirect()`
// could also (but does not always) authenticate a user successfully (e.g. SAML flow)
if (authenticationResult.user || authenticationResult.failed()) {
const auditLogger = this.options.audit.asScoped(request);
auditLogger.log(
userLoginEvent({
authenticationResult,
authenticationProvider: providerName,
authenticationType: provider.type,
})
);
}
return this.handlePreAccessRedirects(
request,
authenticationResult,
@ -421,7 +436,7 @@ export class Authenticator {
accessAgreementAcknowledged: true,
});
this.options.auditLogger.accessAgreementAcknowledged(
this.options.legacyAuditLogger.accessAgreementAcknowledged(
currentUser.username,
existingSessionValue.provider
);

View file

@ -18,7 +18,7 @@ import {
} from '../../../../../src/core/server/mocks';
import { licenseMock } from '../../common/licensing/index.mock';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import { securityAuditLoggerMock } from '../audit/index.mock';
import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock';
import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock';
import { sessionMock } from '../session_management/session.mock';
@ -42,13 +42,14 @@ import {
InvalidateAPIKeyParams,
} from './api_keys';
import { SecurityLicense } from '../../common/licensing';
import { SecurityAuditLogger } from '../audit';
import { AuditServiceSetup, SecurityAuditLogger } from '../audit';
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
import { Session } from '../session_management';
describe('setupAuthentication()', () => {
let mockSetupAuthenticationParams: {
auditLogger: jest.Mocked<SecurityAuditLogger>;
legacyAuditLogger: jest.Mocked<SecurityAuditLogger>;
audit: jest.Mocked<AuditServiceSetup>;
config: ConfigType;
loggers: LoggerFactory;
http: jest.Mocked<HttpServiceSetup>;
@ -60,7 +61,8 @@ describe('setupAuthentication()', () => {
let mockScopedClusterClient: jest.Mocked<PublicMethodsOf<LegacyScopedClusterClient>>;
beforeEach(() => {
mockSetupAuthenticationParams = {
auditLogger: securityAuditLoggerMock.create(),
legacyAuditLogger: securityAuditLoggerMock.create(),
audit: auditServiceMock.create(),
http: coreMock.createSetup().http,
config: createConfig(
ConfigSchema.validate({

View file

@ -12,7 +12,7 @@ import {
} from '../../../../../src/core/server';
import { SecurityLicense } from '../../common/licensing';
import { AuthenticatedUser } from '../../common/model';
import { SecurityAuditLogger } from '../audit';
import { SecurityAuditLogger, AuditServiceSetup } from '../audit';
import { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import { SecurityFeatureUsageServiceStart } from '../feature_usage';
@ -45,7 +45,8 @@ export {
} from './http_authentication';
interface SetupAuthenticationParams {
auditLogger: SecurityAuditLogger;
legacyAuditLogger: SecurityAuditLogger;
audit: AuditServiceSetup;
getFeatureUsageService: () => SecurityFeatureUsageServiceStart;
http: HttpServiceSetup;
clusterClient: ILegacyClusterClient;
@ -58,7 +59,8 @@ interface SetupAuthenticationParams {
export type Authentication = UnwrapPromise<ReturnType<typeof setupAuthentication>>;
export async function setupAuthentication({
auditLogger,
legacyAuditLogger: auditLogger,
audit,
getFeatureUsageService,
http,
clusterClient,
@ -82,7 +84,8 @@ export async function setupAuthentication({
};
const authenticator = new Authenticator({
auditLogger,
legacyAuditLogger: auditLogger,
audit,
loggers,
clusterClient,
basePath: http.basePath,

View file

@ -4,7 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('crypto', () => ({ randomBytes: jest.fn() }));
jest.mock('crypto', () => ({
randomBytes: jest.fn(),
constants: jest.requireActual('crypto').constants,
}));
import { loggingSystemMock } from '../../../../src/core/server/mocks';
import { createConfig, ConfigSchema } from './config';
@ -150,31 +153,23 @@ describe('config schema', () => {
});
it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => {
expect(() =>
ConfigSchema.validate({ encryptionKey: 'foo' })
).toThrowErrorMatchingInlineSnapshot(
`"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."`
expect(() => ConfigSchema.validate({ encryptionKey: 'foo' })).toThrow(
'[encryptionKey]: value has length [3] but it must have a minimum length of [32].'
);
expect(() =>
ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true })
).toThrowErrorMatchingInlineSnapshot(
`"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."`
expect(() => ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true })).toThrow(
'[encryptionKey]: value has length [3] but it must have a minimum length of [32].'
);
});
describe('authc.oidc', () => {
it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['oidc'] } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.oidc.realm]: expected value of type [string] but got [undefined]"`
expect(() => ConfigSchema.validate({ authc: { providers: ['oidc'] } })).toThrow(
'[authc.oidc.realm]: expected value of type [string] but got [undefined]'
);
expect(() =>
ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.oidc.realm]: expected value of type [string] but got [undefined]"`
expect(() => ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } })).toThrow(
'[authc.oidc.realm]: expected value of type [string] but got [undefined]'
);
});
@ -204,10 +199,8 @@ describe('config schema', () => {
});
it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.oidc.realm]: expected value of type [string] but got [undefined]"`
expect(() => ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } })).toThrow(
'[authc.oidc.realm]: expected value of type [string] but got [undefined]'
);
});
@ -240,22 +233,18 @@ describe('config schema', () => {
it(`realm is not allowed when authc.providers is "['basic']"`, async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['basic'], oidc: { realm: 'realm-1' } } })
).toThrowErrorMatchingInlineSnapshot(`"[authc.oidc]: a value wasn't expected to be present"`);
).toThrow("[authc.oidc]: a value wasn't expected to be present");
});
});
describe('authc.saml', () => {
it('fails if authc.providers includes `saml`, but `saml.realm` is not specified', async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['saml'] } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.saml.realm]: expected value of type [string] but got [undefined]"`
expect(() => ConfigSchema.validate({ authc: { providers: ['saml'] } })).toThrow(
'[authc.saml.realm]: expected value of type [string] but got [undefined]'
);
expect(() =>
ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.saml.realm]: expected value of type [string] but got [undefined]"`
expect(() => ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } })).toThrow(
'[authc.saml.realm]: expected value of type [string] but got [undefined]'
);
expect(
@ -285,7 +274,7 @@ describe('config schema', () => {
it('`realm` is not allowed if saml provider is not enabled', async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['basic'], saml: { realm: 'realm-1' } } })
).toThrowErrorMatchingInlineSnapshot(`"[authc.saml]: a value wasn't expected to be present"`);
).toThrow("[authc.saml]: a value wasn't expected to be present");
});
it('`maxRedirectURLSize` accepts any positive value that can coerce to `ByteSizeValue`', async () => {
@ -360,11 +349,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { basic: { basic1: { enabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]'
);
});
it('cannot be hidden from selector', () => {
@ -374,11 +361,9 @@ describe('config schema', () => {
providers: { basic: { basic1: { order: 0, showInSelector: false } } },
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`."
`);
).toThrow(
'[authc.providers.1.basic.basic1.showInSelector]: `basic` provider only supports `true` in `showInSelector`.'
);
});
it('can have only provider of this type', () => {
@ -386,11 +371,7 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured."
`);
).toThrow('[authc.providers.1.basic]: Only one "basic" provider can be configured');
});
it('can be successfully validated', () => {
@ -420,11 +401,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { token: { token1: { enabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]'
);
});
it('cannot be hidden from selector', () => {
@ -434,11 +413,9 @@ describe('config schema', () => {
providers: { token: { token1: { order: 0, showInSelector: false } } },
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`."
`);
).toThrow(
'[authc.providers.1.token.token1.showInSelector]: `token` provider only supports `true` in `showInSelector`.'
);
});
it('can have only provider of this type', () => {
@ -446,11 +423,7 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.token]: Only one \\"token\\" provider can be configured."
`);
).toThrow('[authc.providers.1.token]: Only one "token" provider can be configured');
});
it('can be successfully validated', () => {
@ -480,11 +453,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { pki: { pki1: { enabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]'
);
});
it('can have only provider of this type', () => {
@ -492,11 +463,7 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured."
`);
).toThrow('[authc.providers.1.pki]: Only one "pki" provider can be configured');
});
it('can be successfully validated', () => {
@ -524,11 +491,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { kerberos: { kerberos1: { enabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]'
);
});
it('can have only provider of this type', () => {
@ -538,11 +503,7 @@ describe('config schema', () => {
providers: { kerberos: { kerberos1: { order: 0 }, kerberos2: { order: 1 } } },
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured."
`);
).toThrow('[authc.providers.1.kerberos]: Only one "kerberos" provider can be configured');
});
it('can be successfully validated', () => {
@ -570,11 +531,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { oidc: { oidc1: { enabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]'
);
});
it('requires `realm`', () => {
@ -582,11 +541,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { oidc: { oidc1: { order: 0 } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]'
);
});
it('can be successfully validated', () => {
@ -625,11 +582,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { saml: { saml1: { enabled: true } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]'
);
});
it('requires `realm`', () => {
@ -637,11 +592,9 @@ describe('config schema', () => {
ConfigSchema.validate({
authc: { providers: { saml: { saml1: { order: 0 } } } },
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]"
`);
).toThrow(
'[authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]'
);
});
it('can be successfully validated', () => {
@ -703,11 +656,9 @@ describe('config schema', () => {
},
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]"
`);
).toThrow(
'[authc.providers.1]: Found multiple providers configured with the same name "provider1": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]'
);
});
it('`order` should be unique across all provider types', () => {
@ -723,11 +674,9 @@ describe('config schema', () => {
},
},
})
).toThrowErrorMatchingInlineSnapshot(`
"[authc.providers]: types that failed validation:
- [authc.providers.0]: expected value of type [array] but got [Object]
- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]"
`);
).toThrow(
'[authc.providers.1]: Found multiple providers configured with the same order "0": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]'
);
});
it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => {
@ -792,10 +741,8 @@ describe('config schema', () => {
describe('session', () => {
it('should throw error if xpack.security.session.cleanupInterval is less than 10 seconds', () => {
expect(() =>
ConfigSchema.validate({ session: { cleanupInterval: '9s' } })
).toThrowErrorMatchingInlineSnapshot(
`"[session.cleanupInterval]: the value must be greater or equal to 10 seconds."`
expect(() => ConfigSchema.validate({ session: { cleanupInterval: '9s' } })).toThrow(
'[session.cleanupInterval]: the value must be greater or equal to 10 seconds.'
);
});
});
@ -1091,4 +1038,55 @@ describe('createConfig()', () => {
]
`);
});
it('accepts an audit appender', () => {
expect(
ConfigSchema.validate({
audit: {
appender: {
kind: 'file',
path: '/path/to/file.txt',
layout: {
kind: 'json',
},
},
},
}).audit.appender
).toMatchInlineSnapshot(`
Object {
"kind": "file",
"layout": Object {
"kind": "json",
},
"path": "/path/to/file.txt",
}
`);
});
it('rejects an appender if not fully configured', () => {
expect(() =>
ConfigSchema.validate({
audit: {
// no layout configured
appender: {
kind: 'file',
path: '/path/to/file.txt',
},
},
})
).toThrow('[audit.appender.2.kind]: expected value to equal [legacy-appender]');
});
it('rejects an ignore_filter when no appender is configured', () => {
expect(() =>
ConfigSchema.validate({
audit: {
enabled: true,
ignore_filters: [{ actions: ['some_action'] }],
},
})
).toThrow(
'[audit]: xpack.security.audit.ignore_filters can only be used with the ECS audit logger. To enable the ECS audit logger, specify where you want to write the audit events using xpack.security.audit.appender.'
);
});
});

View file

@ -7,7 +7,7 @@
import crypto from 'crypto';
import { schema, Type, TypeOf } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { Logger } from '../../../../src/core/server';
import { Logger, config as coreConfig } from '../../../../src/core/server';
export type ConfigType = ReturnType<typeof createConfig>;
@ -198,9 +198,30 @@ export const ConfigSchema = schema.object({
schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }),
}),
}),
audit: schema.object({
enabled: schema.boolean({ defaultValue: false }),
}),
audit: schema.object(
{
enabled: schema.boolean({ defaultValue: false }),
appender: schema.maybe(coreConfig.logging.appenders),
ignore_filters: schema.maybe(
schema.arrayOf(
schema.object({
actions: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
categories: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
types: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
outcomes: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
spaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
})
)
),
},
{
validate: (auditConfig) => {
if (auditConfig.ignore_filters && !auditConfig.appender) {
return 'xpack.security.audit.ignore_filters can only be used with the ECS audit logger. To enable the ECS audit logger, specify where you want to write the audit events using xpack.security.audit.appender.';
}
},
}
),
});
export function createConfig(

View file

@ -27,7 +27,7 @@ export {
SAMLLogin,
OIDCLogin,
} from './authentication';
export { AuditLogger } from './audit';
export { LegacyAuditLogger } from './audit';
export { SecurityPluginSetup };
export { AuthenticatedUser } from '../common/model';

View file

@ -55,6 +55,7 @@ describe('Security Plugin', () => {
await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(`
Object {
"audit": Object {
"asScoped": [Function],
"getLogger": [Function],
},
"authc": Object {

View file

@ -67,7 +67,7 @@ export interface SecurityPluginSetup {
'actions' | 'checkPrivilegesDynamicallyWithRequest' | 'checkPrivilegesWithRequest' | 'mode'
>;
license: SecurityLicense;
audit: Pick<AuditServiceSetup, 'getLogger'>;
audit: AuditServiceSetup;
/**
* If Spaces plugin is available it's supposed to register its SpacesService with Security plugin
@ -101,6 +101,7 @@ export class Plugin {
private readonly logger: Logger;
private spacesService?: SpacesService | symbol = Symbol('not accessed');
private securityLicenseService?: SecurityLicenseService;
private authc?: Authentication;
private readonly featureUsageService = new SecurityFeatureUsageService();
private featureUsageServiceStart?: SecurityFeatureUsageServiceStart;
@ -176,8 +177,15 @@ export class Plugin {
registerSecurityUsageCollector({ usageCollection, config, license });
const audit = this.auditService.setup({ license, config: config.audit });
const auditLogger = new SecurityAuditLogger(audit.getLogger());
const audit = this.auditService.setup({
license,
config: config.audit,
logging: core.logging,
http: core.http,
getSpaceId: (request) => this.getSpacesService()?.getSpaceId(request),
getCurrentUser: (request) => this.authc?.getCurrentUser(request),
});
const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger());
const { session } = this.sessionManagementService.setup({
config,
@ -187,8 +195,9 @@ export class Plugin {
taskManager,
});
const authc = await setupAuthentication({
auditLogger,
this.authc = await setupAuthentication({
legacyAuditLogger,
audit,
getFeatureUsageService: this.getFeatureUsageService,
http: core.http,
clusterClient,
@ -209,11 +218,12 @@ export class Plugin {
buildNumber: this.initializerContext.env.packageInfo.buildNum,
getSpacesService: this.getSpacesService,
features,
getCurrentUser: authc.getCurrentUser,
getCurrentUser: this.authc.getCurrentUser,
});
setupSavedObjects({
auditLogger,
legacyAuditLogger,
audit,
authz,
savedObjects: core.savedObjects,
getSpacesService: this.getSpacesService,
@ -226,7 +236,7 @@ export class Plugin {
logger: this.initializerContext.logger.get('routes'),
clusterClient,
config,
authc,
authc: this.authc,
authz,
license,
session,
@ -239,17 +249,18 @@ export class Plugin {
return deepFreeze<SecurityPluginSetup>({
audit: {
asScoped: audit.asScoped,
getLogger: audit.getLogger,
},
authc: {
isAuthenticated: authc.isAuthenticated,
getCurrentUser: authc.getCurrentUser,
areAPIKeysEnabled: authc.areAPIKeysEnabled,
createAPIKey: authc.createAPIKey,
invalidateAPIKey: authc.invalidateAPIKey,
grantAPIKeyAsInternalUser: authc.grantAPIKeyAsInternalUser,
invalidateAPIKeyAsInternalUser: authc.invalidateAPIKeyAsInternalUser,
isAuthenticated: this.authc.isAuthenticated,
getCurrentUser: this.authc.getCurrentUser,
areAPIKeysEnabled: this.authc.areAPIKeysEnabled,
createAPIKey: this.authc.createAPIKey,
invalidateAPIKey: this.authc.invalidateAPIKey,
grantAPIKeyAsInternalUser: this.authc.grantAPIKeyAsInternalUser,
invalidateAPIKeyAsInternalUser: this.authc.invalidateAPIKeyAsInternalUser,
},
authz: {

View file

@ -12,11 +12,12 @@ import {
} from '../../../../../src/core/server';
import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper';
import { AuthorizationServiceSetup } from '../authorization';
import { SecurityAuditLogger } from '../audit';
import { SecurityAuditLogger, AuditServiceSetup } from '../audit';
import { SpacesService } from '../plugin';
interface SetupSavedObjectsParams {
auditLogger: SecurityAuditLogger;
legacyAuditLogger: SecurityAuditLogger;
audit: AuditServiceSetup;
authz: Pick<
AuthorizationServiceSetup,
'mode' | 'actions' | 'checkSavedObjectsPrivilegesWithRequest'
@ -26,7 +27,8 @@ interface SetupSavedObjectsParams {
}
export function setupSavedObjects({
auditLogger,
legacyAuditLogger,
audit,
authz,
savedObjects,
getSpacesService,
@ -50,7 +52,8 @@ export function setupSavedObjects({
return authz.mode.useRbacForRequest(kibanaRequest)
? new SecureSavedObjectsClientWrapper({
actions: authz.actions,
auditLogger,
legacyAuditLogger,
auditLogger: audit.asScoped(kibanaRequest),
baseClient: client,
checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest(
kibanaRequest

View file

@ -6,10 +6,11 @@
import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper';
import { Actions } from '../authorization';
import { securityAuditLoggerMock } from '../audit/index.mock';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { securityAuditLoggerMock, auditServiceMock } from '../audit/index.mock';
import { savedObjectsClientMock, httpServerMock } from '../../../../../src/core/server/mocks';
import { SavedObjectsClientContract } from 'kibana/server';
import { SavedObjectActions } from '../authorization/actions/saved_object';
import { AuditEvent, EventOutcome } from '../audit';
let clientOpts: ReturnType<typeof createSecureSavedObjectsClientWrapperOptions>;
let client: SecureSavedObjectsClientWrapper;
@ -38,7 +39,8 @@ const createSecureSavedObjectsClientWrapperOptions = () => {
checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(),
errors,
getSpacesService,
auditLogger: securityAuditLoggerMock.create(),
legacyAuditLogger: securityAuditLoggerMock.create(),
auditLogger: auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()),
forbiddenError,
generalError,
};
@ -53,8 +55,8 @@ const expectGeneralError = async (fn: Function, args: Record<string, any>) => {
clientOpts.generalError
);
expect(clientOpts.errors.decorateGeneralError).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
};
/**
@ -84,8 +86,8 @@ const expectForbiddenError = async (fn: Function, args: Record<string, any>, act
const spaceIds = [spaceId];
expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
USERNAME,
action ?? ACTION,
types,
@ -93,7 +95,7 @@ const expectForbiddenError = async (fn: Function, args: Record<string, any>, act
missing,
args
);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
};
const expectSuccess = async (fn: Function, args: Record<string, any>, action?: string) => {
@ -105,9 +107,9 @@ const expectSuccess = async (fn: Function, args: Record<string, any>, action?: s
const types = getCalls.map((x) => x[0]);
const spaceIds = args.options?.namespaces || [args.options?.namespace || 'default'];
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(
USERNAME,
action ?? ACTION,
types,
@ -176,6 +178,26 @@ const expectObjectNamespaceFiltering = async (
);
};
const expectAuditEvent = (
action: AuditEvent['event']['action'],
outcome: AuditEvent['event']['outcome'],
savedObject?: Required<AuditEvent>['kibana']['saved_object']
) => {
expect(clientOpts.auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
action,
outcome,
}),
kibana: savedObject
? expect.objectContaining({
saved_object: savedObject,
})
: expect.anything(),
})
);
};
const expectObjectsNamespaceFiltering = async (fn: Function, args: Record<string, any>) => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce(
getMockCheckPrivilegesSuccess // privilege check for authorization
@ -200,15 +222,13 @@ const expectObjectsNamespaceFiltering = async (fn: Function, args: Record<string
clientOpts.baseClient.find.mockReturnValue(returnValue as any);
const result = await fn.bind(client)(...Object.values(args));
expect(result).toEqual(
expect.objectContaining({
saved_objects: [
{ namespaces: ['?'] },
{ namespaces: [authorizedNamespace] },
{ namespaces: [authorizedNamespace, '?'] },
],
})
);
expect(result).toEqual({
saved_objects: [
{ namespaces: ['?'] },
{ namespaces: [authorizedNamespace] },
{ namespaces: [authorizedNamespace, '?'] },
],
});
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2);
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith('login:', [
@ -299,8 +319,10 @@ describe('#addToNamespaces', () => {
);
expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(
clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure
).toHaveBeenCalledWith(
USERNAME,
'addToNamespacesCreate',
[type],
@ -308,7 +330,7 @@ describe('#addToNamespaces', () => {
[{ privilege, spaceId: newNs1 }],
{ id, type, namespaces, options: {} }
);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
});
test(`throws decorated ForbiddenError when unauthorized to update in current space`, async () => {
@ -324,9 +346,9 @@ describe('#addToNamespaces', () => {
);
expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(
clientOpts.auditLogger.savedObjectsAuthorizationFailure
clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure
).toHaveBeenLastCalledWith(
USERNAME,
'addToNamespacesUpdate',
@ -335,7 +357,7 @@ describe('#addToNamespaces', () => {
[{ privilege, spaceId: currentNs }],
{ id, type, namespaces, options: {} }
);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
});
test(`returns result of baseClient.addToNamespaces when authorized`, async () => {
@ -345,9 +367,9 @@ describe('#addToNamespaces', () => {
const result = await client.addToNamespaces(type, id, namespaces);
expect(result).toBe(apiCallReturnValue);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith(
1,
USERNAME,
'addToNamespacesCreate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesCreate'
@ -355,7 +377,7 @@ describe('#addToNamespaces', () => {
namespaces.sort(),
{ type, id, namespaces, options: {} }
);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith(
2,
USERNAME,
'addToNamespacesUpdate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesUpdate'
@ -392,12 +414,28 @@ describe('#addToNamespaces', () => {
// this operation is unique because it requires two privilege checks before it executes
await expectObjectNamespaceFiltering(client.addToNamespaces, { type, id, namespaces }, 2);
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.addToNamespaces.mockReturnValue(apiCallReturnValue as any);
await client.addToNamespaces(type, id, namespaces);
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_add_to_spaces', EventOutcome.UNKNOWN, { type, id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.addToNamespaces(type, id, namespaces)).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_add_to_spaces', EventOutcome.FAILURE, { type, id });
});
});
describe('#bulkCreate', () => {
const attributes = { some: 'attr' };
const obj1 = Object.freeze({ type: 'foo', otherThing: 'sup', attributes });
const obj2 = Object.freeze({ type: 'bar', otherThing: 'everyone', attributes });
const obj1 = Object.freeze({ type: 'foo', id: 'sup', attributes });
const obj2 = Object.freeze({ type: 'bar', id: 'everyone', attributes });
const namespace = 'some-ns';
test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => {
@ -445,6 +483,25 @@ describe('#bulkCreate', () => {
const options = { namespace };
await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any);
const objects = [obj1, obj2];
const options = { namespace };
await expectSuccess(client.bulkCreate, { objects, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type: obj1.type, id: obj1.id });
expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type: obj2.type, id: obj2.id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.bulkCreate([obj1, obj2], { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type: obj1.type, id: obj1.id });
expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type: obj2.type, id: obj2.id });
});
});
describe('#bulkGet', () => {
@ -484,6 +541,25 @@ describe('#bulkGet', () => {
const options = { namespace };
await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any);
const objects = [obj1, obj2];
const options = { namespace };
await expectSuccess(client.bulkGet, { objects, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_get', EventOutcome.SUCCESS, obj1);
expectAuditEvent('saved_object_get', EventOutcome.SUCCESS, obj2);
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.bulkGet([obj1, obj2], { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_get', EventOutcome.FAILURE, obj1);
expectAuditEvent('saved_object_get', EventOutcome.FAILURE, obj2);
});
});
describe('#bulkUpdate', () => {
@ -534,6 +610,25 @@ describe('#bulkUpdate', () => {
const options = { namespace };
await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any);
const objects = [obj1, obj2];
const options = { namespace };
await expectSuccess(client.bulkUpdate, { objects, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_update', EventOutcome.UNKNOWN, { type: obj1.type, id: obj1.id });
expectAuditEvent('saved_object_update', EventOutcome.UNKNOWN, { type: obj2.type, id: obj2.id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.bulkUpdate<any>([obj1, obj2], { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_update', EventOutcome.FAILURE, { type: obj1.type, id: obj1.id });
expectAuditEvent('saved_object_update', EventOutcome.FAILURE, { type: obj2.type, id: obj2.id });
});
});
describe('#checkConflicts', () => {
@ -614,6 +709,22 @@ describe('#create', () => {
const options = { namespace };
await expectObjectNamespaceFiltering(client.create, { type, attributes, options });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any);
const options = { namespace };
await expectSuccess(client.create, { type, attributes, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.create(type, attributes, { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type });
});
});
describe('#delete', () => {
@ -643,6 +754,22 @@ describe('#delete', () => {
const options = { namespace };
await expectPrivilegeCheck(client.delete, { type, id, options }, namespace);
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any);
const options = { namespace };
await expectSuccess(client.delete, { type, id, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_delete', EventOutcome.UNKNOWN, { type, id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.delete(type, id)).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_delete', EventOutcome.FAILURE, { type, id });
});
});
describe('#find', () => {
@ -663,8 +790,10 @@ describe('#find', () => {
const result = await client.find(options);
expect(clientOpts.baseClient.find).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(
clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure
).toHaveBeenCalledWith(
USERNAME,
'find',
[type1],
@ -759,6 +888,27 @@ describe('#find', () => {
const options = { type: [type1, type2], namespaces };
await expectObjectsNamespaceFiltering(client.find, { options });
});
test(`adds audit event when successful`, async () => {
const obj1 = { type: 'foo', id: 'sup' };
const obj2 = { type: 'bar', id: 'everyone' };
const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' };
clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
await expectSuccess(client.find, { options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_find', EventOutcome.SUCCESS, obj1);
expectAuditEvent('saved_object_find', EventOutcome.SUCCESS, obj2);
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
getMockCheckPrivilegesFailure
);
await client.find({ type: type1 });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_find', EventOutcome.FAILURE);
});
});
describe('#get', () => {
@ -793,6 +943,22 @@ describe('#get', () => {
const options = { namespace };
await expectObjectNamespaceFiltering(client.get, { type, id, options });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any);
const options = { namespace };
await expectSuccess(client.get, { type, id, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_get', EventOutcome.SUCCESS, { type, id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.get(type, id, { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_get', EventOutcome.FAILURE, { type, id });
});
});
describe('#deleteFromNamespaces', () => {
@ -817,8 +983,8 @@ describe('#deleteFromNamespaces', () => {
);
expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
USERNAME,
'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces'
[type],
@ -826,7 +992,7 @@ describe('#deleteFromNamespaces', () => {
[{ privilege, spaceId: namespace1 }],
{ type, id, namespaces, options: {} }
);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
});
test(`returns result of baseClient.deleteFromNamespaces when authorized`, async () => {
@ -836,9 +1002,9 @@ describe('#deleteFromNamespaces', () => {
const result = await client.deleteFromNamespaces(type, id, namespaces);
expect(result).toBe(apiCallReturnValue);
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(
USERNAME,
'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces'
[type],
@ -864,6 +1030,21 @@ describe('#deleteFromNamespaces', () => {
test(`filters namespaces that the user doesn't have access to`, async () => {
await expectObjectNamespaceFiltering(client.deleteFromNamespaces, { type, id, namespaces });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(apiCallReturnValue as any);
await client.deleteFromNamespaces(type, id, namespaces);
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_delete_from_spaces', EventOutcome.UNKNOWN, { type, id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_delete_from_spaces', EventOutcome.FAILURE, { type, id });
});
});
describe('#update', () => {
@ -899,6 +1080,22 @@ describe('#update', () => {
const options = { namespace };
await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options });
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = Symbol();
clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any);
const options = { namespace };
await expectSuccess(client.update, { type, id, attributes, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_update', EventOutcome.UNKNOWN, { type, id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.update(type, id, attributes, { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1);
expectAuditEvent('saved_object_update', EventOutcome.FAILURE, { type, id });
});
});
describe('other', () => {

View file

@ -23,10 +23,12 @@ import { SecurityAuditLogger } from '../audit';
import { Actions, CheckSavedObjectsPrivileges } from '../authorization';
import { CheckPrivilegesResponse } from '../authorization/types';
import { SpacesService } from '../plugin';
import { AuditLogger, EventOutcome, SavedObjectAction, savedObjectEvent } from '../audit';
interface SecureSavedObjectsClientWrapperOptions {
actions: Actions;
auditLogger: SecurityAuditLogger;
legacyAuditLogger: SecurityAuditLogger;
auditLogger: AuditLogger;
baseClient: SavedObjectsClientContract;
errors: SavedObjectsClientContract['errors'];
checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges;
@ -58,7 +60,8 @@ interface EnsureAuthorizedTypeResult {
export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract {
private readonly actions: Actions;
private readonly auditLogger: PublicMethodsOf<SecurityAuditLogger>;
private readonly legacyAuditLogger: PublicMethodsOf<SecurityAuditLogger>;
private readonly auditLogger: AuditLogger;
private readonly baseClient: SavedObjectsClientContract;
private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges;
private getSpacesService: () => SpacesService | undefined;
@ -66,6 +69,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
constructor({
actions,
legacyAuditLogger,
auditLogger,
baseClient,
checkSavedObjectsPrivilegesAsCurrentUser,
@ -74,6 +78,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}: SecureSavedObjectsClientWrapperOptions) {
this.errors = errors;
this.actions = actions;
this.legacyAuditLogger = legacyAuditLogger;
this.auditLogger = auditLogger;
this.baseClient = baseClient;
this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser;
@ -85,9 +90,27 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
attributes: T = {} as T,
options: SavedObjectsCreateOptions = {}
) {
const args = { type, attributes, options };
const namespaces = [options.namespace, ...(options.initialNamespaces || [])];
await this.ensureAuthorized(type, 'create', namespaces, { args });
try {
const args = { type, attributes, options };
const namespaces = [options.namespace, ...(options.initialNamespaces || [])];
await this.ensureAuthorized(type, 'create', namespaces, { args });
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
savedObject: { type, id: options.id },
error,
})
);
throw error;
}
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id: options.id },
})
);
const savedObject = await this.baseClient.create(type, attributes, options);
return await this.redactSavedObjectNamespaces(savedObject);
@ -112,25 +135,65 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: Array<SavedObjectsBulkCreateObject<T>>,
options: SavedObjectsBaseOptions = {}
) {
const args = { objects, options };
const namespaces = objects.reduce(
(acc, { initialNamespaces = [] }) => {
return acc.concat(initialNamespaces);
},
[options.namespace]
);
try {
const args = { objects, options };
const namespaces = objects.reduce(
(acc, { initialNamespaces = [] }) => {
return acc.concat(initialNamespaces);
},
[options.namespace]
);
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, {
args,
});
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, {
args,
});
} catch (error) {
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
savedObject: { type, id },
error,
})
)
);
throw error;
}
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.CREATE,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id },
})
)
);
const response = await this.baseClient.bulkCreate(objects, options);
return await this.redactSavedObjectsNamespaces(response);
}
public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
const args = { type, id, options };
await this.ensureAuthorized(type, 'delete', options.namespace, { args });
try {
const args = { type, id, options };
await this.ensureAuthorized(type, 'delete', options.namespace, { args });
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.DELETE,
savedObject: { type, id },
error,
})
);
throw error;
}
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.DELETE,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id },
})
);
return await this.baseClient.delete(type, id, options);
}
@ -145,6 +208,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
`_find across namespaces is not permitted when the Spaces plugin is disabled.`
);
}
const args = { options };
const { status, typeMap } = await this.ensureAuthorized(
options.type,
@ -155,6 +219,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
if (status === 'unauthorized') {
// return empty response
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.FIND,
error: new Error(status),
})
);
return SavedObjectsUtils.createEmptyFindResponse<T>(options);
}
@ -163,11 +233,22 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces),
new Map()
);
const response = await this.baseClient.find<T>({
...options,
typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation
...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined
});
response.saved_objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.FIND,
savedObject: { type, id },
})
)
);
return await this.redactSavedObjectsNamespaces(response);
}
@ -175,20 +256,67 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: SavedObjectsBulkGetObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
const args = { objects, options };
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, {
args,
});
try {
const args = { objects, options };
await this.ensureAuthorized(
this.getUniqueObjectTypes(objects),
'bulk_get',
options.namespace,
{
args,
}
);
} catch (error) {
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.GET,
savedObject: { type, id },
error,
})
)
);
throw error;
}
const response = await this.baseClient.bulkGet<T>(objects, options);
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.GET,
savedObject: { type, id },
})
)
);
return await this.redactSavedObjectsNamespaces(response);
}
public async get<T = unknown>(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
const args = { type, id, options };
await this.ensureAuthorized(type, 'get', options.namespace, { args });
try {
const args = { type, id, options };
await this.ensureAuthorized(type, 'get', options.namespace, { args });
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.GET,
savedObject: { type, id },
error,
})
);
throw error;
}
const savedObject = await this.baseClient.get<T>(type, id, options);
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.GET,
savedObject: { type, id },
})
);
return await this.redactSavedObjectNamespaces(savedObject);
}
@ -198,8 +326,26 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
attributes: Partial<T>,
options: SavedObjectsUpdateOptions = {}
) {
const args = { type, id, attributes, options };
await this.ensureAuthorized(type, 'update', options.namespace, { args });
try {
const args = { type, id, attributes, options };
await this.ensureAuthorized(type, 'update', options.namespace, { args });
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.UPDATE,
savedObject: { type, id },
error,
})
);
throw error;
}
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.UPDATE,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id },
})
);
const savedObject = await this.baseClient.update(type, id, attributes, options);
return await this.redactSavedObjectNamespaces(savedObject);
@ -211,25 +357,45 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
namespaces: string[],
options: SavedObjectsAddToNamespacesOptions = {}
) {
const args = { type, id, namespaces, options };
const { namespace } = options;
// To share an object, the user must have the "share_to_space" permission in each of the destination namespaces.
await this.ensureAuthorized(type, 'share_to_space', namespaces, {
args,
auditAction: 'addToNamespacesCreate',
});
try {
const args = { type, id, namespaces, options };
const { namespace } = options;
// To share an object, the user must have the "share_to_space" permission in each of the destination namespaces.
await this.ensureAuthorized(type, 'share_to_space', namespaces, {
args,
auditAction: 'addToNamespacesCreate',
});
// To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the
// `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in
// the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation
// will result in a 404 error.
await this.ensureAuthorized(type, 'share_to_space', namespace, {
args,
auditAction: 'addToNamespacesUpdate',
});
// To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the
// `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in
// the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation
// will result in a 404 error.
await this.ensureAuthorized(type, 'share_to_space', namespace, {
args,
auditAction: 'addToNamespacesUpdate',
});
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.ADD_TO_SPACES,
savedObject: { type, id },
addToSpaces: namespaces,
error,
})
);
throw error;
}
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.ADD_TO_SPACES,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id },
addToSpaces: namespaces,
})
);
const result = await this.baseClient.addToNamespaces(type, id, namespaces, options);
return await this.redactSavedObjectNamespaces(result);
const response = await this.baseClient.addToNamespaces(type, id, namespaces, options);
return await this.redactSavedObjectNamespaces(response);
}
public async deleteFromNamespaces(
@ -238,31 +404,73 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
namespaces: string[],
options: SavedObjectsDeleteFromNamespacesOptions = {}
) {
const args = { type, id, namespaces, options };
// To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces.
await this.ensureAuthorized(type, 'share_to_space', namespaces, {
args,
auditAction: 'deleteFromNamespaces',
});
try {
const args = { type, id, namespaces, options };
// To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces.
await this.ensureAuthorized(type, 'share_to_space', namespaces, {
args,
auditAction: 'deleteFromNamespaces',
});
} catch (error) {
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.DELETE_FROM_SPACES,
savedObject: { type, id },
deleteFromSpaces: namespaces,
error,
})
);
throw error;
}
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.DELETE_FROM_SPACES,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id },
deleteFromSpaces: namespaces,
})
);
const result = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options);
return await this.redactSavedObjectNamespaces(result);
const response = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options);
return await this.redactSavedObjectNamespaces(response);
}
public async bulkUpdate<T = unknown>(
objects: Array<SavedObjectsBulkUpdateObject<T>> = [],
options: SavedObjectsBaseOptions = {}
) {
const objectNamespaces = objects
// The repository treats an `undefined` object namespace is treated as the absence of a namespace, falling back to options.namespace;
// in this case, filter it out here so we don't accidentally check for privileges in the Default space when we shouldn't be doing so.
.filter(({ namespace }) => namespace !== undefined)
.map(({ namespace }) => namespace!);
const namespaces = [options?.namespace, ...objectNamespaces];
const args = { objects, options };
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, {
args,
});
try {
const objectNamespaces = objects
// The repository treats an `undefined` object namespace is treated as the absence of a namespace, falling back to options.namespace;
// in this case, filter it out here so we don't accidentally check for privileges in the Default space when we shouldn't be doing so.
.filter(({ namespace }) => namespace !== undefined)
.map(({ namespace }) => namespace!);
const namespaces = [options?.namespace, ...objectNamespaces];
const args = { objects, options };
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, {
args,
});
} catch (error) {
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.UPDATE,
savedObject: { type, id },
error,
})
)
);
throw error;
}
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.UPDATE,
outcome: EventOutcome.UNKNOWN,
savedObject: { type, id },
})
)
);
const response = await this.baseClient.bulkUpdate<T>(objects, options);
return await this.redactSavedObjectsNamespaces(response);
@ -316,7 +524,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
);
const logAuthorizationFailure = () => {
this.auditLogger.savedObjectsAuthorizationFailure(
this.legacyAuditLogger.savedObjectsAuthorizationFailure(
username,
auditAction,
types,
@ -326,7 +534,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
);
};
const logAuthorizationSuccess = (typeArray: string[], spaceIdArray: string[]) => {
this.auditLogger.savedObjectsAuthorizationSuccess(
this.legacyAuditLogger.savedObjectsAuthorizationSuccess(
username,
auditAction,
typeArray,

View file

@ -4,12 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuditLogger } from '../../../security/server';
import { LegacyAuditLogger } from '../../../security/server';
export class SpacesAuditLogger {
private readonly auditLogger: AuditLogger;
private readonly auditLogger: LegacyAuditLogger;
constructor(auditLogger: AuditLogger = { log() {} }) {
constructor(auditLogger: LegacyAuditLogger = { log() {} }) {
this.auditLogger = auditLogger;
}
public spacesAuthorizationFailure(username: string, action: string, spaceIds?: string[]) {

View file

@ -35,6 +35,7 @@ const onlyNotInCoverageTests = [
require.resolve('../test/security_api_integration/session_idle.config.ts'),
require.resolve('../test/security_api_integration/session_lifespan.config.ts'),
require.resolve('../test/security_api_integration/login_selector.config.ts'),
require.resolve('../test/security_api_integration/audit.config.ts'),
require.resolve('../test/token_api_integration/config.js'),
require.resolve('../test/oidc_api_integration/config.ts'),
require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'),

View file

@ -27,7 +27,6 @@ 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,12 +49,6 @@ 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

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

View file

@ -1,65 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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

@ -1,129 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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');
});
});
}

View file

@ -0,0 +1,37 @@
/*
* 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 { resolve } from 'path';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts'));
const auditLogPlugin = resolve(__dirname, './fixtures/audit/audit_log');
const auditLogPath = resolve(__dirname, './fixtures/audit/audit.log');
return {
testFiles: [require.resolve('./tests/audit')],
servers: xPackAPITestsConfig.get('servers'),
security: { disableTestUser: true },
services: xPackAPITestsConfig.get('services'),
junit: {
reportName: 'X-Pack Security API Integration Tests (Audit Log)',
},
esTestCluster: xPackAPITestsConfig.get('esTestCluster'),
kbnTestServer: {
...xPackAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${auditLogPlugin}`,
'--xpack.security.audit.enabled=true',
'--xpack.security.audit.appender.kind=file',
`--xpack.security.audit.appender.path=${auditLogPath}`,
'--xpack.security.audit.appender.layout.kind=json',
],
},
};
}

View file

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

View file

@ -0,0 +1,20 @@
/*
* 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) {
const router = core.http.createRouter();
router.get({ path: '/audit_log', validate: false }, async (context, request, response) => {
await context.core.savedObjects.client.create('dashboard', {});
await context.core.savedObjects.client.find({ type: 'dashboard' });
return response.noContent();
});
}
public start() {}
}

View file

@ -0,0 +1,118 @@
/*
* 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 ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const retry = getService('retry');
const { username, password } = getService('config').get('servers.kibana');
describe('Audit Log', function () {
const logFilePath = Path.resolve(__dirname, '../../fixtures/audit/audit.log');
const logFile = new FileWrapper(logFilePath);
beforeEach(async () => {
await logFile.reset();
});
it('logs audit events when reading and writing saved objects', async () => {
await supertest.get('/audit_log?query=param').set('kbn-xsrf', 'foo').expect(204);
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
const content = await logFile.readJSON();
const httpEvent = content.find((c) => c.event.action === 'http_request');
expect(httpEvent).to.be.ok();
expect(httpEvent.trace.id).to.be.ok();
expect(httpEvent.user.name).to.be(username);
expect(httpEvent.kibana.space_id).to.be('default');
expect(httpEvent.http.request.method).to.be('get');
expect(httpEvent.url.path).to.be('/audit_log');
expect(httpEvent.url.query).to.be('query=param');
const createEvent = content.find((c) => c.event.action === 'saved_object_create');
expect(createEvent).to.be.ok();
expect(createEvent.trace.id).to.be.ok();
expect(createEvent.user.name).to.be(username);
expect(createEvent.kibana.space_id).to.be('default');
const findEvent = content.find((c) => c.event.action === 'saved_object_find');
expect(findEvent).to.be.ok();
expect(findEvent.trace.id).to.be.ok();
expect(findEvent.user.name).to.be(username);
expect(findEvent.kibana.space_id).to.be('default');
});
it('logs audit events when logging in successfully', async () => {
await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
currentURL: '/',
params: { username, password },
})
.expect(200);
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
const content = await logFile.readJSON();
const loginEvent = content.find((c) => c.event.action === 'user_login');
expect(loginEvent).to.be.ok();
expect(loginEvent.event.outcome).to.be('success');
expect(loginEvent.trace.id).to.be.ok();
expect(loginEvent.user.name).to.be(username);
});
it('logs audit events when failing to log in', async () => {
await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'basic',
providerName: 'basic',
currentURL: '/',
params: { username, password: 'invalid_password' },
})
.expect(401);
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
const content = await logFile.readJSON();
const loginEvent = content.find((c) => c.event.action === 'user_login');
expect(loginEvent).to.be.ok();
expect(loginEvent.event.outcome).to.be('failure');
expect(loginEvent.trace.id).to.be.ok();
expect(loginEvent.user).not.to.be.ok();
});
});
}

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 { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('security APIs - Audit Log', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./audit_log'));
});
}