diff --git a/etc/poco/bundle/extras-prod-test.json b/etc/poco/bundle/extras-prod-test.json index 971febfce7..a90ca3c1db 100644 --- a/etc/poco/bundle/extras-prod-test.json +++ b/etc/poco/bundle/extras-prod-test.json @@ -41,7 +41,7 @@ "allowed": true }, { - "name": "Allow Prod Basic Check Critical Pollinator Test to call Get Audit Log endpoints", + "name": "Allow Prod Basic Check Critical Pollinator Test to call Get Audit Log endpoints for cloud", "path": "/api/audit-log/subscription/255625", "method": "GET", "mechanism": "asap", @@ -50,6 +50,16 @@ ], "allowed": true }, + { + "name": "Allow Prod Basic Check Critical Pollinator Test to call Get Audit Log endpoints for ghe", + "path": "/api/audit-log/subscription/256125", + "method": "GET", + "mechanism": "asap", + "principals": [ + "pollinator-check/d4f03d07-12fe-4a69-9d68-c1841066772e" + ], + "allowed": true + }, { "name": "Allow pollinator test to call Delete Installation endpoints", "path": "/api/deleteInstallation/21266506/https%3A%2F%2Ffusion-arc-pollinator-staging-app.atlassian.net", diff --git a/etc/poco/bundle/extras-prod.json b/etc/poco/bundle/extras-prod.json index 83a7b3b4c4..d201b96cd0 100644 --- a/etc/poco/bundle/extras-prod.json +++ b/etc/poco/bundle/extras-prod.json @@ -36,7 +36,8 @@ "issuers": [ "pollinator-check/f24ec1a9-d03d-45c7-bbd8-f2094543eaba", "pollinator-check/8692803e-287a-48e3-bad1-49a60a7a4f9d", - "pollinator-check/d4f03d07-12fe-4a69-9d68-c1841066772e" + "pollinator-check/d4f03d07-12fe-4a69-9d68-c1841066772e", + "pollinator-check/42166522-a00b-4c93-858c-bda16bbf7aba" ] } } diff --git a/spa/src/api/subscriptions/index.ts b/spa/src/api/subscriptions/index.ts index 0c5c2b8d0c..f8ddc823d7 100644 --- a/spa/src/api/subscriptions/index.ts +++ b/spa/src/api/subscriptions/index.ts @@ -1,5 +1,7 @@ -import { axiosRest } from "../axiosInstance"; +import { axiosRest, axiosRestWithGitHubToken } from "../axiosInstance"; export default { - getSubscriptions: () => axiosRest.get("/rest/subscriptions") + getSubscriptions: () => axiosRest.get("/rest/subscriptions"), + deleteSubscription: (subscriptionId: number) => + axiosRestWithGitHubToken.delete("/rest/app/cloud/subscription/:subscriptionId", { params: { subscriptionId } }) }; diff --git a/src/github/client/github-client-errors.test.ts b/src/github/client/github-client-errors.test.ts index 8b5b36ffe2..25d33a5085 100644 --- a/src/github/client/github-client-errors.test.ts +++ b/src/github/client/github-client-errors.test.ts @@ -1,4 +1,4 @@ -import { GithubClientBlockedIpError } from "./github-client-errors"; +import { GithubClientBlockedIpError, GithubClientError } from "./github-client-errors"; describe("GitHubClientError", () => { @@ -19,4 +19,19 @@ describe("GitHubClientError", () => { expect(error.stack).toContain("existing stack trace line 2"); expect(error.stack).toContain("existing stack trace line 3"); }); + + it("extract the error response body (empty)", async () => { + const error = new GithubClientError("test", { } as any); + expect(error.resBody).toEqual(undefined); + }); + + it("extract the error response body (str)", async () => { + const error = new GithubClientError("test", { response: { data: "test resp body" } } as any); + expect(error.resBody).toEqual("test resp body"); + }); + + it("extract the error response body (object)", async () => { + const error = new GithubClientError("test", { response: { data: { hello: "error" } } } as any); + expect(error.resBody).toEqual(`{"hello":"error"}`); + }); }); diff --git a/src/github/client/github-client-errors.ts b/src/github/client/github-client-errors.ts index b3858396d8..7c8662cadc 100644 --- a/src/github/client/github-client-errors.ts +++ b/src/github/client/github-client-errors.ts @@ -1,5 +1,17 @@ import { AxiosError, AxiosResponse } from "axios"; import { ErrorCode } from "rest-interfaces"; +import safeJsonStringify from "safe-json-stringify"; + +const safeParseResponseBody = (data: unknown): string | undefined => { + if (data === undefined) return undefined; + if ((typeof data) === "string") { + return data as string; + } + if ((typeof data) === "object") { + return safeJsonStringify(data as object); + } + return String(data); +}; export class GithubClientError extends Error { cause: AxiosError; @@ -7,6 +19,7 @@ export class GithubClientError extends Error { status?: number; code?: string; + resBody?: string; uiErrorCode: ErrorCode; constructor(message: string, cause: AxiosError) { @@ -14,6 +27,7 @@ export class GithubClientError extends Error { this.status = cause.response?.status; this.code = cause.code; + this.resBody = safeParseResponseBody(cause.response?.data); this.uiErrorCode = "UNKNOWN"; this.cause = { ...cause }; diff --git a/src/rest/rest-router.ts b/src/rest/rest-router.ts index 04b972aa15..a0df75f6a5 100644 --- a/src/rest/rest-router.ts +++ b/src/rest/rest-router.ts @@ -46,6 +46,8 @@ subRouter.use("/installation", GitHubAppsRoute); subRouter.use("/jira/cloudid", JiraCloudIDRouter); +subRouter.use("/subscriptions/:subscriptionId", SubscriptionsRouter); + subRouter.use(GitHubTokenHandler); subRouter.use("/org", GitHubOrgsRouter); diff --git a/src/rest/routes/subscriptions/index.test.ts b/src/rest/routes/subscriptions/index.test.ts new file mode 100644 index 0000000000..6c1ce550e3 --- /dev/null +++ b/src/rest/routes/subscriptions/index.test.ts @@ -0,0 +1,97 @@ +import { Installation } from "models/installation"; +import { Subscription } from "models/subscription"; +import { encodeSymmetric } from "atlassian-jwt"; +import { getFrontendApp } from "~/src/app"; +import supertest from "supertest"; +import { envVars } from "config/env"; + +describe("Subscription", () => { + const testSharedSecret = "test-secret"; + const gitHubInstallationId = 15; + const getToken = ({ + secret = testSharedSecret, + iss = "jira-client-key", + exp = Date.now() / 1000 + 10000, + qsh = "context-qsh" } = {}): string => encodeSymmetric({ + qsh, + iss, + exp + }, secret); + let app, subscription; + beforeEach(async () => { + app = getFrontendApp(); + await Installation.install({ + clientKey: "jira-client-key", + host: jiraHost, + sharedSecret: testSharedSecret + }); + subscription = await Subscription.create({ + gitHubInstallationId, + jiraHost + }); + }); + + it("Should return 400 for invalid delete subscription route", async () => { + const resp = await supertest(app) + .delete("/rest/subscriptions/" + gitHubInstallationId) + .set("authorization", `${getToken()}`); + + expect(resp.status).toBe(404); + }); + + it("Should return 401 for valid delete subscription route when missing githubToken", async () => { + const resp = await supertest(app) + .delete("/rest/app/cloud/subscriptions/" + gitHubInstallationId); + + expect(resp.status).toBe(401); + expect(await Subscription.count()).toEqual(1); + }); + + it("Should return 404 for valid delete subscription route when no valid subscriptionId is passed", async () => { + const resp = await supertest(app) + .delete("/rest/app/cloud/subscriptions/random-installation-id") + .set("authorization", `${getToken()}`); + + expect(resp.status).toBe(404); + expect(await Subscription.count()).toEqual(1); + }); + + it("Should return 404 for valid delete subscription route when a different subscriptionId is passed", async () => { + const resp = await supertest(app) + .delete("/rest/app/cloud/subscriptions/12") + .set("authorization", `${getToken()}`); + + expect(resp.status).toBe(404); + expect(await Subscription.count()).toEqual(1); + }); + + it("Should return 204 for valid delete subscription route when subscription is deleted", async () => { + jiraNock + .delete("/rest/devinfo/0.10/bulkByProperties") + .query({ installationId: subscription.gitHubInstallationId }) + .reply(200, "OK"); + + jiraNock + .delete("/rest/builds/0.1/bulkByProperties") + .query({ gitHubInstallationId: subscription.gitHubInstallationId }) + .reply(200, "OK"); + + jiraNock + .delete("/rest/deployments/0.1/bulkByProperties") + .query({ gitHubInstallationId: subscription.gitHubInstallationId }) + .reply(200, "OK"); + + jiraNock + .put(`/rest/atlassian-connect/latest/addons/${envVars.APP_KEY}/properties/is-configured`, { + isConfigured: false + }) + .reply(200); + + const resp = await supertest(app) + .delete("/rest/app/cloud/subscriptions/" + subscription.id) + .set("authorization", `${getToken()}`); + + expect(resp.status).toBe(204); + expect(await Subscription.count()).toEqual(0); + }); +}); diff --git a/src/rest/routes/subscriptions/index.ts b/src/rest/routes/subscriptions/index.ts new file mode 100644 index 0000000000..c947b41282 --- /dev/null +++ b/src/rest/routes/subscriptions/index.ts @@ -0,0 +1,40 @@ +import { Router, Request, Response } from "express"; +import { errorWrapper } from "../../helper"; +import { getAllSubscriptions } from "./service"; +import { Installation } from "models/installation"; +import { removeSubscription } from "utils/jira-utils"; +import { GitHubServerApp } from "models/github-server-app"; +import { InvalidArgumentError } from "config/errors"; + +export const SubscriptionsRouter = Router({ mergeParams: true }); + +SubscriptionsRouter.get("/", errorWrapper("SubscriptionsGet", async (req: Request, res: Response) => { + const { jiraHost, installation } = res.locals; + const { ghCloudSubscriptions, ghEnterpriseServers } = await getAllSubscriptions(jiraHost as string, (installation as Installation).id, req); + + res.status(200).json({ + ghCloudSubscriptions, + ghEnterpriseServers + }); +})); + +/** + * This delete endpoint only handles Github cloud subscriptions + */ +SubscriptionsRouter.delete("/", errorWrapper("SubscriptionDelete", async (req: Request, res: Response) => { + const subscriptionId: number = Number(req.params.subscriptionId); + const { installation } = res.locals as { installation: Installation; }; + + const cloudOrUUID = req.params.cloudOrUUID; + if (!cloudOrUUID) { + throw new InvalidArgumentError("Invalid route, couldn't determine if its cloud or enterprise!"); + } + + // TODO: Check and add test cases for GHE later + const gitHubAppId = cloudOrUUID === "cloud" ? undefined : + (await GitHubServerApp.getForUuidAndInstallationId(cloudOrUUID, installation.id))?.appId; //TODO: validate the uuid regex + + await removeSubscription(installation, undefined, gitHubAppId, req.log, subscriptionId); + + res.sendStatus(204); +})); diff --git a/src/rest/routes/subscriptions/index.tsx b/src/rest/routes/subscriptions/index.tsx deleted file mode 100644 index d19f3effd2..0000000000 --- a/src/rest/routes/subscriptions/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Router, Request, Response } from "express"; -import { errorWrapper } from "../../helper"; -import { getAllSubscriptions } from "./service"; -import { Installation } from "models/installation"; - -export const SubscriptionsRouter = Router(); - -SubscriptionsRouter.get("/", errorWrapper("SubscriptionsGet", async (req: Request, res: Response) => { - const { jiraHost, installation } = res.locals; - const { ghCloudSubscriptions, ghEnterpriseServers } = await getAllSubscriptions(jiraHost as string, (installation as Installation).id, req); - - res.status(200).json({ - ghCloudSubscriptions, - ghEnterpriseServers - }); -})); - diff --git a/src/routes/jira/jira-delete.test.ts b/src/routes/jira/jira-delete.test.ts index e117ba002d..7024958cb1 100644 --- a/src/routes/jira/jira-delete.test.ts +++ b/src/routes/jira/jira-delete.test.ts @@ -19,7 +19,7 @@ describe("DELETE /jira/configuration", () => { beforeEach(async () => { subscription = { - githubInstallationId: 15, + gitHubInstallationId: 15, jiraHost, destroy: jest.fn().mockResolvedValue(undefined) }; @@ -41,17 +41,17 @@ describe("DELETE /jira/configuration", () => { it("Delete Jira Configuration", async () => { jiraNock .delete("/rest/devinfo/0.10/bulkByProperties") - .query({ installationId: subscription.githubInstallationId }) + .query({ installationId: subscription.gitHubInstallationId }) .reply(200, "OK"); jiraNock .delete("/rest/builds/0.1/bulkByProperties") - .query({ gitHubInstallationId: subscription.githubInstallationId }) + .query({ gitHubInstallationId: subscription.gitHubInstallationId }) .reply(200, "OK"); jiraNock .delete("/rest/deployments/0.1/bulkByProperties") - .query({ gitHubInstallationId: subscription.githubInstallationId }) + .query({ gitHubInstallationId: subscription.gitHubInstallationId }) .reply(200, "OK"); jiraNock @@ -67,7 +67,7 @@ describe("DELETE /jira/configuration", () => { jiraHost: subscription.jiraHost }, params: { - installationId: subscription.githubInstallationId + installationId: subscription.gitHubInstallationId } }; @@ -81,17 +81,17 @@ describe("DELETE /jira/configuration", () => { when(booleanFlag).calledWith(BooleanFlags.ENABLE_GITHUB_SECURITY_IN_JIRA, expect.anything()).mockResolvedValue(true); jiraNock .delete("/rest/devinfo/0.10/bulkByProperties") - .query({ installationId: subscription.githubInstallationId }) + .query({ installationId: subscription.gitHubInstallationId }) .reply(200, "OK"); jiraNock .delete("/rest/builds/0.1/bulkByProperties") - .query({ gitHubInstallationId: subscription.githubInstallationId }) + .query({ gitHubInstallationId: subscription.gitHubInstallationId }) .reply(200, "OK"); jiraNock .delete("/rest/deployments/0.1/bulkByProperties") - .query({ gitHubInstallationId: subscription.githubInstallationId }) + .query({ gitHubInstallationId: subscription.gitHubInstallationId }) .reply(200, "OK"); jiraNock @@ -115,7 +115,7 @@ describe("DELETE /jira/configuration", () => { jiraHost: subscription.jiraHost }, params: { - installationId: subscription.githubInstallationId + installationId: subscription.gitHubInstallationId } }; @@ -132,7 +132,7 @@ describe("DELETE /jira/configuration", () => { jiraHost: subscription.jiraHost }, params: { - installationId: subscription.githubInstallationId + installationId: subscription.gitHubInstallationId } }; diff --git a/src/routes/jira/jira-delete.ts b/src/routes/jira/jira-delete.ts index 23b0e94ba3..65d697f04d 100644 --- a/src/routes/jira/jira-delete.ts +++ b/src/routes/jira/jira-delete.ts @@ -1,16 +1,6 @@ -import Logger from "bunyan"; import { Errors } from "config/errors"; import { Request, Response } from "express"; -import { AnalyticsEventTypes, AnalyticsTrackEventsEnum, AnalyticsTrackSource } from "interfaces/common"; -import { Subscription } from "models/subscription"; -import { sendAnalytics } from "utils/analytics-client"; -import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server"; -import { BooleanFlags, booleanFlag } from "~/src/config/feature-flags"; -import { getJiraClient } from "~/src/jira/client/jira-client"; -import { Installation } from "~/src/models/installation"; -import { JiraClient } from "~/src/models/jira-client"; -import { isConnected } from "utils/is-connected"; -import { saveConfiguredAppProperties } from "utils/app-properties-utils"; +import { removeSubscription } from "utils/jira-utils"; /** * Handle the when a user deletes an entry in the UI @@ -39,61 +29,6 @@ export const JiraDelete = async (req: Request, res: Response): Promise => req.log.info("No gitHubAppId passed. Disconnecting cloud subscription."); } - const subscription = await Subscription.getSingleInstallation( - jiraHost, - gitHubInstallationId, - gitHubAppId - ); - - if (!subscription) { - req.log.warn("Cannot find subscription"); - res.status(404).send("Cannot find Subscription"); - return; - } - - const jiraClient = await getJiraClient(jiraHost, gitHubInstallationId, gitHubAppId, req.log); - if (jiraClient === undefined) { - throw new Error("jiraClient is undefined"); - } - - await jiraClient.devinfo.installation.delete(gitHubInstallationId); - if (await booleanFlag(BooleanFlags.ENABLE_GITHUB_SECURITY_IN_JIRA, jiraHost)) { - await deleteSecurityWorkspaceLinkAndVulns(installation, subscription, req.log); - req.log.info({ subscriptionId: subscription.id }, "Deleted security workspace and vulnerabilities"); - } - await subscription.destroy(); - - if (!(await isConnected(jiraHost))) { - await saveConfiguredAppProperties(jiraHost, req.log, false); - } - - await sendAnalytics(jiraHost, AnalyticsEventTypes.TrackEvent, { - action: AnalyticsTrackEventsEnum.DisconnectToOrgTrackEventName, - actionSubject: AnalyticsTrackEventsEnum.DisconnectToOrgTrackEventName, - source: !gitHubAppId ? AnalyticsTrackSource.Cloud : AnalyticsTrackSource.GitHubEnterprise - }, { - gitHubProduct: getCloudOrServerFromGitHubAppId(gitHubAppId) - }); - + await removeSubscription(installation, gitHubInstallationId, gitHubAppId, req.log, undefined); res.sendStatus(204); }; - -const deleteSecurityWorkspaceLinkAndVulns = async ( - installation: Installation, - subscription: Subscription, - logger: Logger -) => { - - try { - logger.info("Fetching info about GitHub installation"); - - const jiraClient = await JiraClient.getNewClient(installation, logger); - await Promise.allSettled([ - jiraClient.deleteWorkspace(subscription.id), - jiraClient.deleteVulnerabilities(subscription.id) - ]); - } catch (err: unknown) { - logger.warn({ err }, "Failed to delete security workspace or vulnerabilities from Jira"); - } - -}; diff --git a/src/util/jira-utils.ts b/src/util/jira-utils.ts index 58092fed93..4323636955 100644 --- a/src/util/jira-utils.ts +++ b/src/util/jira-utils.ts @@ -4,6 +4,18 @@ import axios from "axios"; import { JiraAuthor } from "interfaces/jira"; import { isEmpty, isString, pickBy, uniq } from "lodash"; import { GitHubServerApp } from "models/github-server-app"; +import { Subscription } from "models/subscription"; +import { getJiraClient } from "~/src/jira/client/jira-client"; +import { booleanFlag, BooleanFlags } from "config/feature-flags"; +import { isConnected } from "utils/is-connected"; +import { saveConfiguredAppProperties } from "utils/app-properties-utils"; +import { sendAnalytics } from "utils/analytics-client"; +import { AnalyticsEventTypes, AnalyticsTrackEventsEnum, AnalyticsTrackSource } from "interfaces/common"; +import { getCloudOrServerFromGitHubAppId } from "utils/get-cloud-or-server"; +import { Installation } from "models/installation"; +import Logger from "bunyan"; +import { JiraClient } from "models/jira-client"; +import { RestApiError } from "config/errors"; export const getJiraAppUrl = (jiraHost: string): string => jiraHost?.length ? `${jiraHost}/plugins/servlet/ac/${envVars.APP_KEY}/github-post-install-page` : ""; @@ -114,3 +126,73 @@ export const hasJiraIssueKey = (str: string): boolean => !isEmpty(jiraIssueKeyPa export const isGitHubCloudApp = async (gitHubAppId: number | undefined): Promise => { return !(gitHubAppId && await GitHubServerApp.getForGitHubServerAppId(gitHubAppId)); }; + +const deleteSecurityWorkspaceLinkAndVulns = async ( + installation: Installation, + subscription: Subscription, + logger: Logger, +) => { + + try { + logger.info("Fetching info about GitHub installation"); + + const jiraClient = await JiraClient.getNewClient(installation, logger); + await Promise.allSettled([ + jiraClient.deleteWorkspace(subscription.id), + jiraClient.deleteVulnerabilities(subscription.id) + ]); + } catch (err: unknown) { + logger.warn({ err }, "Failed to delete security workspace or vulnerabilities from Jira"); + } +}; + +export const removeSubscription = async ( + installation: Installation, + ghInstallationId: number | undefined, + gitHubAppId: number | undefined, + logger: Logger, + subscriptionId: number | undefined +) => { + const jiraHost = installation.jiraHost; + // TODO: Remove ghInstallationId and replace it by subscriptionId + const subscription = subscriptionId ? await Subscription.findByPk(subscriptionId) : + await Subscription.getSingleInstallation( + jiraHost, + ghInstallationId as number, + gitHubAppId + ); + if (!subscription) { + logger.warn("Cannot find subscription"); + throw new RestApiError(404, "RESOURCE_NOT_FOUND", "Can not find subscription"); + } + + if (subscription.jiraHost !== jiraHost) { + throw new RestApiError(500, "JIRAHOST_MISMATCH", "Jirahosts do not match for this subscription"); + } + + const gitHubInstallationId = subscription.gitHubInstallationId; + + const jiraClient = await getJiraClient(jiraHost, gitHubInstallationId, gitHubAppId, logger); + if (jiraClient === undefined) { + throw new RestApiError(500, "UNKNOWN", "jiraClient is undefined"); + } + await jiraClient.devinfo.installation.delete(gitHubInstallationId); + if (await booleanFlag(BooleanFlags.ENABLE_GITHUB_SECURITY_IN_JIRA, jiraHost)) { + await deleteSecurityWorkspaceLinkAndVulns(installation, subscription, logger); + logger.info({ subscriptionId: subscription.id }, "Deleted security workspace and vulnerabilities"); + } + await subscription.destroy(); + + if (!(await isConnected(jiraHost))) { + await saveConfiguredAppProperties(jiraHost, logger, false); + } + + await sendAnalytics(jiraHost, AnalyticsEventTypes.TrackEvent, { + action: AnalyticsTrackEventsEnum.DisconnectToOrgTrackEventName, + actionSubject: AnalyticsTrackEventsEnum.DisconnectToOrgTrackEventName, + source: !gitHubAppId ? AnalyticsTrackSource.Cloud : AnalyticsTrackSource.GitHubEnterprise + }, { + gitHubProduct: getCloudOrServerFromGitHubAppId(gitHubAppId), + spa: !!subscriptionId + }); +} diff --git a/test/snapshots/app.test.ts.snap b/test/snapshots/app.test.ts.snap index 359304d482..89829036ce 100644 --- a/test/snapshots/app.test.ts.snap +++ b/test/snapshots/app.test.ts.snap @@ -15,6 +15,8 @@ exports[`app getFrontendApp please review routes and update snapshot when adding query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,serveStatic :GET ^/?(?=/|$)^/rest/?(?=/|$)^/subscriptions/?(?=/|$)^/?$ query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,SubscriptionsGet +:DELETE ^/?(?=/|$)^/rest/?(?=/|$)^/subscriptions/?(?=/|$)^/?$ + query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,SubscriptionDelete :GET ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/github-callback/?$ query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,OAuthCallbackHandler :GET ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/github-installed/?$ @@ -39,6 +41,10 @@ exports[`app getFrontendApp please review routes and update snapshot when adding query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,GetGitHubAppsUrl :GET ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/jira/cloudid/?(?=/|$)^/?$ query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,JiraCloudIDGet +:GET ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/subscriptions/(?:([^/]+?))/?(?=/|$)^/?$ + query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,SubscriptionsGet +:DELETE ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/subscriptions/(?:([^/]+?))/?(?=/|$)^/?$ + query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,SubscriptionDelete :GET ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/org/?(?=/|$)^/?$ query,expressInit,elapsedTimeMetrics,sentryRequestMiddleware,urlencodedParser,jsonParser,cookieParser,LogMiddleware,JwtHandler,jiraAdminEnforceMiddleware,GitHubTokenHandler,GitHubOrgsFetchOrgs :POST ^/?(?=/|$)^/rest/?(?=/|$)^/app/(?:([^/]+?))/?(?=/|$)^/org/?(?=/|$)^/?$