Azure API Management | Function App Backend

Posted by Andrew Wilson on Tuesday, October 1, 2024

Overview

GitHub Repository

Following on from a previous set of posts from earlier this year where I detailed how to securely implement Logic App Standard backends in Azure API Management, there has been questions on how this would be achieved in a similar manner with Azure Function Apps.

To read-up on how this was achieved with Standard Logic Apps have a look at the following:

At a high level comparison with Azure Logic Apps, Azure Functions are a developer-centric serverless compute offering allowing authors to write code in languages such as C#, Java, Javascript, Python, and PowerShell. Azure Functions are best suited for stateless computation and application specific tasks.

Azure Logic Apps are similar in that they too are a serverless offering but more specifically built as a workflow integration platform. It’s a low code/no code user-friendly development option for designing workflows, integrating different systems, and building business process automation.

Given that Standard Logic Apps share the same compute platform as Azure Function Apps, some of what this post will aim to achieve will be similar to that achieved with the posts highlighted above, but with enough differences that I would suggest reading on.

The method explored here (Linking a Azure Function App as an APIM API Backend) aims to be configurable (Both in Deployment and API setup), and secure; ensuring Principal of Least Privilege (PoLP). The diagram below provides an overview of what is to be achieved:

Overview

The overall design aims to abstract the backend from the API Operations, i.e. the backend points to the Azure Function App and the individual operations point to the respective HTTP triggered functions. The design also specifies granular access to the Function specific Shared-Access-Signature (SAS) key as opposed to the Function App SAS key. Providing access to the Function App Host or Admin key is simpler in deployment configuration but has a security concern in allowing access to any Function within the Function App. By utilising the specific Function host SAS key means that only specific access is granted; following in principal of least privilege and lessening the blast radius if a breach of security were to occur. Further to this, the Function SAS key will be held in the applications specific KeyVault where access to this secret will be conducted over Role Based Access Control (RBAC) restricted to the specific secret (again following PoLP). To see further details on this, see Azure RBAC Key Vault | Role Assignment for Specific Secret.

As with my previous posts, I have opted for Infrastructure as Code (IaC) as my method of implementation, specifically Bicep. I have broken down the implementation of the diagram above into two parts, Application Deployment, and API Deployment.

Application Deployment

The following diagram demonstrates how the application backend has been deployed. ApplicationDeployment

The deployment is split into three stages:

  1. Deploy the Core Application Components.
  2. Deploy the Functions to the recently deployed Function App.
  3. Store the Function specific SAS keys in KeyVault for later secure access.

In turn the Bicep for step 1 is shown below:

/**********************************
Bicep Template: Application Deploy
        Author: Andrew Wilson
***********************************/

targetScope = 'resourceGroup'

// ** Parameters **
// ****************

@description('A prefix used to identify the application resources')
param applicationPrefixName string

@description('The name of the application used for tags')
param applicationName string

@description('The location that the resources will be deployed to - defaulting to the resource group location')
param location string = resourceGroup().location

@description('The environment that the resources are being deployed to')
@allowed([
  'dev'
  'test'
  'prod'
])
param env string = 'dev'

// ** Variables **
// ***************

var applicationKeyVaultName = '${applicationPrefixName}${env}kv'
var funcApplicationAppServicePlanName = '${applicationPrefixName}${env}asp'
var funcStorageAccountName = '${applicationPrefixName}${env}st'
var applicationFunctionAppName = '${applicationPrefixName}${env}func'

var isProduction = env == 'prod'

// ** Resources **
// ***************

