Skip to content

Commit

Permalink
feat: Deploy APIM CORS Policy (#166)
Browse files Browse the repository at this point in the history
When the APIM CORS policy is defined within the serverless yaml then we need to deploy the XML policy document during deployment.

Resolve ABA#207
  • Loading branch information
wbreza authored Jun 15, 2019
1 parent 0b5b5ad commit 8bdd805
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 24 deletions.
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"lodash": "^4.16.6",
"open": "^6.3.0",
"request": "^2.81.0",
"rimraf": "^2.6.3"
"rimraf": "^2.6.3",
"xml": "^1.0.1"
},
"devDependencies": {
"@babel/runtime": "^7.4.5",
Expand All @@ -52,6 +53,7 @@
"@types/open": "^6.1.0",
"@types/request": "^2.48.1",
"@types/serverless": "^1.18.2",
"@types/xml": "^1.0.3",
"@typescript-eslint/eslint-plugin": "^1.9.0",
"@typescript-eslint/parser": "^1.9.0",
"axios-mock-adapter": "^1.16.0",
Expand Down
29 changes: 29 additions & 0 deletions src/models/apiManagement.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { OperationContract, ApiContract, BackendContract } from "@azure/arm-apimanagement/esm/models";

/**
* Defines the serverless APIM configuration
*/
export interface ApiManagementConfig {
/** The name of the APIM azure resource */
name: string;
/** The API contract configuration */
api: ApiContract;
/** The API's backend contract configuration */
backend?: BackendContract;
/** The API's CORS policy */
cors?: ApiCorsPolicy;
}

/**
* Defines the APIM API Operation configuration
*/
export interface ApiOperationOptions {
/** The name of the serverless function */
function: string;
/** The APIM operation contract configuration */
operation: OperationContract;
}

/**
* Defines an APIM API CORS (cross origin resource sharing) policy
*/
export interface ApiCorsPolicy {
/** Whether or not to allow credentials */
allowCredentials: boolean;
/** A list of allowed domains - also supports wildcard "*" */
allowedOrigins: string[];
/** A list of allowed HTTP methods */
allowedMethods: string[];
/** A list of allowed headers */
allowedHeaders: string[];
/** A list of headers exposed during OPTION preflight requests */
exposeHeaders: string[];
}
36 changes: 34 additions & 2 deletions src/services/apimService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ApiManagementConfig } from "../models/apiManagement";
import { ApimService } from "./apimService";
import { interpolateJson } from "../test/utils";
import axios from "axios";
import { Api, Backend, Property, ApiOperation, ApiOperationPolicy, ApiManagementService } from "@azure/arm-apimanagement";
import { Api, Backend, Property, ApiOperation, ApiOperationPolicy, ApiManagementService, ApiPolicy } from "@azure/arm-apimanagement";
import apimGetService404 from "../test/responses/apim-get-service-404.json";
import apimGetService200 from "../test/responses/apim-get-service-200.json";
import apimGetApi200 from "../test/responses/apim-get-api-200.json";
Expand All @@ -18,6 +18,7 @@ import {
ApiOperationCreateOrUpdateResponse, ApiManagementServiceResource, ApiGetResponse,
ApiManagementServiceGetResponse,
OperationContract,
ApiPolicyCreateOrUpdateResponse,
} from "@azure/arm-apimanagement/esm/models";

describe("APIM Service", () => {
Expand Down Expand Up @@ -235,13 +236,13 @@ describe("APIM Service", () => {
});

it("ensures API, backend and keys have all been set", async () => {

Api.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<ApiCreateOrUpdateResponse>(expectedApiResult, 201));
Backend.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<BackendCreateOrUpdateResponse>(expectedBackend, 201));
Property.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<PropertyCreateOrUpdateResponse>(expectedProperty, 201));
ApiPolicy.prototype.createOrUpdate = jest.fn(() => Promise.resolve(null));

const apimService = new ApimService(serverless);
const result = await apimService.deployApi();
Expand All @@ -254,6 +255,9 @@ describe("APIM Service", () => {
expectedApi,
);

// No CORS policy by default
expect(ApiPolicy.prototype.createOrUpdate).not.toBeCalled();

expect(Backend.prototype.createOrUpdate).toBeCalledWith(
resourceGroupName,
serviceName,
Expand All @@ -269,6 +273,34 @@ describe("APIM Service", () => {
);
});

it("deploys API CORS policy when defined within configuration", async () => {
Api.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<ApiCreateOrUpdateResponse>(expectedApiResult, 201));
Backend.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<BackendCreateOrUpdateResponse>(expectedBackend, 201));
Property.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<PropertyCreateOrUpdateResponse>(expectedProperty, 201));
ApiPolicy.prototype.createOrUpdate =
jest.fn(() => MockFactory.createTestArmSdkResponse<ApiPolicyCreateOrUpdateResponse>(expectedProperty, 201));

const corsPolicy = MockFactory.createTestMockApiCorsPolicy();
serverless.service.provider["apim"]["cors"] = corsPolicy;

const apimService = new ApimService(serverless);
const result = await apimService.deployApi();

expect(result).not.toBeNull();
expect(ApiPolicy.prototype.createOrUpdate).toBeCalledWith(
resourceGroupName,
serviceName,
apiName,
{
format: "rawxml",
value: expect.stringContaining("cors"),
}
);
});

