Skip to content

Commit

Permalink
[Cases] RBAC Refactoring audit logging (#100952)
Browse files Browse the repository at this point in the history
* Refactoring audit logging

* Adding unit tests for authorization classes

* Addressing feedback and adding util tests

* return undefined on empty array

* fixing eslint
  • Loading branch information
jonathan-buttner authored Jun 3, 2021
1 parent 7ef02f4 commit 739fd6f
Show file tree
Hide file tree
Showing 29 changed files with 3,546 additions and 571 deletions.

Large diffs are not rendered by default.

208 changes: 208 additions & 0 deletions x-pack/plugins/cases/server/authorization/audit_logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { AuditLogger } from '../../../../plugins/security/server';
import { Operations } from '.';
import { AuthorizationAuditLogger } from './audit_logger';
import { ReadOperations } from './types';

describe('audit_logger', () => {
it('creates a failure message without any owners', () => {
expect(
AuthorizationAuditLogger.createFailureMessage({
owners: [],
operation: Operations.createCase,
})
).toBe('Unauthorized to create case of any owner');
});

it('creates a failure message with owners', () => {
expect(
AuthorizationAuditLogger.createFailureMessage({
owners: ['a', 'b'],
operation: Operations.createCase,
})
).toBe('Unauthorized to create case with owners: "a, b"');
});

describe('log function', () => {
const mockLogger: jest.Mocked<AuditLogger> = {
log: jest.fn(),
};

let logger: AuthorizationAuditLogger;

beforeEach(() => {
mockLogger.log.mockReset();
logger = new AuthorizationAuditLogger(mockLogger);
});

it('does not throw an error when the underlying audit logger is undefined', () => {
const authLogger = new AuthorizationAuditLogger();
jest.spyOn(authLogger, 'log');

expect(() => {
authLogger.log({
operation: Operations.createCase,
entity: {
owner: 'a',
id: '1',
},
});
}).not.toThrow();

expect(authLogger.log).toHaveBeenCalledTimes(1);
});

it('logs a message with a saved object ID in the message field', () => {
logger.log({
operation: Operations.createCase,
entity: {
owner: 'a',
id: '1',
},
});
expect(mockLogger.log.mock.calls[0][0]?.message).toContain('[id=1]');
});

it('creates the owner part of the message when no owners are specified', () => {
logger.log({
operation: Operations.createCase,
});

expect(mockLogger.log.mock.calls[0][0]?.message).toContain('as any owners');
});

it('creates the owner part of the message when an owner is specified', () => {
logger.log({
operation: Operations.createCase,
entity: {
owner: 'a',
id: '1',
},
});

expect(mockLogger.log.mock.calls[0][0]?.message).toContain('as owner "a"');
});

it('creates a failure message when passed an error', () => {
logger.log({
operation: Operations.createCase,
entity: {
owner: 'a',
id: '1',
},
error: new Error('error occurred'),
});

expect(mockLogger.log.mock.calls[0][0]?.message).toBe(
'Failed attempt to create cases [id=1] as owner "a"'
);

expect(mockLogger.log.mock.calls[0][0]?.event?.outcome).toBe('failure');
});

it('creates a write operation message', () => {
logger.log({
operation: Operations.createCase,
entity: {
owner: 'a',
id: '1',
},
});

expect(mockLogger.log.mock.calls[0][0]?.message).toBe(
'User is creating cases [id=1] as owner "a"'
);

expect(mockLogger.log.mock.calls[0][0]?.event?.outcome).toBe('unknown');
});

it('creates a read operation message', () => {
logger.log({
operation: Operations.getCase,
entity: {
owner: 'a',
id: '1',
},
});

expect(mockLogger.log.mock.calls[0][0]?.message).toBe(
'User has accessed cases [id=1] as owner "a"'
);

expect(mockLogger.log.mock.calls[0][0]?.event?.outcome).toBe('success');
});

describe('event structure', () => {
// I would have preferred to do these as match inline but that isn't supported because this is essentially a for loop
// for reference: https://github.com/facebook/jest/issues/9409#issuecomment-629272237

// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
`creates the correct audit event for operation: "%s" without an error or entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
const key = operationKey as ReadOperations;
logger.log({
operation: Operations[key],
});
expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot();
}
);

// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
`creates the correct audit event for operation: "%s" with an error but no entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
const key = operationKey as ReadOperations;
logger.log({
operation: Operations[key],
error: new Error('an error'),
});
expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot();
}
);

// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
`creates the correct audit event for operation: "%s" with an error and entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
const key = operationKey as ReadOperations;
logger.log({
operation: Operations[key],
entity: {
owner: 'awesome',
id: '1',
},
error: new Error('an error'),
});
expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot();
}
);

// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
`creates the correct audit event for operation: "%s" without an error but with an entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
const key = operationKey as ReadOperations;
logger.log({
operation: Operations[key],
entity: {
owner: 'super',
id: '5',
},
});
expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot();
}
);
});
});
});
145 changes: 61 additions & 84 deletions x-pack/plugins/cases/server/authorization/audit_logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
* 2.0.
*/

