-
Notifications
You must be signed in to change notification settings - Fork 8.5k
[Cases] Authorization and Client Audit Logger #95477
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
Changes from all commits
8793b2a
21b20aa
25d2403
400601b
ffc81ec
188186e
62e4d24
2b51111
e8e03ec
1c57dcf
0b1db8d
94a3a59
32242b2
4b4691b
1a33a7e
1da64bc
f2da6d0
01c2edf
b1ab09f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,131 @@ | ||||
| /* | ||||
| * 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 { OperationDetails } from '.'; | ||||
| import { AuditLogger, EventCategory, EventOutcome } from '../../../security/server'; | ||||
|
|
||||
| enum AuthorizationResult { | ||||
| Unauthorized = 'Unauthorized', | ||||
| Authorized = 'Authorized', | ||||
| } | ||||
|
|
||||
| export class AuthorizationAuditLogger { | ||||
| private readonly auditLogger?: AuditLogger; | ||||
|
|
||||
| constructor(logger: AuditLogger | undefined) { | ||||
| this.auditLogger = logger; | ||||
| } | ||||
|
|
||||
| private createMessage({ | ||||
| result, | ||||
| owner, | ||||
| operation, | ||||
| }: { | ||||
| result: AuthorizationResult; | ||||
| owner?: string; | ||||
| operation: OperationDetails; | ||||
| }): string { | ||||
| const ownerMsg = owner == null ? 'of any owner' : `with "${owner}" as the owner`; | ||||
| /** | ||||
| * This will take the form: | ||||
| * `Unauthorized to create case with "securitySolution" as the owner` | ||||
| * `Unauthorized to find cases of any owner`. | ||||
| */ | ||||
| return `${result} to ${operation.verbs.present} ${operation.docType} ${ownerMsg}`; | ||||
| } | ||||
|
|
||||
| private logSuccessEvent({ | ||||
| message, | ||||
| operation, | ||||
| username, | ||||
| }: { | ||||
| message: string; | ||||
| operation: OperationDetails; | ||||
| username?: string; | ||||
| }) { | ||||
| this.auditLogger?.log({ | ||||
| message: `${username ?? 'unknown user'} ${message}`, | ||||
| event: { | ||||
| action: operation.action, | ||||
| category: EventCategory.DATABASE, | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think we should create
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no official @legrego should we use
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since I expect these events are recording CRUD operations against
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks Larry!
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see! I wasn't aware of the ECS mapping. Thank you both! |
||||
| type: operation.type, | ||||
| outcome: EventOutcome.SUCCESS, | ||||
| }, | ||||
| ...(username != null && { | ||||
| user: { | ||||
| name: username, | ||||
| }, | ||||
| }), | ||||
| }); | ||||
| } | ||||
|
|
||||
| public failure({ | ||||
| username, | ||||
| owner, | ||||
| operation, | ||||
| }: { | ||||
| username?: string; | ||||
| owner?: string; | ||||
| operation: OperationDetails; | ||||
| }): string { | ||||
| const message = this.createMessage({ | ||||
| result: AuthorizationResult.Unauthorized, | ||||
| owner, | ||||
| operation, | ||||
| }); | ||||
| this.auditLogger?.log({ | ||||
| message: `${username ?? 'unknown user'} ${message}`, | ||||
| event: { | ||||
| action: operation.action, | ||||
| category: EventCategory.DATABASE, | ||||
| type: operation.type, | ||||
| outcome: EventOutcome.FAILURE, | ||||
| }, | ||||
| // add the user information if we have it | ||||
| ...(username != null && { | ||||
| user: { | ||||
| name: username, | ||||
| }, | ||||
| }), | ||||
| }); | ||||
| return message; | ||||
| } | ||||
|
|
||||
| public success({ | ||||
| username, | ||||
| operation, | ||||
| owner, | ||||
| }: { | ||||
| username: string; | ||||
| owner: string; | ||||
| operation: OperationDetails; | ||||
| }): string { | ||||
| const message = this.createMessage({ | ||||
| result: AuthorizationResult.Authorized, | ||||
| owner, | ||||
| operation, | ||||
| }); | ||||
| this.logSuccessEvent({ message, operation, username }); | ||||
| return message; | ||||
| } | ||||
|
|
||||
| public bulkSuccess({ | ||||
| username, | ||||
| operation, | ||||
| owners, | ||||
| }: { | ||||
| username?: string; | ||||
| owners: string[]; | ||||
| operation: OperationDetails; | ||||
| }): string { | ||||
| const message = `${AuthorizationResult.Authorized} to ${operation.verbs.present} ${ | ||||
| operation.docType | ||||
| } of owner: ${owners.join(', ')}`; | ||||
| this.logSuccessEvent({ message, operation, username }); | ||||
| return message; | ||||
| } | ||||
| } | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,11 +7,11 @@ | |
|
|
||
| import { KibanaRequest } from 'kibana/server'; | ||
| import Boom from '@hapi/boom'; | ||
| import { KueryNode } from '../../../../../src/plugins/data/server'; | ||
| import { SecurityPluginStart } from '../../../security/server'; | ||
| import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; | ||
| import { GetSpaceFn, ReadOperations, WriteOperations } from './types'; | ||
| import { AuthorizationFilter, GetSpaceFn } from './types'; | ||
| import { getOwnersFilter } from './utils'; | ||
| import { AuthorizationAuditLogger, OperationDetails, Operations } from '.'; | ||
|
|
||
| /** | ||
| * This class handles ensuring that the user making a request has the correct permissions | ||
|
|
@@ -21,25 +21,23 @@ export class Authorization { | |
| private readonly request: KibanaRequest; | ||
| private readonly securityAuth: SecurityPluginStart['authz'] | undefined; | ||
| private readonly featureCaseOwners: Set<string>; | ||
| private readonly isAuthEnabled: boolean; | ||
| // TODO: create this | ||
| // private readonly auditLogger: AuthorizationAuditLogger; | ||
| private readonly auditLogger: AuthorizationAuditLogger; | ||
|
|
||
| private constructor({ | ||
| request, | ||
| securityAuth, | ||
| caseOwners, | ||
| isAuthEnabled, | ||
| auditLogger, | ||
| }: { | ||
| request: KibanaRequest; | ||
| securityAuth?: SecurityPluginStart['authz']; | ||
| caseOwners: Set<string>; | ||
| isAuthEnabled: boolean; | ||
| auditLogger: AuthorizationAuditLogger; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to create a full mock for the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see what you mean. I have a dilemma about that. From the perspective of a consumer of the |
||
| }) { | ||
| this.request = request; | ||
| this.securityAuth = securityAuth; | ||
| this.featureCaseOwners = caseOwners; | ||
| this.isAuthEnabled = isAuthEnabled; | ||
| this.auditLogger = auditLogger; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -50,13 +48,13 @@ export class Authorization { | |
| securityAuth, | ||
| getSpace, | ||
| features, | ||
| isAuthEnabled, | ||
| auditLogger, | ||
| }: { | ||
| request: KibanaRequest; | ||
| securityAuth?: SecurityPluginStart['authz']; | ||
| getSpace: GetSpaceFn; | ||
| features: FeaturesPluginStart; | ||
| isAuthEnabled: boolean; | ||
| auditLogger: AuthorizationAuditLogger; | ||
| }): Promise<Authorization> { | ||
| // Since we need to do async operations, this static method handles that before creating the Auth class | ||
| let caseOwners: Set<string>; | ||
|
|
@@ -74,102 +72,81 @@ export class Authorization { | |
| caseOwners = new Set<string>(); | ||
| } | ||
|
|
||
| return new Authorization({ request, securityAuth, caseOwners, isAuthEnabled }); | ||
| return new Authorization({ request, securityAuth, caseOwners, auditLogger }); | ||
| } | ||
|
|
||
| private shouldCheckAuthorization(): boolean { | ||
| return this.securityAuth?.mode?.useRbacForRequest(this.request) ?? false; | ||
| } | ||
|
|
||
| public async ensureAuthorized(owner: string, operation: ReadOperations | WriteOperations) { | ||
| // TODO: remove | ||
| if (!this.isAuthEnabled) { | ||
| return; | ||
| } | ||
|
|
||
| public async ensureAuthorized(owner: string, operation: OperationDetails) { | ||
| const { securityAuth } = this; | ||
| const isOwnerAvailable = this.featureCaseOwners.has(owner); | ||
|
|
||
| // TODO: throw if the request is not authorized | ||
| if (securityAuth && this.shouldCheckAuthorization()) { | ||
| // TODO: implement ensure logic | ||
| const requiredPrivileges: string[] = [securityAuth.actions.cases.get(owner, operation)]; | ||
| const requiredPrivileges: string[] = [securityAuth.actions.cases.get(owner, operation.name)]; | ||
|
|
||
| const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request); | ||
| const { hasAllRequested, username, privileges } = await checkPrivileges({ | ||
| const { hasAllRequested, username } = await checkPrivileges({ | ||
| kibana: requiredPrivileges, | ||
| }); | ||
|
|
||
| if (!isOwnerAvailable) { | ||
| // TODO: throw if any of the owner are not available | ||
| /** | ||
| * Under most circumstances this would have been caught by `checkPrivileges` as | ||
| * a user can't have Privileges to an unknown owner, but super users | ||
| * don't actually get "privilege checked" so the made up owner *will* return | ||
| * as Privileged. | ||
| * This check will ensure we don't accidentally let these through | ||
| */ | ||
| // TODO: audit log using `username` | ||
| throw Boom.forbidden('User does not have permissions for this owner'); | ||
| throw Boom.forbidden(this.auditLogger.failure({ username, owner, operation })); | ||
| } | ||
|
|
||
| if (hasAllRequested) { | ||
| // TODO: user authorized. log success | ||
| this.auditLogger.success({ username, operation, owner }); | ||
| } else { | ||
| const authorizedPrivileges = privileges.kibana.reduce<string[]>((acc, privilege) => { | ||
| if (privilege.authorized) { | ||
| return [...acc, privilege.privilege]; | ||
| } | ||
| return acc; | ||
| }, []); | ||
|
|
||
| const unauthorizedPrivilages = requiredPrivileges.filter( | ||
| (privilege) => !authorizedPrivileges.includes(privilege) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I looked through the alerting code and it seems like they're only using these to determine the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make sense to me! |
||
| ); | ||
|
|
||
| // TODO: audit log | ||
| // TODO: User unauthorized. throw an error. authorizedPrivileges & unauthorizedPrivilages are needed for logging. | ||
| throw Boom.forbidden('Not authorized for this owner'); | ||
| throw Boom.forbidden(this.auditLogger.failure({ owner, operation, username })); | ||
| } | ||
| } else if (!isOwnerAvailable) { | ||
| // TODO: throw an error | ||
| throw Boom.forbidden('Security is disabled but no owner was found'); | ||
| throw Boom.forbidden(this.auditLogger.failure({ owner, operation })); | ||
| } | ||
|
|
||
| // else security is disabled so let the operation proceed | ||
| } | ||
|
|
||
| public async getFindAuthorizationFilter( | ||
| savedObjectType: string | ||
| ): Promise<{ | ||
| filter?: KueryNode; | ||
| ensureSavedObjectIsAuthorized: (owner: string) => void; | ||
| }> { | ||
| public async getFindAuthorizationFilter(savedObjectType: string): Promise<AuthorizationFilter> { | ||
| const { securityAuth } = this; | ||
| const operation = Operations.findCases; | ||
| if (securityAuth && this.shouldCheckAuthorization()) { | ||
| const { authorizedOwners } = await this.getAuthorizedOwners([ReadOperations.Find]); | ||
| const { username, authorizedOwners } = await this.getAuthorizedOwners([operation]); | ||
|
|
||
| if (!authorizedOwners.length) { | ||
| // TODO: Better error message, log error | ||
| throw Boom.forbidden('Not authorized for this owner'); | ||
| throw Boom.forbidden(this.auditLogger.failure({ username, operation })); | ||
| } | ||
|
|
||
| return { | ||
| filter: getOwnersFilter(savedObjectType, authorizedOwners), | ||
| ensureSavedObjectIsAuthorized: (owner: string) => { | ||
| if (!authorizedOwners.includes(owner)) { | ||
| // TODO: log error | ||
| throw Boom.forbidden('Not authorized for this owner'); | ||
| throw Boom.forbidden(this.auditLogger.failure({ username, operation, owner })); | ||
| } | ||
| }, | ||
| logSuccessfulAuthorization: () => { | ||
| if (authorizedOwners.length) { | ||
| this.auditLogger.bulkSuccess({ username, owners: authorizedOwners, operation }); | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| return { ensureSavedObjectIsAuthorized: (owner: string) => {} }; | ||
| return { | ||
| ensureSavedObjectIsAuthorized: (owner: string) => {}, | ||
| logSuccessfulAuthorization: () => {}, | ||
| }; | ||
| } | ||
|
|
||
| private async getAuthorizedOwners( | ||
| operations: Array<ReadOperations | WriteOperations> | ||
| operations: OperationDetails[] | ||
| ): Promise<{ | ||
| username?: string; | ||
| hasAllRequested: boolean; | ||
|
|
@@ -182,7 +159,7 @@ export class Authorization { | |
|
|
||
| for (const owner of featureCaseOwners) { | ||
| for (const operation of operations) { | ||
| requiredPrivileges.set(securityAuth.actions.cases.get(owner, operation), [owner]); | ||
| requiredPrivileges.set(securityAuth.actions.cases.get(owner, operation.name), [owner]); | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why it can be
undefined?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's because we get the
AuditLoggerfrom the security plugin and the security plugin can be undefined (my guess is that'll happen when security is disabled).