it("returns null when APIM is not configured", async () => {
serverless.service.provider["apim"] = null;

Expand Down
104 changes: 85 additions & 19 deletions src/services/apimService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Serverless from "serverless";
import xml from "xml";
import { ApiManagementClient } from "@azure/arm-apimanagement";
import { FunctionAppService } from "./functionAppService";
import { BaseService } from "./baseService";
import { ApiManagementConfig, ApiOperationOptions } from "../models/apiManagement";
import { ApiManagementConfig, ApiOperationOptions, ApiCorsPolicy } from "../models/apiManagement";
import {
ApiContract, BackendContract, OperationContract,
PropertyContract, ApiManagementServiceResource,
Expand Down Expand Up @@ -130,16 +131,28 @@ export class ApimService extends BaseService {
this.log("-> Deploying API");

try {
return await this.apimClient.api.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, {
const api = await this.apimClient.api.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, {
isCurrent: true,
subscriptionRequired: this.config.api.subscriptionRequired,
displayName: this.config.api.displayName,
description: this.config.api.description,
path: this.config.api.path,
protocols: this.config.api.protocols,
});

if (this.config.cors) {
this.log("-> Deploying CORS policy");

await this.apimClient.apiPolicy.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, {
format: "rawxml",
value: this.createCorsXmlPolicy(this.config.cors)
});
}

return api;
} catch (e) {
this.log("Error creating APIM API");
this.log(JSON.stringify(e.body, null, 4));
throw e;
}
}
Expand Down Expand Up @@ -171,6 +184,7 @@ export class ApimService extends BaseService {
});
} catch (e) {
this.log("Error creating APIM Backend");
this.log(JSON.stringify(e.body, null, 4));
throw e;
}
}
Expand Down Expand Up @@ -210,36 +224,22 @@ export class ApimService extends BaseService {

await client.apiOperationPolicy.createOrUpdate(this.resourceGroup, this.config.name, this.config.api.name, options.function, {
format: "rawxml",
value: `
<policies>
<inbound>
<base />
<set-backend-service id="apim-generated-policy" backend-id="${this.serviceName}" />
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>`,
value: this.createApiOperationXmlPolicy(),
});

return operation;
} catch (e) {
this.log(`Error deploying API operation ${options.function}`);
this.log(JSON.stringify(e.body, null, 4));
throw e;
}
}

/**
* Gets the master key for the function app and stores a reference in the APIM instance
* @param functionAppUrl The host name for the Azure function app
*/
private async ensureFunctionAppKeys(functionApp): Promise<PropertyContract> {
private async ensureFunctionAppKeys(functionApp: Site): Promise<PropertyContract> {
this.log("-> Deploying API keys");
try {
const masterKey = await this.functionAppService.getMasterKey(functionApp);
Expand All @@ -252,7 +252,73 @@ export class ApimService extends BaseService {
});
} catch (e) {
this.log("Error creating APIM Property");
this.log(JSON.stringify(e.body, null, 4));
throw e;
}
}

/**
* Creates the XML payload that defines the API operation policy to link to the configured backend
*/
private createApiOperationXmlPolicy(): string {
const operationPolicy = [{
policies: [
{
inbound: [
{ base: null },
{
"set-backend-service": [
{
"_attr": {
"id": "apim-generated-policy",
"backend-id": this.serviceName,
}
},
],
},
],
},
{ backend: [{ base: null }] },
{ outbound: [{ base: null }] },
{ "on-error": [{ base: null }] },
]
}];

return xml(operationPolicy);
}

