audit space deletion (#124145)

* Add audit logging to space deletion

* Fix outcome

* Delete all non-global saved objects

* Added suggestions from code review

* Fix tests

* Fix wording

* Fix alert type errors

* updated snapshot

* update mocks

* Added suggestions from code review

* fix type error
This commit is contained in:
Thom Heymann 2022-02-01 15:57:20 +00:00 committed by GitHub
parent 5b4be373e0
commit b94ef10c01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 159 additions and 29 deletions

View file

@ -32,6 +32,7 @@ describe('audit_logger', () => {
describe('log function', () => {
const mockLogger: jest.Mocked<AuditLogger> = {
log: jest.fn(),
enabled: true,
};
let logger: AuthorizationAuditLogger;

View file

@ -24,6 +24,7 @@ describe('authorization', () => {
request = httpServerMock.createKibanaRequest();
mockLogger = {
log: jest.fn(),
enabled: true,
};
});

View file

@ -46,6 +46,7 @@ const fakeRequest = {
const auditLogger = {
log: jest.fn(),
enabled: true,
} as jest.Mocked<AuditLogger>;
describe('AlertsClientFactory', () => {

View file

@ -25,6 +25,7 @@ const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = {
log: jest.fn(),
enabled: true,
} as jest.Mocked<AuditLogger>;
const alertsClientParams: jest.Mocked<ConstructorOptions> = {

View file

@ -24,6 +24,7 @@ const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = {
log: jest.fn(),
enabled: true,
} as jest.Mocked<AuditLogger>;
const alertsClientParams: jest.Mocked<ConstructorOptions> = {

View file

@ -25,6 +25,7 @@ const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = {
log: jest.fn(),
enabled: true,
} as jest.Mocked<AuditLogger>;
const alertsClientParams: jest.Mocked<ConstructorOptions> = {

View file

@ -24,6 +24,7 @@ const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = {
log: jest.fn(),
enabled: true,
} as jest.Mocked<AuditLogger>;
const alertsClientParams: jest.Mocked<ConstructorOptions> = {

View file

@ -218,8 +218,6 @@ export enum SavedObjectAction {
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',
REMOVE_REFERENCES = 'saved_object_remove_references',
OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time',
CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time',
@ -236,8 +234,6 @@ const savedObjectAuditVerbs: Record<SavedObjectAction, VerbsTuple> = {
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'],
saved_object_open_point_in_time: [
'open point-in-time',
'opening point-in-time',
@ -272,8 +268,6 @@ const savedObjectAuditTypes: Record<SavedObjectAction, EcsEventType> = {
saved_object_update: 'change',
saved_object_delete: 'deletion',
saved_object_find: 'access',
saved_object_add_to_spaces: 'change',
saved_object_delete_from_spaces: 'change',
saved_object_open_point_in_time: 'creation',
saved_object_close_point_in_time: 'deletion',
saved_object_remove_references: 'change',

View file

@ -68,6 +68,7 @@ describe('#setup', () => {
Object {
"asScoped": [Function],
"withoutRequest": Object {
"enabled": true,
"log": [Function],
},
}

View file

@ -49,6 +49,14 @@ export interface AuditLogger {
* ```
*/
log: (event: AuditEvent | undefined) => void;
/**
* Indicates whether audit logging is enabled or not.
*
* Useful for skipping resource-intense operations that don't need to be performed when audit
* logging is disabled.
*/
readonly enabled: boolean;
}
export interface AuditServiceSetup {
@ -122,7 +130,8 @@ export class AuditService {
);
// Record feature usage at a regular interval if enabled and license allows
if (config.enabled && config.appender) {
const enabled = !!(config.enabled && config.appender);
if (enabled) {
license.features$.subscribe((features) => {
clearInterval(this.usageIntervalId!);
if (features.allowAuditLogging) {
@ -169,6 +178,7 @@ export class AuditService {
trace: { id: request.id },
});
},
enabled,
});
http.registerOnPostAuth((request, response, t) => {
@ -180,7 +190,7 @@ export class AuditService {
return {
asScoped,
withoutRequest: { log },
withoutRequest: { log, enabled },
};
}

View file

@ -13,9 +13,11 @@ export const auditServiceMock = {
getLogger: jest.fn(),
asScoped: jest.fn().mockReturnValue({
log: jest.fn(),
enabled: true,
}),
withoutRequest: {
log: jest.fn(),
enabled: true,
},
} as jest.Mocked<ReturnType<AuditService['setup']>>;
},

View file

@ -264,6 +264,7 @@ describe('Authenticator', () => {
let mockSessVal: SessionValue;
const auditLogger = {
log: jest.fn(),
enabled: true,
};
beforeEach(() => {
@ -1094,6 +1095,7 @@ describe('Authenticator', () => {
let mockSessVal: SessionValue;
const auditLogger = {
log: jest.fn(),
enabled: true,
};
beforeEach(() => {
@ -1831,6 +1833,7 @@ describe('Authenticator', () => {
let mockSessVal: SessionValue;
const auditLogger = {
log: jest.fn(),
enabled: true,
};
beforeEach(() => {
@ -2009,6 +2012,7 @@ describe('Authenticator', () => {
let mockSessVal: SessionValue;
const auditLogger = {
log: jest.fn(),
enabled: true,
};
beforeEach(() => {
@ -2145,6 +2149,7 @@ describe('Authenticator', () => {
let mockSessionValue: SessionValue;
const auditLogger = {
log: jest.fn(),
enabled: true,
};
beforeEach(() => {

View file

@ -68,6 +68,7 @@ describe('Security Plugin', () => {
"audit": Object {
"asScoped": [Function],
"withoutRequest": Object {
"enabled": false,
"log": [Function],
},
},

View file

@ -8,7 +8,11 @@
import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks';
import { deepFreeze } from '@kbn/std';
import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server';
import type {
EcsEventOutcome,
SavedObjectsClientContract,
SavedObjectsFindResponse,
} from 'src/core/server';
import { SavedObjectsErrorHelpers } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
@ -63,6 +67,31 @@ const setup = ({ securityEnabled = false }: Opts = {}) => {
return space;
});
baseClient.createSavedObjectFinder.mockImplementation(() => ({
async *find() {
yield {
saved_objects: [
{
namespaces: ['*'],
type: 'dashboard',
id: '1',
},
{
namespaces: ['existing_space'],
type: 'dashboard',
id: '2',
},
{
namespaces: ['default', 'existing_space'],
type: 'dashboard',
id: '3',
},
],
} as SavedObjectsFindResponse<unknown, unknown>;
},
async close() {},
}));
const authorization = authorizationMock.create({
version: 'unit-test',
applicationName: 'kibana',
@ -602,7 +631,7 @@ describe('SecureSpacesClientWrapper', () => {
});
});
test(`throws a forbidden error when unauthorized`, async () => {
it(`throws a forbidden error when unauthorized`, async () => {
const username = 'some_user';
const { wrapper, baseClient, authorization, auditLogger, request } = setup({
@ -637,7 +666,7 @@ describe('SecureSpacesClientWrapper', () => {
});
});
it('deletes the space when authorized', async () => {
it('deletes the space with all saved objects when authorized', async () => {
const username = 'some_user';
const { wrapper, baseClient, authorization, auditLogger, request } = setup({
@ -669,6 +698,14 @@ describe('SecureSpacesClientWrapper', () => {
type: 'space',
id: space.id,
});
expectAuditEvent(auditLogger, SavedObjectAction.DELETE, 'unknown', {
type: 'dashboard',
id: '2',
});
expectAuditEvent(auditLogger, SavedObjectAction.UPDATE_OBJECTS_SPACES, 'unknown', {
type: 'dashboard',
id: '3',
});
});
});

View file

@ -17,6 +17,7 @@ import type {
LegacyUrlAliasTarget,
Space,
} from '../../../spaces/server';
import { ALL_SPACES_ID } from '../../common/constants';
import type { AuditLogger } from '../audit';
import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit';
import type { AuthorizationServiceSetup } from '../authorization';
@ -246,6 +247,10 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
return this.spacesClient.update(id, space);
}
public createSavedObjectFinder(id: string) {
return this.spacesClient.createSavedObjectFinder(id);
}
public async delete(id: string) {
if (this.useRbac) {
try {
@ -265,6 +270,35 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
}
}
// Fetch saved objects to be removed for audit logging
if (this.auditLogger.enabled) {
const finder = this.spacesClient.createSavedObjectFinder(id);
try {
for await (const response of finder.find()) {
response.saved_objects.forEach((savedObject) => {
const { namespaces = [] } = savedObject;
const isOnlySpace = namespaces.length === 1; // We can always rely on the `namespaces` field having >=1 element
if (namespaces.includes(ALL_SPACES_ID) && !namespaces.includes(id)) {
// This object exists in All Spaces and its `namespaces` field isn't going to change; there's nothing to audit
return;
}
this.auditLogger.log(
savedObjectEvent({
action: isOnlySpace
? SavedObjectAction.DELETE
: SavedObjectAction.UPDATE_OBJECTS_SPACES,
outcome: 'unknown',
savedObject: { type: savedObject.type, id: savedObject.id },
deleteFromSpaces: [id],
})
);
});
}
} finally {
await finder.close();
}
}
this.auditLogger.log(
spaceAuditEvent({
action: SpaceAuditAction.DELETE,

View file

@ -5,12 +5,15 @@
* 2.0.
*/
import { savedObjectsRepositoryMock } from 'src/core/server/mocks';
import type { Space } from '../../common';
import { DEFAULT_SPACE_ID } from '../../common/constants';
import type { SpacesClient } from './spaces_client';
const createSpacesClientMock = () =>
({
const createSpacesClientMock = () => {
const repositoryMock = savedObjectsRepositoryMock.create();
return {
getAll: jest.fn().mockResolvedValue([
{
id: DEFAULT_SPACE_ID,
@ -28,10 +31,11 @@ const createSpacesClientMock = () =>
}),
create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)),
update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)),
createSavedObjectFinder: repositoryMock.createPointInTimeFinder,
delete: jest.fn(),
disableLegacyUrlAliases: jest.fn(),
} as unknown as jest.Mocked<SpacesClient>);
} as unknown as jest.Mocked<SpacesClient>;
};
export const spacesClientMock = {
create: createSpacesClientMock,
};

View file

@ -77,7 +77,7 @@ describe('#getAll', () => {
} as any);
const mockConfig = createMockConfig({ maxSpaces: 1234 });
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
const actualSpaces = await client.getAll();
expect(actualSpaces).toEqual(expectedSpaces);
@ -90,7 +90,10 @@ describe('#getAll', () => {
});
test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => {
const client = new SpacesClient(null as any, null as any, null as any);
const mockDebugLogger = createMockDebugLogger();
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
const mockConfig = createMockConfig();
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
await expect(
client.getAll({ purpose: 'invalid_purpose' as GetAllSpacesPurpose })
).rejects.toThrowErrorMatchingInlineSnapshot(`"unsupported space purpose: invalid_purpose"`);
@ -122,7 +125,7 @@ describe('#get', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
const id = savedObject.id;
const actualSpace = await client.get(id);
@ -181,7 +184,7 @@ describe('#create', () => {
const mockConfig = createMockConfig({ maxSpaces });
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
const actualSpace = await client.create(spaceToCreate);
@ -207,7 +210,7 @@ describe('#create', () => {
const mockConfig = createMockConfig({ maxSpaces });
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
expect(client.create(spaceToCreate)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"`
@ -267,7 +270,7 @@ describe('#update', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
const id = savedObject.id;
const actualSpace = await client.update(id, spaceToUpdate);
@ -309,7 +312,7 @@ describe('#delete', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
expect(client.delete(id)).rejects.toThrowErrorMatchingInlineSnapshot(
`"The foo space cannot be deleted because it is reserved."`
@ -324,7 +327,7 @@ describe('#delete', () => {
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
await client.delete(id);
@ -339,7 +342,12 @@ describe('#delete', () => {
const mockConfig = createMockConfig();
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
const client = new SpacesClient(
mockDebugLogger,
mockConfig,
mockCallWithRequestRepository,
[]
);
const aliases = [
{ targetSpace: 'space1', targetType: 'foo', sourceId: '123' },
{ targetSpace: 'space2', targetType: 'bar', sourceId: '456' },

View file

@ -8,7 +8,11 @@
import Boom from '@hapi/boom';
import { omit } from 'lodash';
import type { ISavedObjectsRepository, SavedObject } from 'src/core/server';
import type {
ISavedObjectsPointInTimeFinder,
ISavedObjectsRepository,
SavedObject,
} from 'src/core/server';
import type {
GetAllSpacesOptions,
@ -58,6 +62,13 @@ export interface ISpacesClient {
*/
update(id: string, space: Space): Promise<Space>;
/**
* Returns a {@link ISavedObjectsPointInTimeFinder} to help page through
* saved objects within the specified space.
* @param id the id of the space to search.
*/
createSavedObjectFinder(id: string): ISavedObjectsPointInTimeFinder<unknown, unknown>;
/**
* Deletes a space, and all saved objects belonging to that space.
* @param id the id of the space to delete.
@ -78,7 +89,8 @@ export class SpacesClient implements ISpacesClient {
constructor(
private readonly debugLogger: (message: string) => void,
private readonly config: ConfigType,
private readonly repository: ISavedObjectsRepository
private readonly repository: ISavedObjectsRepository,
private readonly nonGlobalTypeNames: string[]
) {}
public async getAll(options: GetAllSpacesOptions = {}): Promise<GetSpaceResult[]> {
@ -136,6 +148,13 @@ export class SpacesClient implements ISpacesClient {
return this.transformSavedObjectToSpace(updatedSavedObject);
}
public createSavedObjectFinder(id: string) {
return this.repository.createPointInTimeFinder({
type: this.nonGlobalTypeNames,
namespaces: [id],
});
}
public async delete(id: string) {
const existingSavedObject = await this.repository.get('space', id);
if (isReservedSpace(this.transformSavedObjectToSpace(existingSavedObject))) {

View file

@ -96,20 +96,28 @@ export class SpacesClientService {
}
public start(coreStart: CoreStart): SpacesClientServiceStart {
const nonGlobalTypes = coreStart.savedObjects
.getTypeRegistry()
.getAllTypes()
.filter((x) => x.namespaceType !== 'agnostic');
const nonGlobalTypeNames = nonGlobalTypes.map((x) => x.name);
if (!this.repositoryFactory) {
const hiddenTypeNames = nonGlobalTypes.filter((x) => x.hidden).map((x) => x.name);
this.repositoryFactory = (request, savedObjectsStart) =>
savedObjectsStart.createScopedRepository(request, ['space']);
savedObjectsStart.createScopedRepository(request, [...hiddenTypeNames, 'space']);
}
return {
createSpacesClient: (request: KibanaRequest) => {
if (!this.config) {
throw new Error('Initialization error: spaces config is not available');
}
const baseClient = new SpacesClient(
this.debugLogger,
this.config,
this.repositoryFactory!(request, coreStart.savedObjects)
this.repositoryFactory!(request, coreStart.savedObjects),
nonGlobalTypeNames
);
if (this.clientWrapper) {
return this.clientWrapper(request, baseClient);