Azure API Management | Logic App (Standard) Backend

Overview

GitHub Repository

Updated [31/01/2024]: See New Post showing methods of linking a Logic App Standard as a Backend to APIM through a Swagger Definition.

I have recently been reviewing the method in which a Logic App (Standard) workflow would be setup as an API in API Management. My aim is to overcome and simplify the limitation whereby directly importing a Logic App (Standard) workflow is not available, only in consumption.

After some exploration I believe I have identified a configurable and secure method in setting up the front-to-backend routing as can be seen in the diagram below: Overview

The overall design aims to abstract the backend from the api operations, i.e. the backend points to the Logic App and the individual operations point to the respective workflows. The design also specifies granular access to the workflow Shared-Access-Signature (sig) held in the applications specific KeyVault (to see further details on this, see Azure RBAC Key Vault | Role Assignment for Specific Secret). Furthermore, the additional required parameters that are necessary to call a workflow have been implemented through APIM policies to remove the need for callers to understand backend implementation.

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 Logic Workflows to the recently deployed Logic App.
  3. Store the Workflow SAS keys in KeyVault for later secure use.

In turn the Bicep for step 1 is shown below:

/***************************************
Bicep Template: Application Core Deploy
***************************************/

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 lgApplicationAppServicePlanName = '${applicationPrefixName}${env}asp'
var lgStorageAccountName = '${applicationPrefixName}${env}st'
var applicationLogicAppName = '${applicationPrefixName}${env}logic'

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 Logic App Standard')
resource lgAppServicePlanDeploy 'Microsoft.Web/serverfarms@2022-09-01' = {
  name: lgApplicationAppServicePlanName
  location: location
  tags: {
    Application: applicationName
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  kind: 'elastic'
  sku: {
    name: 'WS1'
    tier: 'WorkflowStandard'
    size: 'WS1'
    family: 'WS'
    capacity: 1
  }
}

@description('Deploy the Storage Account used for Logic App Standard')
resource lgStorageAccountDeploy 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: lgStorageAccountName
  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 Standard Logic App')
resource applicationLogicAppStandardDeploy 'Microsoft.Web/sites@2022-09-01' = {
  name: applicationLogicAppName
  location: location
  tags: {
    Application: applicationName
    Environment: env
    Version: deployment().properties.template.contentVersion
  }
  kind: 'functionapp,workflowapp'
  properties: {
    serverFarmId: lgAppServicePlanDeploy.id
    publicNetworkAccess: 'Enabled'
    httpsOnly: true
  }
  resource config 'config@2022-09-01' = {
    name: 'appsettings'
    properties: {
      FUNCTIONS_EXTENSION_VERSION: '~4'
      FUNCTIONS_WORKER_RUNTIME: 'node'
      WEBSITE_NODE_DEFAULT_VERSION: '~18'
      AzureWebJobsStorage: 'DefaultEndpointsProtocol=https;AccountName=${lgStorageAccountDeploy.name};AccountKey=${listKeys(lgStorageAccountDeploy.id, '2019-06-01').keys[0].value};EndpointSuffix=core.windows.net'
      WEBSITE_CONTENTAZUREFILECONNECTIONSTRING: 'DefaultEndpointsProtocol=https;AccountName=${lgStorageAccountDeploy.name};AccountKey=${listKeys(lgStorageAccountDeploy.id, '2019-06-01').keys[0].value};EndpointSuffix=core.windows.net'
      WEBSITE_CONTENTSHARE: lgStorageAccountDeploy.name
      AzureFunctionsJobHost__extensionBundle__id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows'
      AzureFunctionsJobHost__extensionBundle__version: '${'[1.*,'}${' 2.0.0)'}'
      APP_KIND: 'workflowApp'
    }
  }
}

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

output keyVaultName string = applicationKeyVaultName
output applicationLogicAppName string  = applicationLogicAppName

Step 2 is the deployment of the workflow definition such as this very simple request response:

{
    "definition": {
        "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
        "actions": {
            "Response": {
                "type": "Response",
                "kind": "Http",
                "inputs": {
                    "statusCode": 200
                },
                "runAfter": {}
            }
        },
        "contentVersion": "1.0.0.0",
        "outputs": {},
        "triggers": {
            "When_a_HTTP_request_is_received": {
                "type": "Request",
                "kind": "Http"
            }
        }
    },
    "kind": "Stateless"
}

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

/*****************************************
Bicep Template: Application Secrets Deploy
*****************************************/

targetScope = 'resourceGroup'

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

@description('Name of the Logic App to place workflow(s) sig into KeyVault')
param applicationLogicAppName string

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

@description('Array of Workflows to obtain sigs from.')
param workflows array = [
  {
    workflowName: ''
    workflowTrigger: ''
  }
]

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



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

@description('Retrieve the existing Logic App')
resource logicApp 'Microsoft.Web/sites@2022-09-01' existing = {
  name: applicationLogicAppName
}

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

@description('Vault the Logic App workflow sig as a secret - Deployment principle requires RBAC permissions to do this')
resource vaultLogicAppKey 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = [for workflow in workflows: {
  name: '${logicApp.name}-${workflow.workflowName}-sig'
  parent: keyVault
  tags: {
    ResourceType: 'LogicAppStandard'
    ResourceName: logicApp.name
  }
  properties: {
    contentType: 'string'
    value: listCallbackUrl(resourceId('Microsoft.Web/sites/hostruntime/webhooks/api/workflows/triggers', logicApp.name, 'runtime', 'workflow', 'management', workflow.workflowName, workflow.workflowTrigger), '2022-09-01').queries.sig
  }
}]

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

API Deployment

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

ApplicationDeployment

The deployment is split into two stages:

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

Our deployment of the API and its operations pointing at the Standard Logic 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 sig secret.
  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. Normally with consumption Logic Apps when imported into APIM a Backend Service is automatically created for you. This is not currently possible with Standard Logic Apps. However, Standard Logic Apps have the same foundations as Azure Functions which means that we can set this up through custom backends (i.e. is treated as a Function Backend).
    • 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
***********************************/

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. The Main deployment template is shown below:

/******************************************
Bicep Template: Logic App Standard APIM API
*******************************************/

targetScope = 'resourceGroup'

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

@description('Name of the Logic App to add as a backend')
param logicAppName 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

@description('Array of API operations')
param apimAPIOperations array = [
  {
    name: ''
    displayName: ''
    method: ''
    lgWorkflowName: ''
    lgWorkflowTrigger: ''
  }
]

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

// Logic App Base URL
var lgBaseUrl = 'https://${logicApp.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')