/**
* Creates the XML payload that defines the specified CORS policy
* @param corsPolicy The CORS policy
*/
private createCorsXmlPolicy(corsPolicy: ApiCorsPolicy): string {
const origins = corsPolicy.allowedOrigins ? corsPolicy.allowedOrigins.map((origin) => ({ origin })) : null;
const methods = corsPolicy.allowedMethods ? corsPolicy.allowedMethods.map((method) => ({ method })) : null;
const allowedHeaders = corsPolicy.allowedHeaders ? corsPolicy.allowedHeaders.map((header) => ({ header })) : null;
const exposeHeaders = corsPolicy.exposeHeaders ? corsPolicy.exposeHeaders.map((header) => ({ header })) : null;

const policy = [{
policies: [
{
inbound: [
{ base: null },
{
cors: [
{ "_attr": { "allow-credentials": corsPolicy.allowCredentials } },
{ "allowed-origins": origins },
{ "allowed-methods": methods },
{ "allowed-headers": allowedHeaders },
{ "expose-headers": exposeHeaders },
]
}
],
},
{ backend: [{ base: null }] },
{ outbound: [{ base: null }] },
{ "on-error": [{ base: null }] },
]
}];

return xml(policy, { indent: "\t" });
}
}
15 changes: 13 additions & 2 deletions src/test/mockFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import PluginManager from "serverless/lib/classes/PluginManager";
import { ServerlessAzureConfig } from "../models/serverless";
import { AzureServiceProvider, ServicePrincipalEnvVariables } from "../models/azureProvider"
import { Logger } from "../models/generic";
import { ApiCorsPolicy } from "../models/apiManagement";

function getAttribute(object: any, prop: string, defaultValue: any): any {
if (object && object[prop]) {
Expand Down Expand Up @@ -218,7 +219,7 @@ export class MockFactory {
const result = []
functions = functions || MockFactory.createTestSlsFunctionConfig();
for (const name of Object.keys(functions)) {
result.push({ properties: MockFactory.createTestFunctionEnvelope(name)});
result.push({ properties: MockFactory.createTestFunctionEnvelope(name) });
}
return result;
}
Expand Down Expand Up @@ -253,7 +254,7 @@ export class MockFactory {
subscriptionId: "azureSubId",
}
}

public static createTestSite(name: string = "Test"): Site {
return {
id: "appId",
Expand Down Expand Up @@ -375,6 +376,16 @@ export class MockFactory {
});
}

public static createTestMockApiCorsPolicy(): ApiCorsPolicy {
return {
allowCredentials: false,
allowedOrigins: ["*"],
allowedHeaders: ["*"],
exposeHeaders: ["*"],
allowedMethods: ["GET","POST"],
};
}

private static createTestCli(): Logger {
return {
log: jest.fn(),
Expand Down
9 changes: 9 additions & 0 deletions src/test/responses/apim-put-api-policy-200.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "/subscriptions/d36d0808-a967-4f73-9fdc-32ea232fc81d/resourceGroups/${resourceGroup.name}/providers/Microsoft.ApiManagement/service/${service.name}/apis/${api.name}/policies/policy",
"type": "Microsoft.ApiManagement/service/apis/policies",
"name": "policy",
"properties": {
"format": "xml",
"value": "<!-- IMPORTANT: - Policy elements can appear only within the <inbound>, <outbound>, <backend> section elements. - To apply a policy to the incoming request (before it is forwarded to the backend service), place a corresponding policy element within the <inbound> section element. - To apply a policy to the outgoing response (before it is sent back to the caller), place a corresponding policy element within the <outbound> section element. - To add a policy, place the cursor at the desired insertion point and select a policy from the sidebar. - To remove a policy, delete the corresponding policy statement from the policy document. - Position the <base> element within a section element to inherit all policies from the corresponding section element in the enclosing scope. - Remove the <base> element to prevent inheriting policies from the corresponding section element in the enclosing scope. - Policies are applied in the order of their appearance, from the top down. - Comments within policy elements are not supported and may disappear. Place your comments between policy elements or at a higher level scope.--><policies>\t<inbound>\t\t<base />\t\t<cors allow-credentials=\"false\">\t\t\t<allowed-origins>\t\t\t\t<origin>*</origin>\t\t\t</allowed-origins>\t\t\t<allowed-methods>\t\t\t\t<method>GET</method>\t\t\t\t<method>POST</method>\t\t\t\t<method>PUT</method>\t\t\t\t<method>DELETE</method>\t\t\t\t<method>PATCH</method>\t\t\t</allowed-methods>\t\t\t<allowed-headers>\t\t\t\t<header>*</header>\t\t\t</allowed-headers>\t\t\t<expose-headers>\t\t\t\t<header>*</header>\t\t\t</expose-headers>\t\t</cors>\t</inbound>\t<backend>\t\t<base />\t</backend>\t<outbound>\t\t<base />\t</outbound>\t<on-error>\t\t<base />\t</on-error></policies>"
}
}

0 comments on commit 8bdd805

Please sign in to comment.