@description('Deploy the Application Specific Key Vault')
resource applicationKeyVaultDeploy 'Microsoft.KeyVault/vaults@2023-07-01' = {
  name: applicationKeyVaultName
  location: location
  tags: {
    Application: applicationName
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  properties: {
    sku: {
      family: 'A'
      name: 'standard'
    }
    tenantId: tenant().tenantId
    enableRbacAuthorization: true
    enableSoftDelete: isProduction
  }
}

@description('Deploy the App Service Plan used for Function App')
resource funcAppServicePlanDeploy 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: funcApplicationAppServicePlanName
  location: location
  tags: {
    Application: applicationName
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  sku: {
    name: 'Y1'
    tier: 'Dynamic'
  }
  properties: {}
}

@description('Deploy the Storage Account used for Function App')
resource funcStorageAccountDeploy 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: funcStorageAccountName
  location: location
  tags: {
    Application: applicationName
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
  properties: {
    supportsHttpsTrafficOnly: true
    minimumTlsVersion: 'TLS1_2'
    defaultToOAuthAuthentication: true
  }
}

@description('Deploy the Application Function App')
resource applicationFunctionAppDeploy 'Microsoft.Web/sites@2023-12-01' = {
  name: applicationFunctionAppName
  location: location
  tags: {
    Application: applicationName
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  kind: 'functionapp'
  properties: {
    serverFarmId: funcAppServicePlanDeploy.id
    publicNetworkAccess: 'Enabled'
    httpsOnly: true
  }
  resource config 'config@2022-09-01' = {
    name: 'appsettings'
    properties: {
      FUNCTIONS_EXTENSION_VERSION: '~4'
      FUNCTIONS_WORKER_RUNTIME: 'dotnet'
      WEBSITE_NODE_DEFAULT_VERSION: '~18'
      AzureWebJobsStorage: 'DefaultEndpointsProtocol=https;AccountName=${funcStorageAccountDeploy.name};AccountKey=${listKeys(funcStorageAccountDeploy.id, '2019-06-01').keys[0].value};EndpointSuffix=core.windows.net'
      WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: 'DefaultEndpointsProtocol=https;AccountName=${funcStorageAccountDeploy.name};AccountKey=${listKeys(funcStorageAccountDeploy.id, '2019-06-01').keys[0].value};EndpointSuffix=core.windows.net'
      WEBSITE_CONTENTSHARE: funcStorageAccountDeploy.name
    }
  }
}

// ** Outputs **
// *************

output applicationFunctionAppName string  = applicationFunctionAppName

Step 2 is the deployment of the Functions such as this simple C# Hello World request response:


using ...

namespace HelloWorldFunctions
{
    public static class HelloWorld
    {
        [FunctionName("HelloWorld")]
        public static async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
            ILogger log)
        {
            log.LogInformation("C# HTTP trigger function processed a request.");

            string name = req.Query["name"];

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            dynamic data = JsonConvert.DeserializeObject(requestBody);
            name = name ?? data?.name;

            string responseMessage = string.IsNullOrEmpty(name)
                ? "This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response."
                : $"Hello, {name}. This HTTP triggered function executed successfully.";

            return new OkObjectResult(responseMessage);
        }
    }
}

Step 3 demonstrated below takes a number of functions, retrieves their SAS keys and creates them as secrets in the Application KeyVault.

Note: To obtain the Function specific SAS key, the Function must have already been deployed to the Function App.

The following Bicep is used to obtain the Function specific SAS key: listKeys(resourceId('Microsoft.Web/sites/functions', applicationFunctionAppName, function),'2023-12-01').default

⚠️ Important

This implementation utilises the default Function SAS key, so special consideration should be taken in your own implementation regarding SAS expiration, revocation, and rotation.

/******************************************
Bicep Template: Application Secrets Deploy
        Author: Andrew Wilson
*******************************************/

targetScope = 'resourceGroup'

// ** Parameters **
// ****************

@description('Name of the Function App to add as a backend')
param applicationFunctionAppName string

@description('The name of the functions in the function app to add secrets for')
param functions string[]

@description('Name of the Key Vault to place secrets into')
param keyVaultName string

// ** Variables **
// ***************

// ** Resources **
// ***************

@description('Retrieve the existing Key Vault instance to store secrets')
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
  name: keyVaultName
}

@description('Vault the Functions key as a secret - Deployment principle requires RBAC permissions to do this')
resource vaultFunctionsKey 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = [for function in functions: {
  name: '${applicationFunctionAppName}-${function}-key'
  parent: keyVault
  tags: {
    ResourceType: 'FunctionApp'
    ResourceName: '${applicationFunctionAppName}-${function}'
  }
  properties: {
    contentType: 'string'
    value: listKeys(resourceId('Microsoft.Web/sites/functions', applicationFunctionAppName, function),'2023-12-01').default
  }
}]

// ** Outputs **
// *************

API Deployment

The following diagram demonstrates how API Management and the Function App backend API have been deployed.

APIDeployment

The deployment is split into two stages:

  1. Deploy an API Management Service Instance.
  2. Deploy respective Backend, Named Values, API, API Operations, and Policies.

The deployment of the API and its operations pointing at the Azure Function App requires the following components:

  1. Azure Role Assignment - This is the authorisation system that we will use to assign APIMs System Assigned Managed Identity access to the applications Key Vault, specifically the SAS Key secrets.
  2. APIM API and API Operations - Represents a set of available operations with each containing a reference to a backend service that implements the API.
  3. APIM Named Values - This is a global collection of name/value pairs within the APIM Instance. Using APIM Policies we can use Named Values to further API configuration. Named Values can store constant string values, secrets, or more importantly Key Vault references to secrets.
  4. APIM Backend - APIM Backend is an HTTP service that implements a front-end API. Setting up the Backend means that we can abstract backend service information, promoting reusability and improved governance.
  5. APIM Policies - Policies are statements that are run sequentially on a given request or response for an API. These statements further our ability to configure the API and its abilities such as adding further parameters, setting a backend, making use of configured Named Values.