// ** 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 Logic App API in APIM')
resource logicAppAPI 'Microsoft.ApiManagement/service/apis@2022-08-01' = {
  name: apiName
  parent: apimInstance
  properties: {
    displayName: apimAPIDisplayName
    subscriptionRequired: true
    path: apimAPIPath
    protocols: [
      'https'
    ]
  }
}

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

@description('Deploy logic App API operation')
module logicAppAPIOperation 'Modules/apimOperation.azuredeploy.bicep' = [for operation in apimAPIOperations :{
  name: '${operation.name}-deploy'
  params: {
    parentName: '${apimInstance.name}/${logicAppAPI.name}'
    lgCallBackObject: listCallbackUrl(resourceId('Microsoft.Web/sites/hostruntime/webhooks/api/workflows/triggers', logicAppName, 'runtime', 'workflow', 'management', operation.lgWorkflowName, operation.lgWorkflowTrigger), '2022-09-01')
    operationDisplayName: operation.displayName
    operationMethod: operation.method
    operationName: operation.name

  }
}]

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

@description('Retrieve the existing logicapp workflow sig secret')
resource vaultLogicAppKey 'Microsoft.KeyVault/vaults/secrets@2023-07-01' existing = [for operation in apimAPIOperations: {
  name: '${logicAppName}-${operation.lgWorkflowName}-sig'
  parent: keyVault
}]

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

@description('Create the named values for the logic app API sigs')
resource logicAppBackendNamedValues 'Microsoft.ApiManagement/service/namedValues@2022-08-01' = [for (operation, index) in apimAPIOperations: {
  name: '${apiName}-${operation.name}-sig'
  parent: apimInstance
  properties: {
    displayName: '${apiName}-${operation.name}-sig'
    tags: [
      'sig'
      'logicApp'
      '${apiName}'
      '${operation.name}'
    ]
    secret: true
    keyVault: {
      identityClientId: null
      secretIdentifier: '${keyVault.properties.vaultUri}secrets/${vaultLogicAppKey[index].name}'
    }
  }
  dependsOn: [
    grantAPIMPermissionsToSecret
  ]
}]

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

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

