Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Cases] RBAC Refactoring audit logging #100952

143 changes: 60 additions & 83 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 @@ -23,102 +26,76 @@ export class AuthorizationAuditLogger {
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));
}
}
98 changes: 69 additions & 29 deletions x-pack/plugins/cases/server/authorization/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { KibanaRequest, Logger } from 'kibana/server';
import Boom from '@hapi/boom';
import { SecurityPluginStart } from '../../../security/server';
import { PluginStartContract as FeaturesPluginStart } from '../../../features/server';
import { AuthorizationFilter, GetSpaceFn } from './types';
import { AuthFilterHelpers, GetSpaceFn } from './types';
import { getOwnersFilter } from './utils';
import { AuthorizationAuditLogger, OperationDetails } from '.';
import { createCaseError } from '../common';
import { OwnerEntity } from './types';

/**
* This class handles ensuring that the user making a request has the correct permissions
Expand Down Expand Up @@ -90,10 +91,49 @@ export class Authorization {
* Checks that the user making the request for the passed in owners and operation has the correct authorization. This
* function will throw if the user is not authorized for the requested operation and owners.
*
* @param owners an array of strings describing the case owners attempting to be authorized
* @param enitites an array of entities describing the case owners in conjunction with the saved object ID attempting
* to be authorized
* @param operation information describing the operation attempting to be authorized
*/
public async ensureAuthorized(owners: string[], operation: OperationDetails) {
public async ensureAuthorized({
entities,
operation,
}: {
entities: OwnerEntity[];
operation: OperationDetails;
}) {
const logSavedObjects = (error?: Error) => {
for (const entity of entities) {
this.auditLogger.log({ operation, error, entity });
}
};

try {
await this.ensureAuthorizedHelper(
entities.map((entity) => entity.owner),
operation
);

logSavedObjects();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in the very unlikely event that this call to logSavedObjects throws an exception, we will end up with both success and failure events in the audit logs. If we move logSavedObjects below this try block, then we will prevent this from happening

} catch (error) {
logSavedObjects(error);
throw error;
}
}

/**
* Returns an object to filter the saved object find request to the authorized owners of an entity.
*/
public async getAuthorizationFilter(operation: OperationDetails): Promise<AuthFilterHelpers> {
try {
return await this.getFindAuthorizationFilterHelper(operation);
} catch (error) {
this.auditLogger.log({ error, operation });
throw error;
}
}

private async ensureAuthorizedHelper(owners: string[], operation: OperationDetails) {
Copy link
Member

@cnasikas cnasikas Jun 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: What do you think about _ensureAuthorized?

const { securityAuth } = this;
const areAllOwnersAvailable = owners.every((owner) => this.featureCaseOwners.has(owner));

Expand All @@ -103,7 +143,7 @@ export class Authorization {
);

const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request);
const { hasAllRequested, username } = await checkPrivileges({
const { hasAllRequested } = await checkPrivileges({
kibana: requiredPrivileges,
});

Expand All @@ -115,55 +155,55 @@ export class Authorization {
* as Privileged.
* This check will ensure we don't accidentally let these through
*/
throw Boom.forbidden(this.auditLogger.failure({ username, owners, operation }));
throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation }));
}

if (hasAllRequested) {
this.auditLogger.success({ username, operation, owners });
} else {
throw Boom.forbidden(this.auditLogger.failure({ owners, operation, username }));
if (!hasAllRequested) {
throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation }));
}
} else if (!areAllOwnersAvailable) {
throw Boom.forbidden(this.auditLogger.failure({ owners, operation }));
throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation }));
}

// else security is disabled so let the operation proceed
}

/**
* Returns an object to filter the saved object find request to the authorized owners of an entity.
*/
public async getFindAuthorizationFilter(
private async getFindAuthorizationFilterHelper(
operation: OperationDetails
): Promise<AuthorizationFilter> {
): Promise<AuthFilterHelpers> {
const { securityAuth } = this;
if (securityAuth && this.shouldCheckAuthorization()) {
const { username, authorizedOwners } = await this.getAuthorizedOwners([operation]);
const { authorizedOwners } = await this.getAuthorizedOwners([operation]);

if (!authorizedOwners.length) {
throw Boom.forbidden(this.auditLogger.failure({ username, operation }));
throw Boom.forbidden(
AuthorizationAuditLogger.createFailureMessage({ owners: authorizedOwners, operation })
);
}

return {
filter: getOwnersFilter(operation.savedObjectType, authorizedOwners),
ensureSavedObjectIsAuthorized: (owner: string) => {
if (!authorizedOwners.includes(owner)) {
throw Boom.forbidden(
this.auditLogger.failure({ username, operation, owners: [owner] })
);
}
},
logSuccessfulAuthorization: () => {
if (authorizedOwners.length) {
this.auditLogger.success({ username, owners: authorizedOwners, operation });
ensureSavedObjectsAreAuthorized: (entities: OwnerEntity[]) => {
for (const entity of entities) {
if (!authorizedOwners.includes(entity.owner)) {
const error = Boom.forbidden(
AuthorizationAuditLogger.createFailureMessage({
operation,
owners: [entity.owner],
})
);
this.auditLogger.log({ error, operation, entity });
throw error;
}

this.auditLogger.log({ operation, entity });
}
},
};
}

return {
ensureSavedObjectIsAuthorized: (owner: string) => {},
logSuccessfulAuthorization: () => {},
ensureSavedObjectsAreAuthorized: (entities: OwnerEntity[]) => {},
};
}

Expand Down
23 changes: 23 additions & 0 deletions x-pack/plugins/cases/server/authorization/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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 { isWriteOperation, Operations } from '.';
import { OperationDetails } from './types';

describe('index tests', () => {
it('should identify a write operation', () => {
expect(isWriteOperation(Operations.createCase)).toBeTruthy();
});

it('should identify a read operation', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: should identify a read operation. That's the purpose of the this test, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah good point, I guess it doesn't necessarily mean that it is a read operation just that it's not a write operation 😆 I'll switch it to say should return false when the operation is not a write operation

expect(isWriteOperation(Operations.getCase)).toBeFalsy();
});

it('should not identify an invalid operation as a write operation', () => {
expect(isWriteOperation({ name: 'blah' } as OperationDetails)).toBeFalsy();
});
});
Loading