From 24f262b9ca74f8e3e219ea417c2cd3889696f08c Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 24 Nov 2020 16:29:32 +0000 Subject: [PATCH] [ML] Space permision checks for job deletion (#83871) * [ML] Space permision checks for job deletion * updating spaces dependency * updating endpoint comments * adding delete job capabilities check * small change based on review * improving permissions checks * renaming function and endpoint Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/ml/common/types/capabilities.ts | 2 +- .../plugins/ml/common/types/saved_objects.ts | 9 ++ x-pack/plugins/ml/server/lib/spaces_utils.ts | 24 +++- .../models/data_recognizer/data_recognizer.ts | 4 +- x-pack/plugins/ml/server/plugin.ts | 5 +- x-pack/plugins/ml/server/routes/apidoc.json | 1 + .../plugins/ml/server/routes/saved_objects.ts | 56 ++++++++- .../ml/server/routes/schemas/saved_objects.ts | 4 + .../ml/server/saved_objects/authorization.ts | 3 +- .../plugins/ml/server/saved_objects/checks.ts | 108 +++++++++++++++++- .../ml/server/saved_objects/service.ts | 17 ++- x-pack/plugins/ml/server/types.ts | 5 + 12 files changed, 223 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index d708cd56b78dff..91020eee266022 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -123,7 +123,7 @@ export function getPluginPrivileges() { catalogue: [], savedObject: { all: [], - read: ['ml-job'], + read: [ML_SAVED_OBJECT_TYPE], }, api: apmUserMlCapabilitiesKeys.map((k) => `ml:${k}`), ui: apmUserMlCapabilitiesKeys, diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index 9f4d402ec1759b..d6c9ad758e8c66 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -27,3 +27,12 @@ export interface InitializeSavedObjectResponse { success: boolean; error?: any; } + +export interface DeleteJobCheckResponse { + [jobId: string]: DeleteJobPermission; +} + +export interface DeleteJobPermission { + canDelete: boolean; + canUntag: boolean; +} diff --git a/x-pack/plugins/ml/server/lib/spaces_utils.ts b/x-pack/plugins/ml/server/lib/spaces_utils.ts index b96fe6f2d1eb64..ecff3b8124cf5f 100644 --- a/x-pack/plugins/ml/server/lib/spaces_utils.ts +++ b/x-pack/plugins/ml/server/lib/spaces_utils.ts @@ -7,6 +7,7 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesPluginStart } from '../../../spaces/server'; +import { PLUGIN_ID } from '../../common/constants/app'; export type RequestFacade = KibanaRequest | Legacy.Request; @@ -22,19 +23,34 @@ export function spacesUtilsProvider( const space = await (await getSpacesPlugin()).spacesService.getActiveSpace( request instanceof KibanaRequest ? request : KibanaRequest.from(request) ); - return space.disabledFeatures.includes('ml') === false; + return space.disabledFeatures.includes(PLUGIN_ID) === false; } - async function getAllSpaces(): Promise { + async function getAllSpaces() { if (getSpacesPlugin === undefined) { return null; } const client = (await getSpacesPlugin()).spacesService.createSpacesClient( request instanceof KibanaRequest ? request : KibanaRequest.from(request) ); - const spaces = await client.getAll(); + return await client.getAll(); + } + + async function getAllSpaceIds(): Promise { + const spaces = await getAllSpaces(); + if (spaces === null) { + return null; + } return spaces.map((s) => s.id); } - return { isMlEnabledInSpace, getAllSpaces }; + async function getMlSpaceIds(): Promise { + const spaces = await getAllSpaces(); + if (spaces === null) { + return null; + } + return spaces.filter((s) => s.disabledFeatures.includes(PLUGIN_ID) === false).map((s) => s.id); + } + + return { isMlEnabledInSpace, getAllSpaces, getAllSpaceIds, getMlSpaceIds }; } diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index f875788d50c5ed..aeaf13ebf954e3 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -1095,7 +1095,9 @@ export class DataRecognizer { job.config.analysis_limits.model_memory_limit = modelMemoryLimit; } } catch (error) { - mlLog.warn(`Data recognizer could not estimate model memory limit ${error.body}`); + mlLog.warn( + `Data recognizer could not estimate model memory limit ${JSON.stringify(error.body)}` + ); } } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 5e103dbc1806ac..e48983c1c53656 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -178,7 +178,10 @@ export class MlServerPlugin notificationRoutes(routeInit); resultsServiceRoutes(routeInit); jobValidationRoutes(routeInit, this.version); - savedObjectsRoutes(routeInit); + savedObjectsRoutes(routeInit, { + getSpaces, + resolveMlCapabilities, + }); systemRoutes(routeInit, { getSpaces, cloud: plugins.cloud, diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index c157ae9e8200fb..5672824f3d040c 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -150,6 +150,7 @@ "AssignJobsToSpaces", "RemoveJobsFromSpaces", "JobsSpaces", + "DeleteJobCheck", "TrainedModels", "GetTrainedModel", diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index 1c9c975b662699..3ba69b0d6b5058 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -5,14 +5,18 @@ */ import { wrapError } from '../client/error_wrapper'; -import { RouteInitialization } from '../types'; +import { RouteInitialization, SavedObjectsRouteDeps } from '../types'; import { checksFactory, repairFactory } from '../saved_objects'; -import { jobsAndSpaces, repairJobObjects } from './schemas/saved_objects'; +import { jobsAndSpaces, repairJobObjects, jobTypeSchema } from './schemas/saved_objects'; +import { jobIdsSchema } from './schemas/job_service_schema'; /** * Routes for job saved object management */ -export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) { +export function savedObjectsRoutes( + { router, routeGuard }: RouteInitialization, + { getSpaces, resolveMlCapabilities }: SavedObjectsRouteDeps +) { /** * @apiGroup JobSavedObjects * @@ -220,4 +224,50 @@ export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) } }) ); + + /** + * @apiGroup JobSavedObjects + * + * @api {get} /api/ml/saved_objects/delete_job_check Check whether user can delete a job + * @apiName DeleteJobCheck + * @apiDescription Check the user's ability to delete jobs. Returns whether they are able + * to fully delete the job and whether they are able to remove it from + * the current space. + * + * @apiSchema (body) jobIdsSchema (params) jobTypeSchema + * + */ + router.post( + { + path: '/api/ml/saved_objects/can_delete_job/{jobType}', + validate: { + params: jobTypeSchema, + body: jobIdsSchema, + }, + options: { + tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService, client }) => { + try { + const { jobType } = request.params; + const { jobIds }: { jobIds: string[] } = request.body; + + const { canDeleteJobs } = checksFactory(client, jobSavedObjectService); + const body = await canDeleteJobs( + request, + jobType, + jobIds, + getSpaces !== undefined, + resolveMlCapabilities + ); + + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index d7385f6468f465..6b8c64714a82cc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -13,3 +13,7 @@ export const jobsAndSpaces = schema.object({ }); export const repairJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) }); + +export const jobTypeSchema = schema.object({ + jobType: schema.string(), +}); diff --git a/x-pack/plugins/ml/server/saved_objects/authorization.ts b/x-pack/plugins/ml/server/saved_objects/authorization.ts index 815ff29ae010c0..958ee2091f11e6 100644 --- a/x-pack/plugins/ml/server/saved_objects/authorization.ts +++ b/x-pack/plugins/ml/server/saved_objects/authorization.ts @@ -6,6 +6,7 @@ import { KibanaRequest } from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; +import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; export function authorizationProvider(authorization: SecurityPluginSetup['authz']) { async function authorizationCheck(request: KibanaRequest) { @@ -18,7 +19,7 @@ export function authorizationProvider(authorization: SecurityPluginSetup['authz' request ); const createMLJobAuthorizationAction = authorization.actions.savedObject.get( - 'ml-job', + ML_SAVED_OBJECT_TYPE, 'create' ); const canCreateGlobally = ( diff --git a/x-pack/plugins/ml/server/saved_objects/checks.ts b/x-pack/plugins/ml/server/saved_objects/checks.ts index 51269599105da2..f682999cd59666 100644 --- a/x-pack/plugins/ml/server/saved_objects/checks.ts +++ b/x-pack/plugins/ml/server/saved_objects/checks.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IScopedClusterClient } from 'kibana/server'; +import Boom from '@hapi/boom'; +import { IScopedClusterClient, KibanaRequest } from 'kibana/server'; import type { JobSavedObjectService } from './service'; -import { JobType } from '../../common/types/saved_objects'; +import { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects'; import { Job } from '../../common/types/anomaly_detection_jobs'; import { Datafeed } from '../../common/types/anomaly_detection_jobs'; import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics'; +import { ResolveMlCapabilities } from '../../common/types/capabilities'; interface JobSavedObjectStatus { jobId: string; @@ -154,5 +156,105 @@ export function checksFactory( }; } - return { checkStatus }; + async function canDeleteJobs( + request: KibanaRequest, + jobType: JobType, + jobIds: string[], + spacesEnabled: boolean, + resolveMlCapabilities: ResolveMlCapabilities + ) { + if (jobType !== 'anomaly-detector' && jobType !== 'data-frame-analytics') { + throw Boom.badRequest('Job type must be "anomaly-detector" or "data-frame-analytics"'); + } + + const mlCapabilities = await resolveMlCapabilities(request); + if (mlCapabilities === null) { + throw Boom.internal('mlCapabilities is not defined'); + } + + if ( + (jobType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) || + (jobType === 'data-frame-analytics' && mlCapabilities.canDeleteDataFrameAnalytics === false) + ) { + // user does not have access to delete jobs. + return jobIds.reduce((results, jobId) => { + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + + if (spacesEnabled === false) { + // spaces are disabled, delete only no untagging + return jobIds.reduce((results, jobId) => { + results[jobId] = { + canDelete: true, + canUntag: false, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + const canCreateGlobalJobs = await jobSavedObjectService.canCreateGlobalJobs(request); + + const jobObjects = await Promise.all( + jobIds.map((id) => jobSavedObjectService.getJobObject(jobType, id)) + ); + + return jobIds.reduce((results, jobId) => { + const jobObject = jobObjects.find((j) => j?.attributes.job_id === jobId); + if (jobObject === undefined || jobObject.namespaces === undefined) { + // job saved object not found + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + } + + const { namespaces } = jobObject; + const isGlobalJob = namespaces.includes('*'); + + // job is in * space, user can see all spaces - delete and no option to untag + if (canCreateGlobalJobs && isGlobalJob) { + results[jobId] = { + canDelete: true, + canUntag: false, + }; + return results; + } + + // job is in * space, user cannot see all spaces - no untagging, no deleting + if (isGlobalJob) { + results[jobId] = { + canDelete: false, + canUntag: false, + }; + return results; + } + + // jobs with are in individual spaces can only be untagged + // from current space if the job is in more than 1 space + const canUntag = namespaces.length > 1; + + // job is in individual spaces, user cannot see all of them - untag only, no delete + if (namespaces.includes('?')) { + results[jobId] = { + canDelete: false, + canUntag, + }; + return results; + } + + // job is individual spaces, user can see all of them - delete and option to untag + results[jobId] = { + canDelete: true, + canUntag, + }; + return results; + }, {} as DeleteJobCheckResponse); + } + + return { checkStatus, canDeleteJobs }; } diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index ecaf0869d196c7..bfc5b165fe555a 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -5,7 +5,12 @@ */ import RE2 from 're2'; -import { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindOptions } from 'kibana/server'; +import { + KibanaRequest, + SavedObjectsClientContract, + SavedObjectsFindOptions, + SavedObjectsFindResult, +} from 'kibana/server'; import type { SecurityPluginSetup } from '../../../security/server'; import { JobType, ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; import { MLJobNotFound } from '../lib/ml_client'; @@ -133,6 +138,15 @@ export function jobSavedObjectServiceFactory( return await _getJobObjects(jobType, undefined, undefined, currentSpaceOnly); } + async function getJobObject( + jobType: JobType, + jobId: string, + currentSpaceOnly: boolean = true + ): Promise | undefined> { + const [jobObject] = await _getJobObjects(jobType, jobId, undefined, currentSpaceOnly); + return jobObject; + } + async function getAllJobObjectsForAllSpaces(jobType?: JobType) { await isMlReady(); const filterObject: JobObjectFilter = {}; @@ -307,6 +321,7 @@ export function jobSavedObjectServiceFactory( return { getAllJobObjects, + getJobObject, createAnomalyDetectionJob, createDataFrameAnalyticsJob, deleteAnomalyDetectionJob, diff --git a/x-pack/plugins/ml/server/types.ts b/x-pack/plugins/ml/server/types.ts index df40f5a26b0f35..780a4284312e71 100644 --- a/x-pack/plugins/ml/server/types.ts +++ b/x-pack/plugins/ml/server/types.ts @@ -31,6 +31,11 @@ export interface SystemRouteDeps { resolveMlCapabilities: ResolveMlCapabilities; } +export interface SavedObjectsRouteDeps { + getSpaces?: () => Promise; + resolveMlCapabilities: ResolveMlCapabilities; +} + export interface PluginsSetup { cloud: CloudSetup; features: FeaturesPluginSetup;