Skip to content

Commit 24f262b

Browse files
[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>
1 parent e892b03 commit 24f262b

File tree

12 files changed

+223
-15
lines changed

12 files changed

+223
-15
lines changed

x-pack/plugins/ml/common/types/capabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function getPluginPrivileges() {
123123
catalogue: [],
124124
savedObject: {
125125
all: [],
126-
read: ['ml-job'],
126+
read: [ML_SAVED_OBJECT_TYPE],
127127
},
128128
api: apmUserMlCapabilitiesKeys.map((k) => `ml:${k}`),
129129
ui: apmUserMlCapabilitiesKeys,

x-pack/plugins/ml/common/types/saved_objects.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,12 @@ export interface InitializeSavedObjectResponse {
2727
success: boolean;
2828
error?: any;
2929
}
30+
31+
export interface DeleteJobCheckResponse {
32+
[jobId: string]: DeleteJobPermission;
33+
}
34+
35+
export interface DeleteJobPermission {
36+
canDelete: boolean;
37+
canUntag: boolean;
38+
}

x-pack/plugins/ml/server/lib/spaces_utils.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { Legacy } from 'kibana';
88
import { KibanaRequest } from '../../../../../src/core/server';
99
import { SpacesPluginStart } from '../../../spaces/server';
10+
import { PLUGIN_ID } from '../../common/constants/app';
1011

1112
export type RequestFacade = KibanaRequest | Legacy.Request;
1213

@@ -22,19 +23,34 @@ export function spacesUtilsProvider(
2223
const space = await (await getSpacesPlugin()).spacesService.getActiveSpace(
2324
request instanceof KibanaRequest ? request : KibanaRequest.from(request)
2425
);
25-
return space.disabledFeatures.includes('ml') === false;
26+
return space.disabledFeatures.includes(PLUGIN_ID) === false;
2627
}
2728

28-
async function getAllSpaces(): Promise<string[] | null> {
29+
async function getAllSpaces() {
2930
if (getSpacesPlugin === undefined) {
3031
return null;
3132
}
3233
const client = (await getSpacesPlugin()).spacesService.createSpacesClient(
3334
request instanceof KibanaRequest ? request : KibanaRequest.from(request)
3435
);
35-
const spaces = await client.getAll();
36+
return await client.getAll();
37+
}
38+
39+
async function getAllSpaceIds(): Promise<string[] | null> {
40+
const spaces = await getAllSpaces();
41+
if (spaces === null) {
42+
return null;
43+
}
3644
return spaces.map((s) => s.id);
3745
}
3846

39-
return { isMlEnabledInSpace, getAllSpaces };
47+
async function getMlSpaceIds(): Promise<string[] | null> {
48+
const spaces = await getAllSpaces();
49+
if (spaces === null) {
50+
return null;
51+
}
52+
return spaces.filter((s) => s.disabledFeatures.includes(PLUGIN_ID) === false).map((s) => s.id);
53+
}
54+
55+
return { isMlEnabledInSpace, getAllSpaces, getAllSpaceIds, getMlSpaceIds };
4056
}

x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,9 @@ export class DataRecognizer {
10951095
job.config.analysis_limits.model_memory_limit = modelMemoryLimit;
10961096
}
10971097
} catch (error) {
1098-
mlLog.warn(`Data recognizer could not estimate model memory limit ${error.body}`);
1098+
mlLog.warn(
1099+
`Data recognizer could not estimate model memory limit ${JSON.stringify(error.body)}`
1100+
);
10991101
}
11001102
}
11011103

