diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index 6f3b4fffb21586..b7675adb5239af 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -19,7 +19,7 @@ import uuid from 'uuid'; -import { getRootType } from '../../../mappings'; +import { getRootType, getRootPropertiesObjects } from '../../../mappings'; import { getSearchDsl } from './search_dsl'; import { trimIdPrefix } from './trim_id_prefix'; import { includedFields } from './included_fields'; @@ -406,6 +406,10 @@ export class SavedObjectsRepository { }; } + getTypes() { + return Object.keys(getRootPropertiesObjects(this._mappings)); + } + async _writeToCluster(method, params) { try { await this._onBeforeWrite(); diff --git a/src/server/saved_objects/service/lib/repository.test.js b/src/server/saved_objects/service/lib/repository.test.js index a02bfb9abb6194..d3e66c0554e438 100644 --- a/src/server/saved_objects/service/lib/repository.test.js +++ b/src/server/saved_objects/service/lib/repository.test.js @@ -632,6 +632,83 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#getTypes', () => { + it(`returns no types if mappings have no types`, () => { + const mappings = { + doc: { + properties: { + 'updated_at': { + type: 'date' + }, + } + } + }; + + savedObjectsRepository = new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + callCluster: callAdminCluster, + onBeforeWrite + }); + + const types = savedObjectsRepository.getTypes(); + expect(types).toEqual([]); + }); + + it(`returns single type defined in mappings`, () => { + const mappings = { + doc: { + properties: { + 'updated_at': { + type: 'date' + }, + 'index-pattern': { + properties: {} + } + } + } + }; + + savedObjectsRepository = new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + callCluster: callAdminCluster, + onBeforeWrite + }); + + const types = savedObjectsRepository.getTypes(); + expect(types).toEqual(['index-pattern']); + }); + + it(`returns multiple types defined in mappings`, () => { + const mappings = { + doc: { + properties: { + 'updated_at': { + type: 'date' + }, + 'index-pattern': { + properties: {} + }, + 'visualization': { + properties: {} + }, + } + } + }; + + savedObjectsRepository = new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + callCluster: callAdminCluster, + onBeforeWrite + }); + + const types = savedObjectsRepository.getTypes(); + expect(types).toEqual(['index-pattern', 'visualization']); + }); + }); + describe('onBeforeWrite', () => { it('blocks calls to callCluster of requests', async () => { onBeforeWrite.returns(delay(500)); 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 65e2d5890fea7a..53d8dadeb180b0 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 @@ -6,6 +6,10 @@ import { get, uniq } from 'lodash'; +const getPrivilege = (type, action) => { + return `action:saved_objects/${type}/${action}`; +}; + export class SecureSavedObjectsClient { constructor(options) { const { @@ -51,11 +55,38 @@ export class SecureSavedObjectsClient { } async find(options = {}) { - await this._performAuthorizationCheck(options.type, 'find', { - options, - }); + const action = 'find'; - return await this._repository.find(options); + // when we have the type or types, it makes our life easy + if (options.type) { + await this._performAuthorizationCheck(options.type, action, { options }); + return await this._repository.find(options); + } + + // otherwise, we have to filter for only their authorized types + const types = this._repository.getTypes(); + const typesToPrivilegesMap = new Map(types.map(type => [type, getPrivilege(type, action)])); + const hasPrivilegesResult = await this._hasSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); + const authorizedTypes = Array.from(typesToPrivilegesMap.entries()) + .filter(([ , privilege]) => !hasPrivilegesResult.missing.includes(privilege)) + .map(([type]) => type); + + if (authorizedTypes.length === 0) { + this._auditLogger.savedObjectsAuthorizationFailure( + hasPrivilegesResult.username, + action, + types, + hasPrivilegesResult.missing, + { options } + ); + throw this.errors.decorateForbiddenError(new Error(`Not authorized to find saved_object`)); + } + this._auditLogger.savedObjectsAuthorizationSuccess(hasPrivilegesResult.username, action, authorizedTypes, { options }); + + return await this._repository.find({ + ...options, + type: authorizedTypes + }); } async bulkGet(objects = []) { @@ -89,15 +120,8 @@ export class SecureSavedObjectsClient { async _performAuthorizationCheck(typeOrTypes, action, args) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - const actions = types.map(type => `action:saved_objects/${type}/${action}`); - - let result; - try { - result = await this._hasPrivileges(actions); - } catch(error) { - const { reason } = get(error, 'body.error', {}); - throw this.errors.decorateGeneralError(error, reason); - } + const privileges = types.map(type => getPrivilege(type, action)); + const result = await this._hasSavedObjectPrivileges(privileges); if (result.success) { this._auditLogger.savedObjectsAuthorizationSuccess(result.username, action, types, args); @@ -107,4 +131,13 @@ export class SecureSavedObjectsClient { throw this.errors.decorateForbiddenError(new Error(msg)); } } + + async _hasSavedObjectPrivileges(privileges) { + try { + return await this._hasPrivileges(privileges); + } catch(error) { + const { reason } = get(error, 'body.error', {}); + throw this.errors.decorateGeneralError(error, reason); + } + } } diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js new file mode 100644 index 00000000000000..036ed7a5e0c3af --- /dev/null +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -0,0 +1,898 @@ +/* + * 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 { SecureSavedObjectsClient } from './secure_saved_objects_client'; + +const createMockErrors = () => { + const forbiddenError = new Error('Mock ForbiddenError'); + const generalError = new Error('Mock GeneralError'); + + return { + forbiddenError, + decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), + generalError, + decorateGeneralError: jest.fn().mockReturnValue(generalError) + }; +}; + +const createMockAuditLogger = () => { + return { + savedObjectsAuthorizationFailure: jest.fn(), + savedObjectsAuthorizationSuccess: jest.fn(), + }; +}; + +describe('#errors', () => { + test(`assigns errors from constructor to .errors`, () => { + const errors = Symbol(); + + const client = new SecureSavedObjectsClient({ errors }); + + expect(client.errors).toBe(errors); + }); +}); + +describe('#create', () => { + test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type}/create` + ], + username + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const attributes = Symbol(); + const options = Symbol(); + + await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'create', + [type], + [`action:saved_objects/${type}/create`], + { + type, + attributes, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.create`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + create: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const attributes = Symbol(); + const options = Symbol(); + + const result = await client.create(type, attributes, options); + + expect(result).toBe(returnValue); + expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { + type, + attributes, + options, + }); + }); +}); + +describe('#bulkCreate', () => { + test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type1}/bulk_create` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const objects = [ + { type: type1 }, + { type: type1 }, + { type: type2 }, + ]; + const options = Symbol(); + + await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([ + `action:saved_objects/${type1}/bulk_create`, + `action:saved_objects/${type2}/bulk_create` + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_create', + [type2, type1], + [`action:saved_objects/${type1}/bulk_create`], + { + objects, + options, + } + ); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/bulk_create`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.bulkCreate`, async () => { + const username = Symbol(); + const type1 = 'foo'; + const type2 = 'bar'; + const returnValue = Symbol(); + const mockRepository = { + bulkCreate: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const objects = [ + { type: type1, otherThing: 'sup' }, + { type: type2, otherThing: 'everyone' }, + ]; + const options = Symbol(); + + const result = await client.bulkCreate(objects, options); + + expect(result).toBe(returnValue); + expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { + objects, + options, + }); + }); +}); + +describe('#delete', () => { + test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type}/delete` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const id = Symbol(); + + await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'delete', + [type], + [`action:saved_objects/${type}/delete`], + { + type, + id, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.delete`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + delete: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const id = Symbol(); + + const result = await client.delete(type, id); + + expect(result).toBe(returnValue); + expect(mockRepository.delete).toHaveBeenCalledWith(type, id); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { + type, + id, + }); + }); +}); + +describe('#find', () => { + describe('type', () => { + test(`throws decorated ForbiddenError when type is sinuglar and user isn't authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockRepository = {}; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type}/find` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const options = { type }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type], + [`action:saved_objects/${type}/find`], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type is an array and user isn't authorized for one type`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockRepository = {}; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type1}/find` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const options = { type: [ type1, type2 ] }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type2, type1], + [`action:saved_objects/${type1}/find`], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type is an array and user isn't authorized for either type`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockRepository = {}; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type1}/find`, + `action:saved_objects/${type2}/find` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const options = { type: [ type1, type2 ] }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type2, type1], + [`action:saved_objects/${type2}/find`, `action:saved_objects/${type1}/find`], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.find`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + find: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const options = { type }; + + const result = await client.find(options); + + expect(result).toBe(returnValue); + expect(mockRepository.find).toHaveBeenCalledWith({ type }); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { + options, + }); + }); + }); + + describe('no type', () => { + test(`throws decorated ForbiddenError when user has no authorized types`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockRepository = { + getTypes: jest.fn().mockReturnValue([type]) + }; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type}/find` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const options = Symbol(); + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type], + [`action:saved_objects/${type}/find`], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const mockRepository = { + getTypes: jest.fn().mockReturnValue([type1, type2]) + }; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.find()).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`specifies authorized types when calling repository.find()`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const mockRepository = { + getTypes: jest.fn().mockReturnValue([type1, type2]), + find: jest.fn(), + }; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type1}/find` + ] + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await client.find(); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockRepository.find).toHaveBeenCalledWith(expect.objectContaining({ + type: [type2] + })); + }); + + test(`calls and returns result of repository.find`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + getTypes: jest.fn().mockReturnValue([type]), + find: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + missing: [], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const options = Symbol(); + + const result = await client.find(options); + + expect(result).toBe(returnValue); + expect(mockRepository.find).toHaveBeenCalledWith({ type: [type] }); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { + options, + }); + }); + }); +}); + +describe('#bulkGet', () => { + test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type1}/bulk_get` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const objects = [ + { type: type1 }, + { type: type1 }, + { type: type2 }, + ]; + + await expect(client.bulkGet(objects)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/bulk_get`, `action:saved_objects/${type2}/bulk_get`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_get', + [type2, type1], + [`action:saved_objects/${type1}/bulk_get`], + { + objects + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith(['action:saved_objects/foo/bulk_get']); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.bulkGet`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + bulkGet: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const objects = [ + { type: type1, id: 'foo-id' }, + { type: type2, id: 'bar-id' }, + ]; + + const result = await client.bulkGet(objects); + + expect(result).toBe(returnValue); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { + objects, + }); + }); +}); + +describe('#get', () => { + test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + `action:saved_objects/${type}/get` + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const id = Symbol(); + + await expect(client.get(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'get', + [type], + [`action:saved_objects/${type}/get`], + { + type, + id, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.get`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + get: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const id = Symbol(); + + const result = await client.get(type, id); + + expect(result).toBe(returnValue); + expect(mockRepository.get).toHaveBeenCalledWith(type, id); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { + type, + id, + }); + }); +}); + +describe('#update', () => { + test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: false, + missing: [ + 'action:saved_objects/foo/update' + ], + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); + + await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'update', + [type], + [`action:saved_objects/${type}/update`], + { + type, + id, + attributes, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`calls and returns result of repository.update`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + update: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + success: true, + username, + })); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); + + const result = await client.update(type, id, attributes, options); + + expect(result).toBe(returnValue); + expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { + type, + id, + attributes, + options, + }); + }); +}); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js index 72a3fa2ec2bfce..e00dd1aa907155 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js @@ -13,7 +13,7 @@ export default function ({ getService }) { describe('find', () => { - const expectResults = (resp) => { + const expectVisualizationResults = (resp) => { expect(resp.body).to.eql({ page: 1, per_page: 20, @@ -31,6 +31,44 @@ export default function ({ getService }) { }); }; + const expectAllResults = (resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 4, + saved_objects: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + type: 'index-pattern', + updated_at: '2017-09-21T18:49:16.270Z', + version: 1, + attributes: resp.body.saved_objects[0].attributes + }, + { + id: '7.0.0-alpha1', + type: 'config', + updated_at: '2017-09-21T18:49:16.302Z', + version: 1, + attributes: resp.body.saved_objects[1].attributes + }, + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: 1, + attributes: resp.body.saved_objects[2].attributes + }, + { + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: 1, + attributes: resp.body.saved_objects[3].attributes + }, + ] + }); + }; + const createExpectEmpty = (page, perPage, total) => (resp) => { expect(resp.body).to.eql({ page: page, @@ -40,7 +78,7 @@ export default function ({ getService }) { }); }; - const createExpectForbidden = (canLogin, type) => resp => { + const createExpectActionForbidden = (canLogin, type) => resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', @@ -48,6 +86,14 @@ export default function ({ getService }) { }); }; + const expectForbiddenCantFindAnyTypes = resp => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Not authorized to find saved_object` + }); + }; + const findTest = (description, { auth, tests }) => { describe(description, () => { before(() => esArchiver.load('saved_objects/basic')); @@ -90,6 +136,16 @@ export default function ({ getService }) { .then(tests.unknownSearchField.response) )); }); + + describe('no type', () => { + it(`should return ${tests.noType.statusCode} with ${tests.noType.description}`, async () => ( + await supertest + .get('/api/saved_objects/_find') + .auth(auth.username, auth.password) + .expect(tests.noType.statusCode) + .then(tests.noType.response) + )); + }); }); }; @@ -102,23 +158,28 @@ export default function ({ getService }) { normal: { description: 'forbidden login and find visualization message', statusCode: 403, - response: createExpectForbidden(false, 'visualization'), + response: createExpectActionForbidden(false, 'visualization'), }, unknownType: { description: 'forbidden login and find wigwags message', statusCode: 403, - response: createExpectForbidden(false, 'wigwags'), + response: createExpectActionForbidden(false, 'wigwags'), }, pageBeyondTotal: { description: 'forbidden login and find visualization message', statusCode: 403, - response: createExpectForbidden(false, 'visualization'), + response: createExpectActionForbidden(false, 'visualization'), }, unknownSearchField: { description: 'forbidden login and find wigwags message', statusCode: 403, - response: createExpectForbidden(false, 'wigwags'), + response: createExpectActionForbidden(false, 'wigwags'), }, + noType: { + description: `forbidded can't find any types`, + statusCode: 403, + response: expectForbiddenCantFindAnyTypes, + } } }); @@ -129,9 +190,9 @@ export default function ({ getService }) { }, tests: { normal: { - description: 'individual responses', + description: 'only the visualization', statusCode: 200, - response: expectResults, + response: expectVisualizationResults, }, unknownType: { description: 'empty result', @@ -148,6 +209,11 @@ export default function ({ getService }) { statusCode: 200, response: createExpectEmpty(1, 20, 0), }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults, + }, }, }); @@ -158,9 +224,9 @@ export default function ({ getService }) { }, tests: { normal: { - description: 'individual responses', + description: 'only the visualization', statusCode: 200, - response: expectResults, + response: expectVisualizationResults, }, unknownType: { description: 'empty result', @@ -177,6 +243,11 @@ export default function ({ getService }) { statusCode: 200, response: createExpectEmpty(1, 20, 0), }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults, + }, }, }); @@ -187,14 +258,14 @@ export default function ({ getService }) { }, tests: { normal: { - description: 'individual responses', + description: 'only the visualization', statusCode: 200, - response: expectResults, + response: expectVisualizationResults, }, unknownType: { description: 'forbidden find wigwags message', statusCode: 403, - response: createExpectForbidden(true, 'wigwags'), + response: createExpectActionForbidden(true, 'wigwags'), }, pageBeyondTotal: { description: 'empty result', @@ -204,7 +275,12 @@ export default function ({ getService }) { unknownSearchField: { description: 'forbidden find wigwags message', statusCode: 403, - response: createExpectForbidden(true, 'wigwags'), + response: createExpectActionForbidden(true, 'wigwags'), + }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults, }, } }); diff --git a/x-pack/test/rbac_api_integration/config.js b/x-pack/test/rbac_api_integration/config.js index 481b14913da919..b9f3f19b0576c9 100644 --- a/x-pack/test/rbac_api_integration/config.js +++ b/x-pack/test/rbac_api_integration/config.js @@ -33,8 +33,13 @@ export default async function ({ readConfigFile }) { reportName: 'X-Pack RBAC API Integration Tests', }, + // The saved_objects/basic archives are almost an exact replica of the ones in OSS + // with the exception of a bogus "not-a-visualization" type that I added to make sure + // the find filtering without a type specified worked correctly. Once we have the ability + // to specify more granular access to the objects via the Kibana privileges, this should + // no longer be necessary, and it's only required as long as we do read/all privileges. esArchiver: { - directory: resolveKibanaPath(path.join('test', 'api_integration', 'fixtures', 'es_archiver')) + directory: path.join(__dirname, 'fixtures', 'es_archiver') }, esTestCluster: { diff --git a/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz b/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz new file mode 100644 index 00000000000000..910382479979df Binary files /dev/null and b/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz differ diff --git a/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json b/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json new file mode 100644 index 00000000000000..107a45fab187bc --- /dev/null +++ b/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json @@ -0,0 +1,283 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "doc": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + }, + "aliases": {} + } +} \ No newline at end of file