@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}/${logicAppAPI.name}/${operation.name}'
    rawPolicy: apimOperationPolicyRaw
    apiVersion: listCallbackUrl(resourceId('Microsoft.Web/sites/hostruntime/webhooks/api/workflows/triggers', logicAppName, 'runtime', 'workflow', 'management', operation.lgWorkflowName, operation.lgWorkflowTrigger), '2022-09-01').queries['api-version']
    sp: listCallbackUrl(resourceId('Microsoft.Web/sites/hostruntime/webhooks/api/workflows/triggers', logicAppName, 'runtime', 'workflow', 'management', operation.lgWorkflowName, operation.lgWorkflowTrigger), '2022-09-01').queries.sp
    sv: listCallbackUrl(resourceId('Microsoft.Web/sites/hostruntime/webhooks/api/workflows/triggers', logicAppName, 'runtime', 'workflow', 'management', operation.lgWorkflowName, operation.lgWorkflowTrigger), '2022-09-01').queries.sv
    sig: '{{${apiName}-${operation.name}-sig}}'
  }
  dependsOn: [
    logicAppAPIOperation
  ]
}]

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

The APIM All Operations Policy template is as follows:

The Deletion 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="logicapp-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 as follows:

By setting the parameters as policy means that those calling the API do not need to be aware of backend configuration. Thus allowing for complete separation of concerns.

<!-- API OPERATION SCOPE -->
<policies>
    <inbound>
        <base />
        <set-query-parameter name="api-version" exists-action="append">
            <value>__api-version__</value>
        </set-query-parameter>
        <set-query-parameter name="sp" exists-action="append">
            <value>__sp__</value>
        </set-query-parameter>
        <set-query-parameter name="sv" exists-action="append">
            <value>__sv__</value>
        </set-query-parameter>
        <set-query-parameter name="sig" exists-action="append">
            <value>__sig__</value>
        </set-query-parameter>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

The API Operation Module deployment is as follows:

/**********************************
Bicep Template: API Operation Deploy
***********************************/

targetScope = 'resourceGroup'

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

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

@description('Name of the API Operation')
param operationName string

@description('Display name for the API operation')
param operationDisplayName string

@description('API Operation Method e.g. GET')
param operationMethod string

@description('Logic App Call Back object containing URL and other details')
param lgCallBackObject object

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

var operationUrlBase = split(split(lgCallBackObject.value, '?')[0], '/api')[1]
var hasRelativePath = lgCallBackObject.?relativePath != null ? true : false
var pathParametersList = hasRelativePath ? lgCallBackObject.relativePathParameters : []
var pathParameters = [for pathParameter in pathParametersList: {
    name: pathParameter
    type: 'string'
}]
var RelativePathHasBeginingSlash = hasRelativePath ? first(lgCallBackObject.relativePath) == '/' : false
var operationUrl = hasRelativePath && RelativePathHasBeginingSlash ? '${operationUrlBase}${lgCallBackObject.relativePath}' : hasRelativePath && !RelativePathHasBeginingSlash ? '${operationUrlBase}/${lgCallBackObject.relativePath}' : operationUrlBase

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

@description('Deploy logic App API operation')
resource logicAppAPIGetOperation 'Microsoft.ApiManagement/service/apis/operations@2022-08-01' = {
  name: '${parentName}/${operationName}'
  properties: {
    displayName: operationDisplayName
    method: operationMethod
    urlTemplate: operationUrl
    templateParameters: hasRelativePath ? pathParameters : null
  }
}

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

The API Operation Policy Module Deployment is as follows:

/********************************************
Bicep Template: APIM LG API Operation Policy
********************************************/

targetScope = 'resourceGroup'

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

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

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

@description('The Logic App service API version')
param apiVersion string

@description('The Logic App workflow permissions')
param sp string

@description('The Logic App workflow version number of the query parameters')
param sv string

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

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

var policyApiVersion = replace(rawPolicy, '__api-version__', apiVersion)
var policySP = replace(policyApiVersion, '__sp__', sp)
var policySV = replace(policySP, '__sv__', sv)
var policySIG = replace(policySV, '__sig__', sig)


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

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

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

Have a go and see if this simplifies your Standard Logic App APIM Configurations.

For the original version of this post see Andrew Wilson's personal blog at Azure API Management | Logic App (Standard) Backend