The Bicep for Step 1 is shown below:

/**********************************
Bicep Template: APIM Instance Deploy
        Author: Andrew Wilson
***********************************/

targetScope = 'resourceGroup'

// ** Parameters **
// ****************

@description('A prefix used to identify the api resources')
param apiPrefixName string

@description('The location that the resources will be deployed to - defaulting to the resource group location')
param location string = resourceGroup().location

@description('The environment that the resources are being deployed to')
@allowed([
  'dev'
  'test'
  'prod'
])
param env string = 'dev'

@description('The apim publisher email')
param apimPublisherEmail string

@description('The apim publisher name')
param apimPublisherName string

// ** Variables **
// ***************

var apimInstanceName = '${apiPrefixName}${env}apim'

// ** Resources **
// ***************

@description('Deployment of the APIM instance')
resource apimInstanceDeploy 'Microsoft.ApiManagement/service@2022-08-01' = {
  name: apimInstanceName
  location: location
  tags: {
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  sku: {
    capacity: 0
    name: 'Consumption'
  }
  properties: {
    publisherEmail: apimPublisherEmail
    publisherName: apimPublisherName
  }
  identity: {
    type: 'SystemAssigned'
  }
}

// ** Outputs **
// *************

output apimInstanceName string = apimInstanceName

The Bicep for step 2 makes use of module deployments and policies loaded as text into variables. Further to this, the Function operations are defined within a JSON control file so that the Bicep templates can remain agnostic to operation implementation. The JSON control file also allows the definition of multiple API operations per Function due to a function allowing multiple HTTP Methods such as GET and POST.

The structure of the operations within the Control file allow for APIM to provide a different Operation name to that specified on the backend such as:

  • APIM /HWGET –> Function /HellowWorld

Each Operation will also detail which Function it is associated with as to utilise the correct Named Value in APIM containing reference to KeyVault Function SaS Key.

The JSON Control file is shown below:

[
    {
        "name": "HelloWorldGet",
        "backendFunctionName": "HelloWorld",
        "rewriteUrl": "/HelloWorld",
        "properties": {
            "displayName": "Hello World GET",
            "method": "GET",
            "urlTemplate": "/HWGET",
            "description": "Hello World GET",
            "templateParameters": [],
            "request": {
                "queryParameters": [
                    {
                        "name": "name",
                        "type": "string",
                        "required": false
                    }
                ],
                "headers": []
            },
            "responses": [
                {
                    "statusCode": 200
                }
            ]
        }
    },
    {
        "name": "HelloWorldPost",
        "backendFunctionName": "HelloWorld",
        "rewriteUrl": "/HelloWorld",
        "properties": {
            "displayName": "Hello World POST",
            "method": "POST",
            "urlTemplate": "/HWPOST",
            "description": "Hello World POST",
            "templateParameters": [],
            "request": {
                "queryParameters": [],
                "headers": []
            },
            "responses": [
                {
                    "statusCode": 200
                }
            ]
        }
    }
]

The Main deployment template is shown below:

/******************************************
Bicep Template: Function App APIM API
        Author: Andrew Wilson
*******************************************/

targetScope = 'resourceGroup'

// ** Parameters **
// ****************

@description('Name of the Function App to add as a backend')
param functionAppName string

@description('Name of the APIM instance')
param apimInstanceName string

@description('Name of the Key Vault instance')
param keyVaultName string

@description('Name of the API to create in APIM')
param apiName string

@description('APIM API path')
param apimAPIPath string

@description('APIM API display name')
param apimAPIDisplayName string

// ** Variables **
// ***************

// Function App Base URL
var funcBaseUrl = 'https://${functionApp.properties.defaultHostName}/api'

// Key Vault Read Access
var keyVaultSecretsUserRoleDefinitionId = '4633458b-17de-408a-b874-0445c86b69e6'

// All Operations Policy
var apimAPIPolicyRaw = loadTextContent('./APIM-Policies/APIMAllOperationsPolicy.xml')
var apimAPIPolicy = replace(apimAPIPolicyRaw, '__apiName__', apiName)

// Operation Policy Template
var apimOperationPolicyRaw = loadTextContent('./APIM-Policies/APIMOperationPolicy.xml')

// Operation List and Details
var apimApiOperations = loadJsonContent('apimApiConfigurations/helloWorldApiOperationsConfiguration.json')

// Obtain single distinct list of functions used in operations 
var allFunction = map(apimApiOperations, op => op.backendFunctionName)
var uniqueFunctions = union(allFunction, allFunction)

// ** Resources **
// ***************

@description('Retrieve the existing APIM Instance, will add APIs and Policies to this resource')
resource apimInstance 'Microsoft.ApiManagement/service@2022-08-01' existing = {
  name: apimInstanceName
}

@description('Create the Function App API in APIM')
resource functionAppAPI 'Microsoft.ApiManagement/service/apis@2022-08-01' = {
  name: apiName
  parent: apimInstance
  properties: {
    displayName: apimAPIDisplayName
    subscriptionRequired: true
    path: apimAPIPath
    protocols: [
      'https'
    ]
  }
}

@description('Retrieve the existing Function App for linking as a backend')
resource functionApp 'Microsoft.Web/sites@2022-09-01' existing = {
  name: functionAppName
}

@description('Deploy function App API operations')
module functionAppAPIOperation 'Modules/apimOperation.azuredeploy.bicep' = [
  for operation in apimApiOperations: {
    name: '${operation.name}-deploy'
    params: {
      parentName: '${apimInstance.name}/${functionAppAPI.name}'
      apiManagementApiOperationDefinition: operation
    }
  }
]

@description('Retrieve the existing application Key Vault instance')
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = {
  name: keyVaultName
}

@description('Retrieve the existing function app func key secret')
resource vaultFunctionAppKey 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = [
  for function in uniqueFunctions: {
    name: '${functionAppName}-${function}-key'
    parent: keyVault
  }
]

@description('Grant APIM Key Vault Reader for the function app API key secret')
resource grantAPIMPermissionsToSecret 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
  for (function, index) in uniqueFunctions: {
    name: guid(keyVaultSecretsUserRoleDefinitionId, keyVault.id, function)
    scope: vaultFunctionAppKey[index]
    properties: {
      roleDefinitionId: subscriptionResourceId(
        'Microsoft.Authorization/roleDefinitions',
        keyVaultSecretsUserRoleDefinitionId
      )
      principalId: apimInstance.identity.principalId
      principalType: 'ServicePrincipal'
    }
  }
]

@description('Create the named values for the function app API keys')
resource functionAppBackendNamedValues 'Microsoft.ApiManagement/service/namedValues@2022-08-01' = [
  for (function, index) in uniqueFunctions: {
    name: '${apiName}-${function}-key'
    parent: apimInstance
    properties: {
      displayName: '${apiName}-${function}-key'
      tags: [
        'key'
        'functionApp'
        '${apiName}'
        '${function}'
      ]
      secret: true
      keyVault: {
        identityClientId: null
        secretIdentifier: '${keyVault.properties.vaultUri}secrets/${vaultFunctionAppKey[index].name}'
      }
    }
    dependsOn: [
      grantAPIMPermissionsToSecret
    ]
  }
]

@description('Create the backend for the Function App API')
resource functionAppBackend 'Microsoft.ApiManagement/service/backends@2022-08-01' = {
  name: apiName
  parent: apimInstance
  properties: {
    protocol: 'http'
    url: funcBaseUrl
    resourceId: uri(environment().resourceManager, functionApp.id)
    tls: {
      validateCertificateChain: true
      validateCertificateName: true
    }
  }
}

@description('Create a policy for the function App API and all its operations - linking the function app backend')
resource functionAppAPIAllOperationsPolicy 'Microsoft.ApiManagement/service/apis/policies@2022-08-01' = {
  name: 'policy'
  parent: functionAppAPI
  properties: {
    value: apimAPIPolicy
    format: 'xml'
  }
  dependsOn: [
    functionAppBackend
  ]
}

@description('Add query strings via policy')
module operationPolicy './Modules/apimOperationPolicy.azuredeploy.bicep' = [
  for (operation, index) in apimApiOperations: {
    name: 'operationPolicy-${operation.name}'
    params: {
      parentStructureForName: '${apimInstance.name}/${functionAppAPI.name}/${operation.name}'
      functionRelativePath: operation.rewriteUrl
      rawPolicy: apimOperationPolicyRaw
      key: '{{${apiName}-${operation.backendFunctionName}-key}}'
    }
    dependsOn: [
      functionAppAPIOperation
    ]
  }
]

// ** Outputs **
// *************

The APIM All Operations Policy template provides the link to the APIM Function App Backend as shown below:

The Delete Set Header is used to remove subscription key headers from the forwarded request to the backend. For more information see Azure API Management | Unintentional Pass through of Subscription Key Header.

<!-- API ALL OPERATIONS SCOPE -->
<policies>
    <inbound>
        <base />
        <set-backend-service id="functionapp-backend-policy" backend-id="__apiName__" />
        <set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

The APIM Operation Policy template is used to conduct a uri rewrite to point to the specific Function backend as defined in the JSON Control file. The Policy also appends the Function specific SAS Key “code” Named Value ref to the query parameter set. The actual value is obtained on invocation from KeyVault.

<!-- API OPERATION SCOPE -->
<policies>
    <inbound>
        <base />
        <rewrite-uri template="__uri__" />
        <set-query-parameter name="code" exists-action="append">
            <value>__key__</value>
        </set-query-parameter>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

The API Operation Module deployment makes use of Bicep User Defined Types to conduct validation of the Operation Control JSON as shown below:

/**********************************
Bicep Template: API Operation Deploy
        Author: Andrew Wilson
***********************************/

targetScope = 'resourceGroup'

// ** User Defined Types **
// ************************

// TYPES: APIM API Operation
// - API Operation Definition
// - Query Parameter
// - Template Parameter
// - Header
// - Response

@export()
@description('APIM API Operation Definition')
@sealed()
type apiOperationDefinition = {
  @minLength(1)
  @maxLength(80)
  @description('The resource name')
  name: string
  @description('The backend function name')
  backendFunctionName: string
  @minLength(1)
  @maxLength(1000)
  @description('Relative URL rewrite template for the Function backend (in policy).')
  rewriteUrl: string
  @description('Properties of the Operation Contract')
  properties: {
    @minLength(1)
    @maxLength(300)
    @description('Operation Name.')
    displayName: string
    @description('A Valid HTTP Operation Method. Typical Http Methods like GET, PUT, POST but not limited by only them.')
    method: string
    @minLength(1)
    @maxLength(1000)
    @description('Relative URL template identifying the target resource for this operation. May include parameters. Example: /customers/{cid}/orders/{oid}/?date={date}')
    urlTemplate: string
    @maxLength(1000)
    @description('Description of the operation. May include HTML formatting tags.')
    description: string
    @description('Collection of URL template parameters.')
    templateParameters: templateParameter[]?
    @description('An entity containing request details.')
    request: {
      @description('Collection of operation request query parameters.')
      queryParameters: queryParameter[]?
      @description('Collection of operation request headers.')
      headers: header[]?
    }
    @description('Array of Operation responses.')
    responses: response[]?
  }
}

@export()
type queryParameter = {
  @description('Parameter name.')
  name: string
  @description('Parameter type.')
  type: string
  @description('Specifies whether parameter is required or not.')
  required: bool?
}

@export()
type templateParameter = {
  @description('Parameter name.')
  name: string
  @description('Parameter type.')
  type: string
  @description('Specifies whether parameter is required or not.')
  required: bool?
}

@export()
type header = {
  @description('Header name.')
  name: string
  @description('Header type.')
  type: string
  @description('Specifies whether header is required or not.')
  required: bool?
  @description('Header values.')
  values: string[]?
}

@export()
type response = {
  @description('Operation response HTTP status code.')
  statusCode: int
}

// ** Parameters **
// ****************

@description('API Management Service API Name Path')
param parentName string

@description('Definition of the operation to create')
param apiManagementApiOperationDefinition apiOperationDefinition

// ** Variables **
// ***************

// ** Resources **
// ***************

@description('Deploy function App API operation')
resource functionAppAPIGetOperation 'Microsoft.ApiManagement/service/apis/operations@2022-08-01' = {
  name: '${parentName}/${apiManagementApiOperationDefinition.name}'
  properties: apiManagementApiOperationDefinition.properties
}

// ** Outputs **
// *************

The API Operation Policy Module Deployment is as follows:

/********************************************
Bicep Template: APIM FUNC API Operation Policy
        Author: Andrew Wilson
********************************************/

targetScope = 'resourceGroup'

// ** Parameters **
// ****************

@description('The Parent naming structure for the Policy')
param parentStructureForName string

@description('The function relative path')
param functionRelativePath string

@description('The raw policy document template')
param rawPolicy string

@description('The named value name for the workflow key')
param key string = ''

// ** Variables **
// ***************

var policyURI = replace(rawPolicy, '__uri__', functionRelativePath)
var policyKEY = replace(policyURI, '__key__', key)

// ** Resources **
// ***************

@description('Add query strings via policy')
resource operationPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2022-08-01' = {
  name: '${parentStructureForName}/policy'
  properties: {
    value: policyKEY
    format: 'xml'
  }
}

// ** Outputs **
// *************

Hope this helps, and have fun.