mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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>
This commit is contained in:
parent
e892b03173
commit
24f262b9ca
12 changed files with 223 additions and 15 deletions
|
@ -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,
|
||||
|
|
|
@ -27,3 +27,12 @@ export interface InitializeSavedObjectResponse {
|
|||
success: boolean;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
export interface DeleteJobCheckResponse {
|
||||
[jobId: string]: DeleteJobPermission;
|
||||
}
|
||||
|
||||
export interface DeleteJobPermission {
|
||||
canDelete: boolean;
|
||||
canUntag: boolean;
|
||||
}
|
||||
|
|
|
@ -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<string[] | null> {
|
||||
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<string[] | null> {
|
||||
const spaces = await getAllSpaces();
|
||||
if (spaces === null) {
|
||||
return null;
|
||||
}
|
||||
return spaces.map((s) => s.id);
|
||||
}
|
||||
|
||||
return { isMlEnabledInSpace, getAllSpaces };
|
||||
async function getMlSpaceIds(): Promise<string[] | null> {
|
||||
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 };
|
||||
}
|
||||
|
|
|
@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -150,6 +150,7 @@
|
|||
"AssignJobsToSpaces",
|
||||
"RemoveJobsFromSpaces",
|
||||
"JobsSpaces",
|
||||
"DeleteJobCheck",
|
||||
|
||||
"TrainedModels",
|
||||
"GetTrainedModel",
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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<SavedObjectsFindResult<JobObject> | 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,
|
||||
|
|
|
@ -31,6 +31,11 @@ export interface SystemRouteDeps {
|
|||
resolveMlCapabilities: ResolveMlCapabilities;
|
||||
}
|
||||
|
||||
export interface SavedObjectsRouteDeps {
|
||||
getSpaces?: () => Promise<SpacesPluginStart>;
|
||||
resolveMlCapabilities: ResolveMlCapabilities;
|
||||
}
|
||||
|
||||
export interface PluginsSetup {
|
||||
cloud: CloudSetup;
|
||||
features: FeaturesPluginSetup;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue