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

[ResponseOps][Cases] User suggestion API #137346

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions x-pack/plugins/cases/common/api/cases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export * from './status';
export * from './user_actions';
export * from './constants';
export * from './alerts';
export * from './suggest_user_profiles';
jonathan-buttner marked this conversation as resolved.
Show resolved Hide resolved
18 changes: 18 additions & 0 deletions x-pack/plugins/cases/common/api/cases/suggest_user_profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
* 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 * as rt from 'io-ts';

export const SuggestUserProfilesRequestRt = rt.intersection([
rt.type({
name: rt.string,
owners: rt.array(rt.string),
}),
rt.partial({ size: rt.number }),
]);

export type SuggestUserProfilesRequest = rt.TypeOf<typeof SuggestUserProfilesRequestRt>;
2 changes: 2 additions & 0 deletions x-pack/plugins/cases/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}` as cons
export const CASES_INTERNAL_URL = '/internal/cases' as const;
export const INTERNAL_BULK_CREATE_ATTACHMENTS_URL =
`${CASES_INTERNAL_URL}/{case_id}/attachments/_bulk_create` as const;
export const INTERNAL_SUGGEST_USER_PROFILES_URL =
`${CASES_INTERNAL_URL}/_suggest_user_profiles` as const;

/**
* Action routes
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/cases/server/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
cases: [APP_ID],
privileges: {
all: {
api: ['casesSuggestAssignees'],
jonathan-buttner marked this conversation as resolved.
Show resolved Hide resolved
cases: {
create: [APP_ID],
read: [APP_ID],
Expand Down
11 changes: 10 additions & 1 deletion x-pack/plugins/cases/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { createCasesTelemetry, scheduleCasesTelemetryTask } from './telemetry';
import { getInternalRoutes } from './routes/api/get_internal_routes';
import { PersistableStateAttachmentTypeRegistry } from './attachment_framework/persistable_state_registry';
import { ExternalReferenceAttachmentTypeRegistry } from './attachment_framework/external_reference_registry';
import { UserProfileService } from './services';

export interface PluginsSetup {
actions: ActionsPluginSetup;
Expand All @@ -77,13 +78,15 @@ export class CasePlugin {
private lensEmbeddableFactory?: LensServerPluginSetup['lensEmbeddableFactory'];
private persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
private externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry;
private userProfileService: UserProfileService;

constructor(private readonly initializerContext: PluginInitializerContext) {
this.kibanaVersion = initializerContext.env.packageInfo.version;
this.logger = this.initializerContext.logger.get();
this.clientFactory = new CasesClientFactory(this.logger);
this.persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry();
this.externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
this.userProfileService = new UserProfileService(this.logger);
}

public setup(core: CoreSetup, plugins: PluginsSetup): PluginSetupContract {
Expand Down Expand Up @@ -138,7 +141,7 @@ export class CasePlugin {

registerRoutes({
router,
routes: [...getExternalRoutes(), ...getInternalRoutes()],
routes: [...getExternalRoutes(), ...getInternalRoutes(this.userProfileService)],
logger: this.logger,
kibanaVersion: this.kibanaVersion,
telemetryUsageCounter,
Expand All @@ -163,6 +166,12 @@ export class CasePlugin {
scheduleCasesTelemetryTask(plugins.taskManager, this.logger);
}

this.userProfileService.initialize({
jonathan-buttner marked this conversation as resolved.
Show resolved Hide resolved
spaces: plugins.spaces,
securityPluginSetup: this.securityPluginSetup,
securityPluginStart: plugins.security,
});

this.clientFactory.initialize({
securityPluginSetup: this.securityPluginSetup,
securityPluginStart: plugins.security,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
* 2.0.
*/

import { UserProfileService } from '../../services';
import { bulkCreateAttachmentsRoute } from './internal/bulk_create_attachments';
import { suggestUserProfilesRoute } from './internal/suggest_user_profiles';
import { CaseRoute } from './types';

export const getInternalRoutes = () => [bulkCreateAttachmentsRoute] as CaseRoute[];
export const getInternalRoutes = (userProfileService: UserProfileService) =>
[bulkCreateAttachmentsRoute, suggestUserProfilesRoute(userProfileService)] as CaseRoute[];
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 { UserProfileService } from '../../../services';
import { INTERNAL_SUGGEST_USER_PROFILES_URL } from '../../../../common/constants';
import { createCaseError } from '../../../common/error';
import { createCasesRoute } from '../create_cases_route';
import { escapeHatch } from '../utils';

export const suggestUserProfilesRoute = (userProfileService: UserProfileService) =>
createCasesRoute({
method: 'post',
path: INTERNAL_SUGGEST_USER_PROFILES_URL,
routerOptions: {
tags: ['access:casesSuggestAssignees'],
},
params: {
body: escapeHatch,
},
handler: async ({ request, response }) => {
try {
return response.ok({
body: await userProfileService.suggest(request),
jonathan-buttner marked this conversation as resolved.
Show resolved Hide resolved
});
} catch (error) {
throw createCaseError({
message: `Failed to find user profiles: ${error}`,
error,
});
}
},
});
3 changes: 2 additions & 1 deletion x-pack/plugins/cases/server/routes/api/register_routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ export const registerRoutes = (deps: RegisterRoutesDeps) => {
const { router, routes, logger, kibanaVersion, telemetryUsageCounter } = deps;

routes.forEach((route) => {
const { method, path, params, options, handler } = route;
const { method, path, params, options, routerOptions, handler } = route;

(router[method] as RouteRegistrar<typeof method, CasesRequestHandlerContext>)(
{
path,
options: routerOptions,
validate: {
params: params?.params ?? escapeHatch,
query: params?.query ?? escapeHatch,
Expand Down
9 changes: 9 additions & 0 deletions x-pack/plugins/cases/server/routes/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,19 @@ interface CaseRouteHandlerArguments<P, Q, B> {
kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
}

type CaseRouteTags = 'access:casesSuggestAssignees';

export interface CaseRoute<P = unknown, Q = unknown, B = unknown> {
method: 'get' | 'post' | 'put' | 'delete' | 'patch';
path: string;
params?: RouteValidatorConfig<P, Q, B>;
/**
* These options control pre-route execution behavior
*/
options?: { deprecated?: boolean };
/**
* These options are passed to the router's options field
*/
routerOptions?: { tags: CaseRouteTags[] };
handler: (args: CaseRouteHandlerArguments<P, Q, B>) => Promise<IKibanaResponse>;
}
1 change: 1 addition & 0 deletions x-pack/plugins/cases/server/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { CaseUserActionService } from './user_actions';
export { ConnectorMappingsService } from './connector_mappings';
export { AlertService } from './alerts';
export { AttachmentService } from './attachments';
export { UserProfileService } from './user_profiles';

export interface ClientArgs {
unsecuredSavedObjectsClient: SavedObjectsClientContract;
Expand Down
117 changes: 117 additions & 0 deletions x-pack/plugins/cases/server/services/user_profiles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* 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 Boom from '@hapi/boom';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';

import { KibanaRequest, Logger } from '@kbn/core/server';
import {
SecurityPluginSetup,
SecurityPluginStart,
UserProfileServiceStart,
} from '@kbn/security-plugin/server';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';

import { excess, SuggestUserProfilesRequestRt, throwErrors } from '../../../common/api';
import { Operations } from '../../authorization';
import { createCaseError } from '../../common/error';

interface UserProfileOptions {
securityPluginSetup?: SecurityPluginSetup;
securityPluginStart?: SecurityPluginStart;
Comment on lines +26 to +27
Copy link
Member

Choose a reason for hiding this comment

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

question: I remember you were considering making security as a required dependency, is there any reason you decided not to do that?

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 I found that the security plugin can still be disabled. The functionality to disable it was moved into Elasticsearch. When we initially developed the cases RBAC we intentionally allowed the security plugin to be disabled. It was something that Larry suggested we support. So I didn't want to deviate from that in this PR. I think some users want to play around with Kibana without security enable and it'd be nice for them to still have access to cases in that situation.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah I found that the security plugin can still be disabled. The functionality to disable it was moved into Elasticsearch.

Yeah, but you'll still get security contract even if Security is disabled in Elasticsearch and can replace securityPluginStart?: SecurityPluginStart; with securityPluginStart: SecurityPluginStart; to have less "undefined" checks (since you need to rely on the availabilty of the security feature via license check anyway). But it's minor, just wanted to point this out.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah ok, good to know. I'll create an issue to fix that up

Copy link
Contributor Author

Choose a reason for hiding this comment

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

spaces: SpacesPluginStart;
}

export class UserProfileService {
private options?: UserProfileOptions;

constructor(private readonly logger: Logger) {}

public initialize(options: UserProfileOptions) {
if (this.options !== undefined) {
throw new Error('UserProfileService was already initialized');
}

this.options = options;
}

public async suggest(request: KibanaRequest): ReturnType<UserProfileServiceStart['suggest']> {
Copy link
Member

Choose a reason for hiding this comment

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

nit: would be a little bit more readable (requires import type { UserProfile } from '@kbn/security-plugin/common';)

Suggested change
public async suggest(request: KibanaRequest): ReturnType<UserProfileServiceStart['suggest']> {
public async suggest(request: KibanaRequest): Promise<UserProfile[]> {

Alternatively you can change line 65 to return [] as UserProfile[]; and remove return type signature completely since it can be inferred automatically, up to you.

const params = pipe(
excess(SuggestUserProfilesRequestRt).decode(request.body),
fold(throwErrors(Boom.badRequest), identity)
);

const { name, size, owners } = params;

try {
if (this.options === undefined) {
throw new Error('UserProfileService must be initialized before calling suggest');
}

const { spaces } = this.options;

const securityPluginFields = {
securityPluginSetup: this.options.securityPluginSetup,
Copy link
Member

Choose a reason for hiding this comment

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

note to myself: we need to expose license on the start contract too so that you can rely just on a single contract.

securityPluginStart: this.options.securityPluginStart,
};

if (!UserProfileService.isSecurityEnabled(securityPluginFields)) {
return [];
}

const { securityPluginStart } = securityPluginFields;

return securityPluginStart.userProfiles.suggest({
name,
size,
dataPath: 'avatar',
requiredPrivileges: {
spaceId: spaces.spacesService.getSpaceId(request),
privileges: {
kibana: UserProfileService.buildRequiredPrivileges(owners, securityPluginStart),
},
},
});
} catch (error) {
throw createCaseError({
logger: this.logger,
message: `Failed to retrieve suggested user profiles in service for name: ${name} owners: [${owners.join(
','
)}]`,
Copy link
Member

Choose a reason for hiding this comment

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

optional nit: join(',') is actually done automatically when array is used in the template string, we usually use join explicitly only if we want another separator (e.g. comma + space).

Suggested change
message: `Failed to retrieve suggested user profiles in service for name: ${name} owners: [${owners.join(
','
)}]`,
message: `Failed to retrieve suggested user profiles in service for name: ${name} owners: [${owners}]`,

});
}
}

private static isSecurityEnabled(fields: {
securityPluginSetup?: SecurityPluginSetup;
securityPluginStart?: SecurityPluginStart;
}): fields is {
securityPluginSetup: SecurityPluginSetup;
securityPluginStart: SecurityPluginStart;
} {
const { securityPluginSetup, securityPluginStart } = fields;

return (
securityPluginStart !== undefined &&
securityPluginSetup !== undefined &&
securityPluginSetup.license.isEnabled()
);
}

private static buildRequiredPrivileges(owners: string[], security: SecurityPluginStart) {
Copy link
Member

Choose a reason for hiding this comment

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

question: would you mind adding a JSDoc here explaining what privileges exactly we'd like potential assignees to have and why (who\what are these owners and why having only getCase isn't enough to be assigned to the case)? It'd help other Cases-noobs like me to quickly understand the "privilege model" here 🙂

const privileges: string[] = [];
for (const owner of owners) {
for (const operation of [Operations.updateCase.name, Operations.getCase.name]) {
privileges.push(security.authz.actions.cases.get(owner, operation));
}
}

return privileges;
}
}
3 changes: 1 addition & 2 deletions x-pack/plugins/observability/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
cases: [observabilityFeatureId],
privileges: {
all: {
api: ['casesSuggestAssignees'],
app: [casesFeatureId, 'kibana'],
catalogue: [observabilityFeatureId],
cases: {
Expand All @@ -63,7 +64,6 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
update: [observabilityFeatureId],
push: [observabilityFeatureId],
},
api: [],
savedObject: {
all: [],
read: [],
Expand All @@ -76,7 +76,6 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
cases: {
read: [observabilityFeatureId],
},
api: [],
savedObject: {
all: [],
read: [],
Expand Down
3 changes: 1 addition & 2 deletions x-pack/plugins/security_solution/server/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
cases: [APP_ID],
privileges: {
all: {
api: ['casesSuggestAssignees'],
app: [CASES_FEATURE_ID, 'kibana'],
catalogue: [APP_ID],
cases: {
Expand All @@ -37,7 +38,6 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
update: [APP_ID],
push: [APP_ID],
},
api: [],
savedObject: {
all: [],
read: [],
Expand All @@ -50,7 +50,6 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
cases: {
read: [APP_ID],
},
api: [],
savedObject: {
all: [],
read: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
cases: ['observabilityFixture'],
privileges: {
all: {
api: ['casesSuggestAssignees'],
app: ['kibana'],
cases: {
all: ['observabilityFixture'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
cases: ['securitySolutionFixture'],
privileges: {
all: {
api: ['casesSuggestAssignees'],
app: ['kibana'],
cases: {
create: ['securitySolutionFixture'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ export const noKibanaPrivileges: Role = {
},
};

export const noCasesPrivilegesSpace1: Role = {
name: 'no_kibana_privileges',
privileges: {
elasticsearch: {
indices: [
{
names: ['*'],
privileges: ['all'],
},
],
},
kibana: [
{
feature: {
actions: ['read'],
actionsSimulators: ['read'],
},
spaces: ['space1'],
},
],
},
};

export const globalRead: Role = {
name: 'global_read',
privileges: {
Expand Down Expand Up @@ -217,6 +240,7 @@ export const observabilityOnlyRead: Role = {

export const roles = [
noKibanaPrivileges,
noCasesPrivilegesSpace1,
globalRead,
securitySolutionOnlyAll,
securitySolutionOnlyRead,
Expand Down
Loading