diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 0346fa90b9cfa..535fbf8cf82d3 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -23,6 +23,8 @@ import { registerPrivilegesWithCluster } from './server/lib/privileges'; import { createDefaultRoles } from './server/lib/authorization/create_default_roles'; import { initPrivilegesApi } from './server/routes/api/v1/privileges'; import { hasPrivilegesWithServer } from './server/lib/authorization/has_privileges'; +import { SecurityAuditLogger } from './server/lib/audit_logger'; +import { AuditLogger } from '../../server/lib/audit_logger'; export const security = (kibana) => new kibana.Plugin({ id: 'security', @@ -51,6 +53,9 @@ export const security = (kibana) => new kibana.Plugin({ `may contain alphanumeric characters (a-z, A-Z, 0-9), underscores and hyphens` ), }).default(), + audit: Joi.object({ + enabled: Joi.boolean().default(false) + }).default(), }).default(); }, @@ -98,6 +103,8 @@ export const security = (kibana) => new kibana.Plugin({ await createDefaultRoles(server); }); + server.expose('auditLogger', new SecurityAuditLogger(server.config(), new AuditLogger(server, 'security'))); + // Register a function that is called whenever the xpack info changes, // to re-compute the license check results for this plugin xpackMainPlugin.info.feature(this.id).registerLicenseCheckResultsGenerator(checkLicense); diff --git a/x-pack/plugins/security/server/lib/audit_logger.js b/x-pack/plugins/security/server/lib/audit_logger.js new file mode 100644 index 0000000000000..22cf207e3d363 --- /dev/null +++ b/x-pack/plugins/security/server/lib/audit_logger.js @@ -0,0 +1,47 @@ +/* + * 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. + */ + +export class SecurityAuditLogger { + constructor(config, auditLogger) { + this._enabled = config.get('xpack.security.audit.enabled'); + this._auditLogger = auditLogger; + } + + savedObjectsAuthorizationFailure(username, action, types, missing, args) { + if (!this._enabled) { + return; + } + + this._auditLogger.log( + 'saved_objects_authorization_failure', + `${username} unauthorized to ${action} ${types.join(',')}, missing ${missing.join(',')}`, + { + username, + action, + types, + missing, + args + } + ); + } + + savedObjectsAuthorizationSuccess(username, action, types, args) { + if (!this._enabled) { + return; + } + + this._auditLogger.log( + 'saved_objects_authorization_success', + `${username} authorized to ${action} ${types.join(',')}`, + { + username, + action, + types, + args, + } + ); + } +} diff --git a/x-pack/plugins/security/server/lib/audit_logger.test.js b/x-pack/plugins/security/server/lib/audit_logger.test.js new file mode 100644 index 0000000000000..da727552aae58 --- /dev/null +++ b/x-pack/plugins/security/server/lib/audit_logger.test.js @@ -0,0 +1,113 @@ +/* + * 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 { SecurityAuditLogger } from './audit_logger'; + + +const createMockConfig = (settings) => { + const mockConfig = { + get: jest.fn() + }; + + const defaultSettings = {}; + + mockConfig.get.mockImplementation(key => { + return key in settings ? settings[key] : defaultSettings[key]; + }); + + return mockConfig; +}; + +const createMockAuditLogger = () => { + return { + log: jest.fn() + }; +}; + +describe(`#savedObjectsAuthorizationFailure`, () => { + test(`doesn't log anything when xpack.security.audit.enabled is false`, () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': false + }); + const auditLogger = createMockAuditLogger(); + + const securityAuditLogger = new SecurityAuditLogger(config, auditLogger); + securityAuditLogger.savedObjectsAuthorizationFailure(); + + expect(auditLogger.log).toHaveBeenCalledTimes(0); + }); + + test('logs via auditLogger when xpack.security.audit.enabled is true', () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': true + }); + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SecurityAuditLogger(config, auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + const types = [ 'foo-type-1', 'foo-type-2' ]; + const missing = [`action:saved-objects/${types[0]}/foo-action`, `action:saved-objects/${types[1]}/foo-action`]; + const args = { + 'foo': 'bar', + 'baz': 'quz', + }; + + securityAuditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'saved_objects_authorization_failure', + expect.stringContaining(`${username} unauthorized to ${action}`), + { + username, + action, + types, + missing, + args, + } + ); + }); +}); + +describe(`#savedObjectsAuthorizationSuccess`, () => { + test(`doesn't log anything when xpack.security.audit.enabled is false`, () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': false + }); + const auditLogger = createMockAuditLogger(); + + const securityAuditLogger = new SecurityAuditLogger(config, auditLogger); + securityAuditLogger.savedObjectsAuthorizationSuccess(); + + expect(auditLogger.log).toHaveBeenCalledTimes(0); + }); + + test('logs via auditLogger when xpack.security.audit.enabled is true', () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': true + }); + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SecurityAuditLogger(config, auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + const types = [ 'foo-type-1', 'foo-type-2' ]; + const args = { + 'foo': 'bar', + 'baz': 'quz', + }; + + securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'saved_objects_authorization_success', + expect.stringContaining(`${username} authorized to ${action}`), + { + username, + action, + types, + args, + } + ); + }); +}); diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js index bd91272ebbcf5..03a0642ecb8e8 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -48,7 +48,8 @@ export function hasPrivilegesWithServer(server) { return { success, - missing: missingPrivileges + missing: missingPrivileges, + username: privilegeCheck.username, }; }; }; diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js index 9d6b9580c47d8..aed3dbcfe94cc 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -43,8 +43,9 @@ const createMockServer = ({ settings = {} } = {}) => { return mockServer; }; -const mockResponse = (hasAllRequested, privileges, application = defaultApplication) => { +const mockResponse = (hasAllRequested, privileges, application = defaultApplication, username = '') => { mockCallWithRequest.mockImplementationOnce(async () => ({ + username: username, has_all_requested: hasAllRequested, application: { [application]: { @@ -151,6 +152,21 @@ test(`returns success when has_all_requested`, async () => { expect(result.success).toBe(true); }); +test(`returns username from has_privileges response when has_all_requested`, async () => { + const mockServer = createMockServer(); + const username = 'foo-username'; + mockResponse(true, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: true, + }, defaultApplication, username); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + const result = await hasPrivileges(['foo']); + expect(result.username).toBe(username); +}); + test(`returns false success when has_all_requested is false`, async () => { const mockServer = createMockServer(); mockResponse(false, { @@ -158,16 +174,6 @@ test(`returns false success when has_all_requested is false`, async () => { [getLoginPrivilege()]: true, foo: false, }); - mockCallWithRequest.mockImplementationOnce(async () => ({ - has_all_requested: false, - application: { - [defaultApplication]: { - [DEFAULT_RESOURCE]: { - foo: false - } - } - } - })); const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); const hasPrivileges = hasPrivilegesWithRequest({}); @@ -175,6 +181,21 @@ test(`returns false success when has_all_requested is false`, async () => { expect(result.success).toBe(false); }); +test(`returns username from has_privileges when has_all_requested is false`, async () => { + const username = 'foo-username'; + const mockServer = createMockServer(); + mockResponse(false, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: false, + }, defaultApplication, username); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({ }); + const result = await hasPrivileges(['foo']); + expect(result.username).toBe(username); +}); + test(`returns missing privileges`, async () => { const mockServer = createMockServer(); mockResponse(false, { diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js index 65f0ce3ff64e8..9eba7621bf5d8 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js @@ -10,10 +10,12 @@ export function secureSavedObjectsClientOptionsBuilder(server, hasPrivilegesWithRequest, options) { const adminCluster = server.plugins.elasticsearch.getCluster('admin'); const { callWithInternalUser } = adminCluster; + const auditLogger = server.plugins.security.auditLogger; return { ...options, callCluster: callWithInternalUser, - hasPrivilegesWithRequest + hasPrivilegesWithRequest, + auditLogger }; } diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index 8793985233c35..08ee87d5f281b 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -12,60 +12,83 @@ export class SecureSavedObjectsClient { request, hasPrivilegesWithRequest, baseClient, + auditLogger, } = options; this.errors = baseClient.errors; this._client = baseClient; this._hasPrivileges = hasPrivilegesWithRequest(request); + this._auditLogger = auditLogger; } async create(type, attributes = {}, options = {}) { - await this._performAuthorizationCheck(type, 'create'); + await this._performAuthorizationCheck(type, 'create', { + type, + attributes, + options, + }); return await this._client.create(type, attributes, options); } async bulkCreate(objects, options = {}) { const types = uniq(objects.map(o => o.type)); - await this._performAuthorizationCheck(types, 'create'); + await this._performAuthorizationCheck(types, 'create', { + objects, + options, + }); return await this._client.bulkCreate(objects, options); } async delete(type, id) { - await this._performAuthorizationCheck(type, 'delete'); + await this._performAuthorizationCheck(type, 'delete', { + type, + id, + }); return await this._client.delete(type, id); } async find(options = {}) { - await this._performAuthorizationCheck(options.type, 'search'); + await this._performAuthorizationCheck(options.type, 'search', { + options, + }); return await this._client.find(options); } async bulkGet(objects = []) { - for (const object of objects) { - await this._performAuthorizationCheck(object.type, 'mget'); - } + const types = uniq(objects.map(o => o.type)); + await this._performAuthorizationCheck(types, 'mget', { + objects, + }); return await this._client.bulkGet(objects); } async get(type, id) { - await this._performAuthorizationCheck(type, 'get'); + await this._performAuthorizationCheck(type, 'get', { + type, + id, + }); return await this._client.get(type, id); } async update(type, id, attributes, options = {}) { - await this._performAuthorizationCheck(type, 'update'); + await this._performAuthorizationCheck(type, 'update', { + type, + id, + attributes, + options, + }); return await this._client.update(type, id, attributes, options); } - async _performAuthorizationCheck(typeOrTypes, action) { + async _performAuthorizationCheck(typeOrTypes, action, args) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const actions = types.map(type => `action:saved-objects/${type}/${action}`); @@ -77,7 +100,10 @@ export class SecureSavedObjectsClient { throw this._client.errors.decorateGeneralError(error, reason); } - if (!result.success) { + if (result.success) { + this._auditLogger.savedObjectsAuthorizationSuccess(result.username, action, types, args); + } else { + this._auditLogger.savedObjectsAuthorizationFailure(result.username, action, types, result.missing, args); const msg = `Unable to ${action} ${types.join(',')}, missing ${result.missing.join(',')}`; throw this._client.errors.decorateForbiddenError(new Error(msg)); }