Introduction
While developing sample code for my OpenAI GPT client, I wanted to stand up an Azure Function that answers direct messages sent to my Twitter account with responses GPT-3. This is inspired by a friend who has a parody account on Twitter and often gets odd DM messages from strangers. The Azure Function web hook responds in accordance with the personality of the parody account. Both Twitter credentials and an OpenAI API key are required. While this was developed primarily as a code sample for my GPT-3 API client Nuget package, I still applied secure coding practices and stored the credentials in an Azure Key Vault. Further, I want the deployment to be as seamless as possible and so I started with an ARM template, but converted over to a Bicep template. Bicep is easier to read than ARM and extensions are available both for VS Code and Visual Studio.
This article shows how to do the following using Bicep:
- Deploy an Azure Key Vault
- Deploy an Azure Function Application
- Grant the Azure Function Application access to secrets in the Azure Key Vault
Completed Bicep and PowerShell scripts are available here.
How to Install Bicep
First things first, let's get Bicep.
General guidelines for installing Bicep are available at Install Bicep Tools. The examples in this article use PowerShell.
To use Bicep with PowerShell:
- Install (or upgrade) to PowerShell 5.6.0+ at Install PowerShell on Windows
- Install Azure Az Powershell. Open a PowerShell Command prompt as a local admin and run:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force
- Download and run the latest Windows Bicep installer.
- Close any PowerShell terminals and reopen to validate the installation by running:
bicep --version
Convert from ARM to Bicep
Starting with ARM templates, I found the syntax difficult to follow when functions were applied. Take the following example that concatenates a series of Twitter credentials into a JSON string in preparation for storage as an Azure Key Vault secret. The logic is difficult to read between the escaped double quotes used by JSON and the single quotes of the concat function.
{
"type": "secrets",
"name": "twittercreds",
"apiVersion": "2016-10-01",
"properties": {
"value": "[concat('{ \"AccessToken\": \"', parameters('twitterAccessToken'), '\", \"AccessTokenSecret\": \"', parameters('twitterAccessTokenSecret'), '\", \"ConsumerKey\": \"', parameters('twitterConsumerKey'),'\", \"ConsumerSecret\": \"', parameters('twitterConsumerSecret'), '\"}')]"
}
}
Converting from ARM to Bicep is a simple command line operation.
az bicep decompile --file .\twitterfunc.jsonc
The same logic is expressed in the Bicep template using string interpolation.
resource twitterchatgpt_dev_kv01_twittercreds 'Microsoft.KeyVault/vaults/secrets@2016-10-01' = {
parent: twitterchatgpt_dev_kv01
name: 'twittercreds'
properties: {
value: '{ "AccessToken": "${twitterAccessToken}", "AccessTokenSecret": "${twitterAccessTokenSecret}", "ConsumerKey": "${twitterConsumerKey}", "ConsumerSecret": "${twitterConsumerSecret}"}'
}
}
This is much easier to read than the original implementation; however, I did run into a conversion issue with dependsOn references. This block from the original ARM template resulted in an error.
{
"name": "functionName",
"type": "Microsoft.Web/sites",
. . .
"dependsOn": [
"[resourceId('Microsoft.Web/serverfarms', 'hostingPlanName')]",
"[resourceId('Microsoft.Storage/storageAccounts', 'storageAccountName')]"
],
Bicep translated these as errors that surfaced in Visual Studio Code:
ARM is more forgiving in accepting plain text to reference resource. I called out these dependencies for the sake of explicit documentation. They're not strictly necessary and so I removed them.
Transpiling to ARM - There and Back Again
Like running a translation of one language to another in Google Translate and back again, it's helpful to see how the translated phrase comes back to the original language. When deploying a Bicep template, Azure transpires to ARM-JSON. We can do the same thing at the command line with:
bicep build twitterfunc.bicep
In the string concatenation example, Bicep opted for a format function rather than concat. While easier to read than the original, Bicep's use of string interpolation is the clearest implementation.
{
"type": "Microsoft.KeyVault/vaults/secrets",
"apiVersion": "2016-10-01",
"name": "[format('{0}/{1}', 'twitterchatgpt-dev', 'twittercreds')]",
"properties": {
"value": "[format('{{ \"AccessToken\": \"{0}\", \"AccessTokenSecret\": \"{1}\", \"ConsumerKey\": \"{2}\", \"ConsumerSecret\": \"{3}\"}}', parameters('twitterAccessToken'), parameters('twitterAccessTokenSecret'), parameters('twitterConsumerKey'), parameters('twitterConsumerSecret'))]"
},
"dependsOn": ["[resourceId('Microsoft.KeyVault/vaults', 'twitterchatgpt-dev')]"]
}
The ARM template translated back from Bicep added more dependencies to the Azure function than I had originally, but this time it used variables rather than explicit resource names.
"dependsOn": [
"[resourceId('Microsoft.Insights/components', variables('applicationInsightsName'))]",
"[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
"[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]",
"[resourceId('Microsoft.KeyVault/vaults', variables('keyVaultName'))]",
"[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'openaicreds')]",
"[resourceId('Microsoft.KeyVault/vaults/secrets', variables('keyVaultName'), 'twittercreds')]"
]
Building the Bicep Template
This template is based on the Bicep file at Quickstart: Create and deploy Azure Functions resources using Bicep which includes a storage account, hosting plan, function, and Application Insights. The next steps assume familiarity with that template. The template is saved as twitterfunc.bicep. It does not include secure parameters or an AKV (Azure Key Vault). This section will add the AKV, secrets, and grant rights to the Azure Function Application to the secrets using least-privilege principles.
First, we'll add parameters to accept secure strings.
@description('Twitter credentials in JSON.')
@secure()
param twitterCredsJson string
@description('OpenAI credentials in JSON.')
@secure()
param openAIAPICredsJson string
This is a departure from the prior configuration settings where the Twitter credentials were built from concatenated strings. Here, the credentials are already passed as JSON strings. Moreover, they are passed as secure strings which prevents their plain text values from appearing in any deployment logs.
Next comes the AKV.
resource twitterchatgpt_dev_kv 'Microsoft.KeyVault/vaults@2019-09-01' = {
name: keyVaultName
location: twitgptgrouppname
tags: {
displayName: 'twitterchatgpt'
}
properties: {
enabledForDeployment: true
enabledForTemplateDeployment: true
enabledForDiskEncryption: true
tenantId: tenantId
enableSoftDelete: true
accessPolicies: []
sku: {
name: 'standard'
family: 'A'
}
}
}
Note that it does not apply any access policies. We'll grant Get access to the secrets to the Azure function later. Next come the secrets.
resource twitterchatgpt_dev_kv_twittercreds 'Microsoft.KeyVault/vaults/secrets@2016-10-01' = {
parent: twitterchatgpt_dev_kv
name: 'twittercreds'
tags: {
displayName: 'twitterchatgpt'
}
properties: {
value: twitterCredsJson
}
}
resource twitterchatgpt_dev_kv_openaiapicreds 'Microsoft.KeyVault/vaults/secrets@2016-10-01' = {
parent: twitterchatgpt_dev_kv
name: 'openaicreds'
tags: {
displayName: 'twitterchatgpt'
}
properties: {
value: openAIAPICredsJson
}
}
The Twitter and OpenAI credential secure strings are saved to our AKV secrets.
resource
function 'Microsoft.Web/sites@2020-12-01' = {
name: appName
location: twitgptgrouppname
kind: 'functionapp'
tags: {
displayName: 'twitterchatgpt'
}
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: hostingPlan.id
siteConfig: {
ftpsState: 'Disabled'
minTlsVersion: '1.2'
http20Enabled: true
appSettings: overwriteFuncSettings ? [... {
name: 'TWITTER_CREDS'
value: '@Microsoft.KeyVault(VaultName=${twitterchatgpt_dev_kv.name};SecretName=${twitterchatgpt_dev_kv_twittercreds.name})'
} {
name: 'OPENAI_API_CREDS'
value: '@Microsoft.KeyVault(VaultName=${twitterchatgpt_dev_kv.name};SecretName=${twitterchatgpt_dev_kv_openaiapicreds.name})'
}] : null
}
httpsOnly: true
}
}
The @Microsoft.KeyVault macro function reads the Twitter and OpenAI secrets from the AKV and passes the decrypted values to the Azure Function. No further code is required in the Azure Function to read the credentials. They're referenced from environment variables like any other function app setting.
IConfiguration config = context.Configuration;
string? twitterCredString = config["TWITTER_CREDS"];
WebhookCredentials? twitterCreds = string.IsNullOrWhiteSpace(twitterCredString) ?
null : JsonSerializer.Deserialize<WebhookCredentials>(twitterCredString);
At this point, the Azure Function has not been granted rights to the AKV secrets. We have given the Azure Function a SystemAssigned managed identity.
identity: {
type: 'SystemAssigned'
}
This is surfaced in the Azure portal.
And the configuration settings point to the Azure Key Vault.
The Bicep visualization of the template gives us:
Now we have an issue with the order of operations. The Azure Function has a reference to the AKV secrets but doesn't yet have access to them. The AKV was already created and cannot be modified from the same template. In an ARM template, this could be resolved using a subtemplate. With Bicep, this can be applied in a module that updates the AKV policies to grant Get secret rights to the Azure Function's system identity. At the end of the template, add:
module appService 'updateakv.bicep' = {
name: 'appService'
params: {
funcName: function.name
location: location
keyVaultName: keyVaultName
}
}
This module passes the function name, resource group, and AKV name. And then create a new Bicep file in the same directory named updateakv.bicep with the contents:
@description('Location for all resources.')
param funcName string
@description('Location for all resources.')
param location string = resourceGroup().location
@minLength(3)
@maxLength(24)
@description('Name of the keyvault that stores Twitter and OpenAI credentials.')
param keyVaultName string
var tenantId = tenant().tenantId
resource
function 'Microsoft.Web/sites@2020-12-01'
existing = {
name: funcName
}
resource twitterchatgpt_dev_kv 'Microsoft.KeyVault/vaults@2019-09-01' = {
name: keyVaultName
location: location
tags: {
displayName: 'twitterchatgpt'
}
properties: {
tenantId: tenantId
accessPolicies: [{
tenantId: tenantId
objectId: function.identity.principalId
permissions: {
secrets: ['get']
}
}]
sku: {
name: 'standard'
family: 'A'
}
}
}
This could have just passed the principalId of the keyvault, but it's helpful to show that Bicep modules can reference existing resources. Here, the Azure Function Application is used as an existing resource to pull the principalId from the function app's configured identity and grant that identity Get rights to the AKV secrets. This results in a much simpler template visualization.
This template results in the following access policy grant.
Finally, the deployment is kicked off with a PowerShell script which creates the twitter-chatgpt resource group in the East US region. While we could use a parameter file, that would require typing the secrets in for every deployment since the parameter file can't store encrypted secrets. Instead, the OpenAI and Twitter credentials are read in from environment variables, stored as SecureStrings, and passed as secure parameters.
$resourceGroupName = "twitter-chatgpt"
New - AzResourceGroup `
-Name $resourceGroupName ` - Location "East US"
$templateFile = ".\twitterfunc.bicep"
$twitterCredsJson = ConvertTo - SecureString $Env: TWITTER_CREDS - AsPlainText - Force
$OpenAIAPICreds = ConvertTo - SecureString $Env: OPENAI_API_CREDS - AsPlainText - Force
New - AzResourceGroupDeployment `
-Name twitter-chatgpt-resources ` - ResourceGroupName $resourceGroupName `
-TemplateFile $templateFile ` - twitterCredsJson $twitterCredsJson `
-openAIAPICredsJson $OpenAIAPICreds
Azure Function Redeployments
I did experience unexpected behavior when redeploying the Bicep template. Every time the Bicep template was redeployed, I had to redeploy my Azure Function from Visual Studio as well. There are resources online that suggest setting WEBSITE_RUN_FROM_PACKAGE to 1 when deploying the Azure Function as a package from Visual Studio.
{
name: 'WEBSITE_RUN_FROM_PACKAGE'
value: '1'
}
This did not work in my experience. I also tried running the New-AzureResourceGroupDeployment command with IncrementalMode but that didn't work either.
I found that redeploying the Bicep template without applying application settings left my prior Azure Function deployment intact.
New-AzResourceGroupDeployment `
-Name twitter-chatgpt-resources `
-ResourceGroupName $resourceGroupName `
-TemplateFile $templateFile `
-twitterCredsJson $twitterCredsJson `
-openAIAPICredsJson $OpenAIAPICreds `
-overwriteFuncSettings $true
The setting is applied in a ternary operation on appSettings. If true, then appSettings are provided. False, no appSettings are sent.
appSettings: overwriteFuncSettings ? [... {
name: 'TWITTER_CREDS'
value: '@Microsoft.KeyVault(VaultName=${twitterchatgpt_dev_kv.name};SecretName=${twitterchatgpt_dev_kv_twittercreds.name})'
} {
name: 'OPENAI_API_CREDS'
value: '@Microsoft.KeyVault(VaultName=${twitterchatgpt_dev_kv.name};SecretName=${twitterchatgpt_dev_kv_openaiapicreds.name})'
}] : null
Summary
This walkthrough is informed by my experiences configuring and deploying a Twitter webhook that works with the OpenAI GPT-3 API client following a least-privilege approach. Only the service principal of the Azure Function has read access to the OpenAI and Twitter credentials. The Bicep templates and the PowerShell script are available here. If you would like to see more articles about this project, like how to configure a Twitter webhook, let me know in the comments.