diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 8d2582d9c5857..5881b7b7633be 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -59,11 +59,13 @@ export interface PluginStartContract { export class CasePlugin { private readonly log: Logger; + private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; private clientFactory: CasesClientFactory; private securityPluginSetup?: SecurityPluginSetup; private lensEmbeddableFactory?: LensServerPluginSetup['lensEmbeddableFactory']; constructor(private readonly initializerContext: PluginInitializerContext) { + this.kibanaVersion = initializerContext.env.packageInfo.version; this.log = this.initializerContext.logger.get(); this.clientFactory = new CasesClientFactory(this.log); } @@ -101,6 +103,7 @@ export class CasePlugin { initCaseApi({ logger: this.log, router, + kibanaVersion: this.kibanaVersion, }); } diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts index f8b85313482f4..c8558d09e5c5f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts @@ -8,10 +8,10 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../types'; -import { wrapError } from '../utils'; +import { getWarningHeader, logDeprecatedEndpoint, wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; -export function initGetCaseApi({ router, logger }: RouteDeps) { +export function initGetCaseApi({ router, logger, kibanaVersion }: RouteDeps) { router.get( { path: CASE_DETAILS_URL, @@ -20,16 +20,35 @@ export function initGetCaseApi({ router, logger }: RouteDeps) { case_id: schema.string(), }), query: schema.object({ + /** + * @deprecated since version 8.1.0 + */ includeComments: schema.boolean({ defaultValue: true }), }), }, }, async (context, request, response) => { try { + const isIncludeCommentsParamProvidedByTheUser = + request.url.searchParams.has('includeComments'); + + if (isIncludeCommentsParamProvidedByTheUser) { + logDeprecatedEndpoint( + logger, + request.headers, + `The query parameter 'includeComments' of the get case API '${CASE_DETAILS_URL}' is deprecated` + ); + } + const casesClient = await context.cases.getCasesClient(); const id = request.params.case_id; return response.ok({ + ...(isIncludeCommentsParamProvidedByTheUser && { + headers: { + ...getWarningHeader(kibanaVersion, 'Deprecated query parameter includeComments'), + }, + }), body: await casesClient.cases.get({ id, includeComments: request.query.includeComments, diff --git a/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts index d7f95d503c394..e94b19cdd9a1c 100644 --- a/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts @@ -8,10 +8,13 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../types'; -import { wrapError } from '../utils'; +import { wrapError, getWarningHeader, logDeprecatedEndpoint } from '../utils'; import { CASE_COMMENTS_URL } from '../../../../common/constants'; -export function initGetAllCommentsApi({ router, logger }: RouteDeps) { +/** + * @deprecated since version 8.1.0 + */ +export function initGetAllCommentsApi({ router, logger, kibanaVersion }: RouteDeps) { router.get( { path: CASE_COMMENTS_URL, @@ -23,9 +26,18 @@ export function initGetAllCommentsApi({ router, logger }: RouteDeps) { }, async (context, request, response) => { try { + logDeprecatedEndpoint( + logger, + request.headers, + `The get all cases comments API '${CASE_COMMENTS_URL}' is deprecated.` + ); + const client = await context.cases.getCasesClient(); return response.ok({ + headers: { + ...getWarningHeader(kibanaVersion), + }, body: await client.attachments.getAll({ caseID: request.params.case_id, }), diff --git a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts index ffbc101a75dc4..90044c9516b3a 100644 --- a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts +++ b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts @@ -6,12 +6,15 @@ */ import { RouteDeps } from '../types'; -import { escapeHatch, wrapError } from '../utils'; +import { escapeHatch, wrapError, getWarningHeader, logDeprecatedEndpoint } from '../utils'; import { CasesStatusRequest } from '../../../../common/api'; import { CASE_STATUS_URL } from '../../../../common/constants'; -export function initGetCasesStatusApi({ router, logger }: RouteDeps) { +/** + * @deprecated since version 8.1.0 + */ +export function initGetCasesStatusApi({ router, logger, kibanaVersion }: RouteDeps) { router.get( { path: CASE_STATUS_URL, @@ -19,8 +22,17 @@ export function initGetCasesStatusApi({ router, logger }: RouteDeps) { }, async (context, request, response) => { try { + logDeprecatedEndpoint( + logger, + request.headers, + `The get cases status API '${CASE_STATUS_URL}' is deprecated.` + ); + const client = await context.cases.getCasesClient(); return response.ok({ + headers: { + ...getWarningHeader(kibanaVersion), + }, body: await client.metrics.getStatusTotalsByType(request.query as CasesStatusRequest), }); } catch (error) { diff --git a/x-pack/plugins/cases/server/routes/api/types.ts b/x-pack/plugins/cases/server/routes/api/types.ts index 9211aee5606a6..e3aa6e0e970fa 100644 --- a/x-pack/plugins/cases/server/routes/api/types.ts +++ b/x-pack/plugins/cases/server/routes/api/types.ts @@ -5,13 +5,14 @@ * 2.0. */ -import type { Logger } from 'kibana/server'; +import type { Logger, PluginInitializerContext } from 'kibana/server'; import type { CasesRouter } from '../../types'; export interface RouteDeps { router: CasesRouter; logger: Logger; + kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; } export interface TotalCommentByCase { diff --git a/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts index cf258c284568b..2e38ac8b4ebc7 100644 --- a/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts @@ -8,10 +8,13 @@ import { schema } from '@kbn/config-schema'; import { RouteDeps } from '../types'; -import { wrapError } from '../utils'; +import { getWarningHeader, logDeprecatedEndpoint, wrapError } from '../utils'; import { CASE_USER_ACTIONS_URL } from '../../../../common/constants'; -export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { +/** + * @deprecated since version 8.1.0 + */ +export function initGetAllCaseUserActionsApi({ router, logger, kibanaVersion }: RouteDeps) { router.get( { path: CASE_USER_ACTIONS_URL, @@ -27,10 +30,19 @@ export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } + logDeprecatedEndpoint( + logger, + request.headers, + `The get all cases user actions API '${CASE_USER_ACTIONS_URL}' is deprecated.` + ); + const casesClient = await context.cases.getCasesClient(); const caseId = request.params.case_id; return response.ok({ + headers: { + ...getWarningHeader(kibanaVersion), + }, body: await casesClient.userActions.getAll({ caseId }), }); } catch (error) { diff --git a/x-pack/plugins/cases/server/routes/api/utils.test.ts b/x-pack/plugins/cases/server/routes/api/utils.test.ts index c2cff04f56a49..2614536e3242b 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.test.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.test.ts @@ -6,8 +6,9 @@ */ import { isBoom, boomify } from '@hapi/boom'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { HTTPError } from '../../common/error'; -import { wrapError } from './utils'; +import { logDeprecatedEndpoint, wrapError } from './utils'; describe('Utils', () => { describe('wrapError', () => { @@ -55,4 +56,23 @@ describe('Utils', () => { expect(res.headers).toEqual({}); }); }); + + describe('logDeprecatedEndpoint', () => { + const logger = loggingSystemMock.createLogger(); + const kibanaHeader = { 'kbn-version': '8.1.0', referer: 'test' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does NOT log when the request is from the kibana client', () => { + logDeprecatedEndpoint(logger, kibanaHeader, 'test'); + expect(logger.warn).not.toHaveBeenCalledWith('test'); + }); + + it('does log when the request is NOT from the kibana client', () => { + logDeprecatedEndpoint(logger, {}, 'test'); + expect(logger.warn).toHaveBeenCalledWith('test'); + }); + }); }); diff --git a/x-pack/plugins/cases/server/routes/api/utils.ts b/x-pack/plugins/cases/server/routes/api/utils.ts index a09fd4cc9c746..532a316e9a7b8 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.ts @@ -6,9 +6,8 @@ */ import { Boom, boomify, isBoom } from '@hapi/boom'; - import { schema } from '@kbn/config-schema'; -import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; +import type { CustomHttpResponseOptions, ResponseError, Headers, Logger } from 'kibana/server'; import { CaseError, isCaseError, HTTPError, isHTTPError } from '../../common/error'; /** @@ -35,3 +34,32 @@ export function wrapError( } export const escapeHatch = schema.object({}, { unknowns: 'allow' }); + +/** + * Creates a warning header with a message formatted according to RFC7234. + * We follow the same formatting as Elasticsearch + * https://github.com/elastic/elasticsearch/blob/5baabff6670a8ed49297488ca8cac8ec12a2078d/server/src/main/java/org/elasticsearch/common/logging/HeaderWarning.java#L55 + */ +export const getWarningHeader = ( + kibanaVersion: string, + msg: string | undefined = 'Deprecated endpoint' +): { warning: string } => ({ + warning: `299 Kibana-${kibanaVersion} "${msg}"`, +}); + +/** + * Taken from + * https://github.com/elastic/kibana/blob/ec30f2aeeb10fb64b507935e558832d3ef5abfaa/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts#L113-L118 + */ + +const getIsKibanaRequest = (headers?: Headers) => { + // The presence of these two request headers gives us a good indication that this is a first-party request from the Kibana client. + // We can't be 100% certain, but this is a reasonable attempt. + return headers && headers['kbn-version'] && headers.referer; +}; + +export const logDeprecatedEndpoint = (logger: Logger, headers: Headers, msg: string) => { + if (!getIsKibanaRequest(headers)) { + logger.warn(msg); + } +}; diff --git a/x-pack/test/cases_api_integration/common/lib/utils.ts b/x-pack/test/cases_api_integration/common/lib/utils.ts index de27836a9559e..9c56db80e45fc 100644 --- a/x-pack/test/cases_api_integration/common/lib/utils.ts +++ b/x-pack/test/cases_api_integration/common/lib/utils.ts @@ -1131,3 +1131,15 @@ export const getServiceNowSimulationServer = async (): Promise<{ return { server, url }; }; + +/** + * Extracts the warning value a warning header that is formatted according to RFC 7234. + * For example for the string 299 Kibana-8.1.0 "Deprecation endpoint", the return value is Deprecation endpoint. + * + */ +export const extractWarningValueFromWarningHeader = (warningHeader: string) => { + const firstQuote = warningHeader.indexOf('"'); + const lastQuote = warningHeader.length - 1; + const warningValue = warningHeader.substring(firstQuote + 1, lastQuote); + return warningValue; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/validation.ts b/x-pack/test/cases_api_integration/common/lib/validation.ts index 220074f22db86..826ac7e2600e1 100644 --- a/x-pack/test/cases_api_integration/common/lib/validation.ts +++ b/x-pack/test/cases_api_integration/common/lib/validation.ts @@ -37,3 +37,17 @@ export function arraysToEqual(array1?: object[], array2?: object[]) { const array1AsSet = new Set(array1); return array2.every((item) => array1AsSet.has(item)); } + +/** + * Regular expression to test if a string matches the RFC7234 specification (without warn-date) for warning headers. This pattern assumes that the warn code + * is always 299. Further, this pattern assumes that the warn agent represents a version of Kibana. + * + * Example: 299 Kibana-8.2.0 "Deprecated endpoint" + */ +const WARNING_HEADER_REGEX = + /299 Kibana-\d+.\d+.\d+(?:-(?:alpha|beta|rc)\\d+)?(?:-SNAPSHOT)? \".+\"/g; + +export const assertWarningHeader = (warningHeader: string) => { + const res = warningHeader.match(WARNING_HEADER_REGEX); + expect(res).not.to.be(null); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts index 8df044baf813e..abafc514e56f3 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/get_case.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { AttributesTypeUser } from '../../../../../../plugins/cases/common/api'; +import { AttributesTypeUser, getCaseDetailsUrl } from '../../../../../../plugins/cases/common/api'; import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; import { defaultUser, @@ -24,6 +24,7 @@ import { createComment, removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromSavedObject, + extractWarningValueFromWarningHeader, } from '../../../../common/lib/utils'; import { secOnly, @@ -37,6 +38,7 @@ import { obsSec, } from '../../../../common/lib/authentication/users'; import { getUserInfo } from '../../../../common/lib/authentication'; +import { assertWarningHeader } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -191,5 +193,29 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + describe('deprecations', () => { + for (const paramValue of [true, false]) { + it(`should return a warning header if includeComments=${paramValue}`, async () => { + const theCase = await createCase(supertest, postCaseReq); + const res = await supertest + .get(`${getCaseDetailsUrl(theCase.id)}?includeComments=${paramValue}`) + .expect(200); + const warningHeader = res.header.warning; + + assertWarningHeader(warningHeader); + + const warningValue = extractWarningValueFromWarningHeader(warningHeader); + expect(warningValue).to.be('Deprecated query parameter includeComments'); + }); + } + + it('should not return a warning header if includeComments is not provided', async () => { + const theCase = await createCase(supertest, postCaseReq); + const res = await supertest.get(getCaseDetailsUrl(theCase.id)).expect(200); + const warningHeader = res.header.warning; + expect(warningHeader).to.be(undefined); + }); + }); }); }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts index 02ace7077a20a..c170dc0ff3ccd 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts @@ -16,6 +16,7 @@ import { getAllCasesStatuses, deleteAllCaseItems, superUserSpace1Auth, + extractWarningValueFromWarningHeader, } from '../../../../../common/lib/utils'; import { globalRead, @@ -26,6 +27,8 @@ import { secOnlyRead, superUser, } from '../../../../../common/lib/authentication/users'; +import { CASE_STATUS_URL } from '../../../../../../../plugins/cases/common/constants'; +import { assertWarningHeader } from '../../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -181,5 +184,18 @@ export default ({ getService }: FtrProviderContext): void => { }); } }); + + describe('deprecations', () => { + it('should return a warning header', async () => { + await createCase(supertest, postCaseReq); + const res = await supertest.get(CASE_STATUS_URL).expect(200); + const warningHeader = res.header.warning; + + assertWarningHeader(warningHeader); + + const warningValue = extractWarningValueFromWarningHeader(warningHeader); + expect(warningValue).to.be('Deprecated endpoint'); + }); + }); }); }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts index e0ee2576ef610..d6e52b6577fef 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts @@ -15,6 +15,7 @@ import { createComment, getAllComments, superUserSpace1Auth, + extractWarningValueFromWarningHeader, } from '../../../../common/lib/utils'; import { globalRead, @@ -27,6 +28,8 @@ import { secOnlyRead, superUser, } from '../../../../common/lib/authentication/users'; +import { getCaseCommentsUrl } from '../../../../../../plugins/cases/common/api'; +import { assertWarningHeader } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -148,5 +151,18 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + describe('deprecations', () => { + it('should return a warning header', async () => { + const theCase = await createCase(supertest, postCaseReq); + const res = await supertest.get(getCaseCommentsUrl(theCase.id)).expect(200); + const warningHeader = res.header.warning; + + assertWarningHeader(warningHeader); + + const warningValue = extractWarningValueFromWarningHeader(warningHeader); + expect(warningValue).to.be('Deprecated endpoint'); + }); + }); }); }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 17863b282bf05..b09059966a16b 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -13,6 +13,7 @@ import { CaseStatuses, CommentType, ConnectorTypes, + getCaseUserActionUrl, } from '../../../../../../plugins/cases/common/api'; import { CreateCaseUserAction } from '../../../../../../plugins/cases/common/api/cases/user_actions/create_case'; import { postCaseReq, postCommentUserReq, getPostCaseRequest } from '../../../../common/lib/mock'; @@ -26,6 +27,7 @@ import { createComment, updateComment, deleteComment, + extractWarningValueFromWarningHeader, } from '../../../../common/lib/utils'; import { globalRead, @@ -36,6 +38,7 @@ import { secOnlyRead, superUser, } from '../../../../common/lib/authentication/users'; +import { assertWarningHeader } from '../../../../common/lib/validation'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -354,5 +357,18 @@ export default ({ getService }: FtrProviderContext): void => { }); } }); + + describe('deprecations', () => { + it('should return a warning header', async () => { + const theCase = await createCase(supertest, postCaseReq); + const res = await supertest.get(getCaseUserActionUrl(theCase.id)).expect(200); + const warningHeader = res.header.warning; + + assertWarningHeader(warningHeader); + + const warningValue = extractWarningValueFromWarningHeader(warningHeader); + expect(warningValue).to.be('Deprecated endpoint'); + }); + }); }); };