x-pack/plugins/ml/server/plugin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ export class MlServerPlugin
178178
notificationRoutes(routeInit);
179179
resultsServiceRoutes(routeInit);
180180
jobValidationRoutes(routeInit, this.version);
181-
savedObjectsRoutes(routeInit);
181+
savedObjectsRoutes(routeInit, {
182+
getSpaces,
183+
resolveMlCapabilities,
184+
});
182185
systemRoutes(routeInit, {
183186
getSpaces,
184187
cloud: plugins.cloud,

x-pack/plugins/ml/server/routes/apidoc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
"AssignJobsToSpaces",
151151
"RemoveJobsFromSpaces",
152152
"JobsSpaces",
153+
"DeleteJobCheck",
153154

154155
"TrainedModels",
155156
"GetTrainedModel",

x-pack/plugins/ml/server/routes/saved_objects.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
*/
66

77
import { wrapError } from '../client/error_wrapper';
8-
import { RouteInitialization } from '../types';
8+
import { RouteInitialization, SavedObjectsRouteDeps } from '../types';
99
import { checksFactory, repairFactory } from '../saved_objects';
10-
import { jobsAndSpaces, repairJobObjects } from './schemas/saved_objects';
10+
import { jobsAndSpaces, repairJobObjects, jobTypeSchema } from './schemas/saved_objects';
11+
import { jobIdsSchema } from './schemas/job_service_schema';
1112

1213
/**
1314
* Routes for job saved object management
1415
*/
15-
export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization) {
16+
export function savedObjectsRoutes(
17+
{ router, routeGuard }: RouteInitialization,
18+
{ getSpaces, resolveMlCapabilities }: SavedObjectsRouteDeps
19+
) {
1620
/**
1721
* @apiGroup JobSavedObjects
1822
*
@@ -220,4 +224,50 @@ export function savedObjectsRoutes({ router, routeGuard }: RouteInitialization)
220224
}
221225
})
222226
);
227+
228+
/**
229+
* @apiGroup JobSavedObjects
230+
*
231+
* @api {get} /api/ml/saved_objects/delete_job_check Check whether user can delete a job
232+
* @apiName DeleteJobCheck
233+
* @apiDescription Check the user's ability to delete jobs. Returns whether they are able
234+
* to fully delete the job and whether they are able to remove it from
235+
* the current space.
236+
*
237+
* @apiSchema (body) jobIdsSchema (params) jobTypeSchema
238+
*
239+
*/
240+
router.post(
241+
{
242+
path: '/api/ml/saved_objects/can_delete_job/{jobType}',
243+
validate: {
244+
params: jobTypeSchema,
245+
body: jobIdsSchema,
246+
},
247+
options: {
248+
tags: ['access:ml:canGetJobs', 'access:ml:canGetDataFrameAnalytics'],
249+
},
250+
},
251+
routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService, client }) => {
252+
try {
253+
const { jobType } = request.params;
254+
const { jobIds }: { jobIds: string[] } = request.body;
255+
256+
const { canDeleteJobs } = checksFactory(client, jobSavedObjectService);
257+
const body = await canDeleteJobs(
258+
request,
259+
jobType,
260+
jobIds,
261+
getSpaces !== undefined,
262+
resolveMlCapabilities
263+
);
264+
265+
return response.ok({
266+
body,
267+
});
268+
} catch (e) {
269+
return response.customError(wrapError(e));
270+
}
271+
})
272+
);
223273
}

x-pack/plugins/ml/server/routes/schemas/saved_objects.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ export const jobsAndSpaces = schema.object({
1313
});
1414

1515
export const repairJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) });
16+
17+
export const jobTypeSchema = schema.object({
18+
jobType: schema.string(),
19+
});

x-pack/plugins/ml/server/saved_objects/authorization.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { KibanaRequest } from 'kibana/server';
88
import type { SecurityPluginSetup } from '../../../security/server';
9+
import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects';
910