import { DATABASE_CATEGORY, ECS_OUTCOMES, OperationDetails } from '.';
import { AuditLogger } from '../../../security/server';
import { EcsEventOutcome } from 'kibana/server';
import { DATABASE_CATEGORY, ECS_OUTCOMES, isWriteOperation, OperationDetails } from '.';
import { AuditEvent, AuditLogger } from '../../../security/server';
import { OwnerEntity } from './types';

enum AuthorizationResult {
Unauthorized = 'Unauthorized',
Authorized = 'Authorized',
interface CreateAuditMsgParams {
operation: OperationDetails;
entity?: OwnerEntity;
error?: Error;
}

/**
Expand All @@ -19,106 +22,80 @@ enum AuthorizationResult {
export class AuthorizationAuditLogger {
private readonly auditLogger?: AuditLogger;

constructor(logger: AuditLogger | undefined) {
constructor(logger?: AuditLogger) {
this.auditLogger = logger;
}

private static createMessage({
result,
owners,
operation,
}: {
result: AuthorizationResult;
owners?: string[];
operation: OperationDetails;
}): string {
const ownerMsg = owners == null ? 'of any owner' : `with owners: "${owners.join(', ')}"`;
/**
* This will take the form:
* `Unauthorized to create case with owners: "securitySolution, observability"`
* `Unauthorized to find cases of any owner`.
*/
return `${result} to ${operation.verbs.present} ${operation.docType} ${ownerMsg}`;
}
/**
* Creates an AuditEvent describing the state of a request.
*/
private static createAuditMsg({ operation, error, entity }: CreateAuditMsgParams): AuditEvent {
const doc =
entity !== undefined
? `${operation.savedObjectType} [id=${entity.id}]`
: `a ${operation.docType}`;

private logSuccessEvent({
message,
operation,
username,
}: {
message: string;
operation: OperationDetails;
username?: string;
}) {
this.auditLogger?.log({
message: `${username ?? 'unknown user'} ${message}`,
const ownerText = entity === undefined ? 'as any owners' : `as owner "${entity.owner}"`;

let message: string;
let outcome: EcsEventOutcome;

if (error) {
message = `Failed attempt to ${operation.verbs.present} ${doc} ${ownerText}`;
outcome = ECS_OUTCOMES.failure;
} else if (isWriteOperation(operation)) {
message = `User is ${operation.verbs.progressive} ${doc} ${ownerText}`;
outcome = ECS_OUTCOMES.unknown;
} else {
message = `User has ${operation.verbs.past} ${doc} ${ownerText}`;
outcome = ECS_OUTCOMES.success;
}

return {
message,
event: {
action: operation.action,
category: DATABASE_CATEGORY,
type: [operation.type],
outcome: ECS_OUTCOMES.success,
type: [operation.ecsType],
outcome,
},
...(username != null && {
user: {
name: username,
...(entity !== undefined && {
kibana: {
saved_object: { type: operation.savedObjectType, id: entity.id },
},
}),
});
...(error !== undefined && {
error: {
code: error.name,
message: error.message,
},
}),
};
}

/**
* Creates a audit message describing a failure to authorize
* Creates a message to be passed to an Error or Boom.
*/
public failure({
username,
public static createFailureMessage({
owners,
operation,
}: {
username?: string;
owners?: string[];
owners: string[];
operation: OperationDetails;
}): string {
const message = AuthorizationAuditLogger.createMessage({
result: AuthorizationResult.Unauthorized,
owners,
operation,
});
this.auditLogger?.log({
message: `${username ?? 'unknown user'} ${message}`,
event: {
action: operation.action,
category: DATABASE_CATEGORY,
type: [operation.type],
outcome: ECS_OUTCOMES.failure,
},
// add the user information if we have it
...(username != null && {
user: {
name: username,
},
}),
});
return message;
}) {
const ownerMsg = owners.length <= 0 ? 'of any owner' : `with owners: "${owners.join(', ')}"`;
/**
* This will take the form:
* `Unauthorized to create case with owners: "securitySolution, observability"`
* `Unauthorized to access cases of any owner`
*/
return `Unauthorized to ${operation.verbs.present} ${operation.docType} ${ownerMsg}`;
}

/**
* Creates a audit message describing a successful authorization
* Logs an audit event based on the status of an operation.
*/
public success({
username,
operation,
owners,
}: {
username?: string;
owners: string[];
operation: OperationDetails;
}): string {
const message = AuthorizationAuditLogger.createMessage({
result: AuthorizationResult.Authorized,
owners,
operation,
});
this.logSuccessEvent({ message, operation, username });
return message;
public log(auditMsgParams: CreateAuditMsgParams) {
this.auditLogger?.log(AuthorizationAuditLogger.createAuditMsg(auditMsgParams));
}
}
Loading

0 comments on commit 739fd6f

Please sign in to comment.