1011
export function authorizationProvider(authorization: SecurityPluginSetup['authz']) {
1112
async function authorizationCheck(request: KibanaRequest) {
@@ -18,7 +19,7 @@ export function authorizationProvider(authorization: SecurityPluginSetup['authz'
1819
request
1920
);
2021
const createMLJobAuthorizationAction = authorization.actions.savedObject.get(
21-
'ml-job',
22+
ML_SAVED_OBJECT_TYPE,
2223
'create'
2324
);
2425
const canCreateGlobally = (

x-pack/plugins/ml/server/saved_objects/checks.ts

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import { IScopedClusterClient } from 'kibana/server';
7+
import Boom from '@hapi/boom';
8+
import { IScopedClusterClient, KibanaRequest } from 'kibana/server';
89
import type { JobSavedObjectService } from './service';
9-
import { JobType } from '../../common/types/saved_objects';
10+
import { JobType, DeleteJobCheckResponse } from '../../common/types/saved_objects';
1011

1112
import { Job } from '../../common/types/anomaly_detection_jobs';
1213
import { Datafeed } from '../../common/types/anomaly_detection_jobs';
1314
import { DataFrameAnalyticsConfig } from '../../common/types/data_frame_analytics';
15+
import { ResolveMlCapabilities } from '../../common/types/capabilities';
1416

1517
interface JobSavedObjectStatus {
1618
jobId: string;
@@ -154,5 +156,105 @@ export function checksFactory(
154156
};
155157
}
156158

157-
return { checkStatus };
159+
async function canDeleteJobs(
160+
request: KibanaRequest,
161+
jobType: JobType,
162+
jobIds: string[],
163+
spacesEnabled: boolean,
164+
resolveMlCapabilities: ResolveMlCapabilities
165+
) {
166+
if (jobType !== 'anomaly-detector' && jobType !== 'data-frame-analytics') {
167+
throw Boom.badRequest('Job type must be "anomaly-detector" or "data-frame-analytics"');
168+
}
169+
170+
const mlCapabilities = await resolveMlCapabilities(request);
171+
if (mlCapabilities === null) {
172+
throw Boom.internal('mlCapabilities is not defined');
173+
}
174+
175+
if (
176+
(jobType === 'anomaly-detector' && mlCapabilities.canDeleteJob === false) ||
177+
(jobType === 'data-frame-analytics' && mlCapabilities.canDeleteDataFrameAnalytics === false)
178+
) {
179+
// user does not have access to delete jobs.
180+
return jobIds.reduce((results, jobId) => {
181+
results[jobId] = {
182+
canDelete: false,
183+
canUntag: false,
184+
};
185+
return results;
186+
}, {} as DeleteJobCheckResponse);
187+
}
188+
189+
if (spacesEnabled === false) {
190+
// spaces are disabled, delete only no untagging
191+
return jobIds.reduce((results, jobId) => {
192+
results[jobId] = {
193+
canDelete: true,
194+
canUntag: false,
195+
};
196+
return results;
197+
}, {} as DeleteJobCheckResponse);
198+
}
199+
const canCreateGlobalJobs = await jobSavedObjectService.canCreateGlobalJobs(request);
200+
201+
const jobObjects = await Promise.all(
202+
jobIds.map((id) => jobSavedObjectService.getJobObject(jobType, id))
203+
);
204+
205+
return jobIds.reduce((results, jobId) => {
206+
const jobObject = jobObjects.find((j) => j?.attributes.job_id === jobId);
207+
if (jobObject === undefined || jobObject.namespaces === undefined) {
208+
// job saved object not found
209+
results[jobId] = {
210+
canDelete: false,
211+
canUntag: false,
212+
};
213+
return results;
214+
}
215+
216+
const { namespaces } = jobObject;
217+
const isGlobalJob = namespaces.includes('*');
218+
219+
// job is in * space, user can see all spaces - delete and no option to untag
220+
if (canCreateGlobalJobs && isGlobalJob) {
221+
results[jobId] = {
222+
canDelete: true,
223+
canUntag: false,
224+
};
225+
return results;
226+
}
227+
228+
// job is in * space, user cannot see all spaces - no untagging, no deleting
229+
if (isGlobalJob) {
230+
results[jobId] = {
231+
canDelete: false,
232+
canUntag: false,
233+
};
234+
return results;
235+
}
236+
237+
// jobs with are in individual spaces can only be untagged
238+
// from current space if the job is in more than 1 space
239+
const canUntag = namespaces.length > 1;
240+
241+
// job is in individual spaces, user cannot see all of them - untag only, no delete
242+
if (namespaces.includes('?')) {
243+
results[jobId] = {
244+
canDelete: false,
245+
canUntag,
246+
};
247+
return results;
248+
}
249+
250+
// job is individual spaces, user can see all of them - delete and option to untag
251+
results[jobId] = {
252+
canDelete: true,
253+
canUntag,
254+
};
255+
return results;
256+
}, {} as DeleteJobCheckResponse);
257+
}
258+
259+
return { checkStatus, canDeleteJobs };
158260
}

0 commit comments

Comments
 (0)