<< Automatic Azure StorageSyncService Migration and SMB over QUIC Deployment | Azure Virtual Desktop Infrastructure Deployment AAD FSLogix Golden Image Howto >> |
In this post we detail the security measures we took in the development and automated deployment of a Graph API application we created. The console application accesses an Office 365 user's Excel file stored on OneDrive and sends "New" or "Reply" emails from the user's account (depending on the contents of tables in the Excel document). The application uses the Microsoft Graph API to access Office 365 resources without the user's credentials. Instead of a user's credentials, the application uses its own service account configured in Azure Active Directory (AAD).
The console application is deployed to Azure as an WebApp triggered WebJob. This allows us to increase the security of the application by granting access to only the WebApp running the WebJob to the application secrets (such as the application service account id and password). Thus, the application as configured, will not work outside an Azure resource with an ID that has been granted access to the secrets.
The application is deployed and maintained with an Azure DevOps CI Pipeline. Every resource needed by the application, DevOps project, and pipelines are deployed with a bash shell script that runs AZ CLI commands. The shell script is included in the GitHub repository and is named deployResourcesProject.sh
The source code for this project can be found in this public GitHub repository:
Better-Computing-Consulting/microsoft-graph-api-excel-emailer-webjob: C# Console .Net 6 application that uses the Microsoft Graph API to Send new or reply emails on behalf of Office 365 user, based on the contents of tables from an Excel file stored on the user's OneDrive directory deployed as an Azure WebJob via DevOps CI. (github.com)I have posted a YouTube video demonstrating the automated deployment and pipeline execution from beginning to end. The video also shows testing of the newly deployed WebJob to execute the application and send New or Reply emails based on the contents of a OneDrive Excel file.
The application has functions to perform different types of access to the Graph API. It has functions for retrieving/adding/deleting rows from an Excel document table, functions for retrieving emails from an Exchange Online mailbox, and functions for sending New or Reply emails from the mailbox. Every one of the functions relies on a valid authentication token from the Graph API.
For example, in the GraphHelper.cs C# file of the program you can see the function GraphHelper.GetTableRowsAsync for retrieving rows from a table:
124 125 126 127 128 129 130 131 132 133 |
public static Task<IWorkbookTableRowsCollectionPage> GetTableRowsAsync(string tableid)
{
_ = _appClient ?? throw new System.NullReferenceException("Graph has not been initialized for app-only auth");
_ = _settings ?? throw new System.NullReferenceException("Settings cannot be null");
return _appClient.Users[_settings.ADUser].Drive.Root.ItemWithPath(_settings.DocumentPath).Workbook.Tables[tableid].Rows
.Request()
.Select(r => new { r.Index, r.Values })
.GetAsync();
}
|
As you can see, the rows are retrieved by using a function of the Microsoft.Graph.GraphServiceClient class as instantiated in the variable _appClient. Thus, before we can use the function, we must initiate the GraphServiceClient.
We instantiate the GraphServiceClient in the GraphHelper.EnsureGraphForAppOnlyAuth function, which is also located in the GraphHelper.cs C# file:
13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
public static void EnsureGraphForAppOnlyAuth(Settings settings)
{
_settings = settings;
_ = _settings ?? throw new System.NullReferenceException("Settings cannot be null");
if (_clientSecretCredential == null)
{
_clientSecretCredential = new ClientSecretCredential(_settings.TenantId, _settings.ClientId, _settings.ClientSecret);
}
if (_appClient == null)
{
_appClient = new GraphServiceClient(_clientSecretCredential, new[] { "https://graph.microsoft.com/.default" });
}
}
|
To instantiate the GraphServiceClient we need first to instantiate an Azure.Identity.ClientSecretCredential object to pass to the GraphServiceClient New function. The ClientSecretCredential New function in turn needs three parameters to obtain an authenticated credential token: a TenantID (the Azure tenant id of the target Office 365 and Active Directory), a ClientID (the ID of the AAD application account that will access Graph), and a Client Secret (the password of the AAD application account).
To avoid having to write these three security sensitive parameters anywhere in the code of the application they are kept as Secrets in an Azure KeyVault. The secrets are loaded into the application configuration in the Settings.LoadSettings function. The Settings.LoadSettings is executed at the start of the application, close to the top of the implied Main function of the Program.cs C# file:
10
|
var settings = Settings.LoadSettings();
|
We declared the GraphHelper class and LoadSettings function in the GraphHelper.cs C# file. Here is the full LoadSettings function:
11 12 13 14 15 16 17 18 19 20 21 |
public static Settings LoadSettings()
{
string? keyVaultName = Environment.GetEnvironmentVariable("KEY_VAULT_NAME");
var kvUri = "https://" + keyVaultName + ".vault.azure.net";
IConfiguration config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.AddAzureKeyVault(new Uri(kvUri), new DefaultAzureCredential())
.Build();
return config.Get<Settings>();
}
}
|
The name of the KeyVault is stored as an environment variable in the Azure WebApp hosting the WebJob that executes this application. Please note that the WebApp and this application are deployed automatically. The WebApp is deployed by executing deployResourcesProject.sh script. This application is automatically deployed by triggering the execution of the BuildDeploy DevOps CI Pipeline. The deployResourcesProject.sh is also responsible for deploying the DevOps Project and the Pipeline. Lastly, we can see the deployResourcesProject.sh script sets the environment variable in the Azure WebApp hosting the WebJob at line 39.
39
|
az webapp config appsettings set -n $webAppName --settings KEY_VAULT_NAME=$keyVaultName --output none
|
Back to the application flow: the Settings.LoadSettings function loads the Secrets from the KeyVault into the configuration of the application at line 17.
17
|
.AddAzureKeyVault(new Uri(kvUri), new DefaultAzureCredential())
|
As you can see, we pass to the AddAzureKeyVault function, in addition to the URI of the KeyVault, a new Azure.Identity.DefaultAzureCredential object to authenticate against the KeyVault and get access to the secrets.
This DefaultAzureCredential object will attempt to authenticate using different credentials, one of which is the ManagedIdentityCredential. In Azure we can assign Managed Identities to resources we deploy. Managed Identities cannot be shared, so they can only be used by the resource for which they were created. Managed Identities are stored in Azure AD, and we can use these identities to grant access to other resources in Azure. For example, in this case we use the ManagedIdentity of the WebApp to authenticate against the KeyVault. You could also use the Managed Identity to authenticate to other resources, like Azure Storage, via RBAC Role assignments.
To successfully authenticate to the KeyVault from the application, first we must assign a ManagedIdentity to the WebApp. We do this when we deploy it, in line 26 of deployResourcesProject.sh.
26
|
webAppManagedId=$(az webapp create -p $appSvcName -n $webAppName --assign-identity --scope $rgId --only-show-errors --query identity.principalId)
|
Then, we must configure the target resource, in this case the KeyVault, to grant access to the Managed Identity. The deployResourcesProject.sh script sets the Access Policy of the KeVault to grant get and list access to the Secrets to the WebApp's Managed Identity in line 36:
36
|
az keyvault set-policy --name $keyVaultName --object-id $webAppManagedId --secret-permissions get list --output none
|
In addition to restricting access to the KeyVault by managed identity, we added another restriction based on source IP of the access request. Thus, to be able to access the secrets, the request must be coming from a pre-approved IP address.
To add the Network rule restriction, first the deployResourcesProject.sh script queries the WebApp for its possible public IPs at line 28.
28
|
webAppPubIPs=$(az webapp show -n $webAppName --query possibleOutboundIpAddresses)
|
Then, when the deployResourcesProject.sh script creates the KeyVault at line 33 it passes the public IP addresses of the WebApp as the value for the network-acls-ips property and sets the KeyVault's default-action to deny. Setting the default action to deny will reject any authentication requests to KeyVault unless they are originating from a pre-approved IP address.
33
|
az keyvault create -n "$keyVaultName" --network-acls-ips ${webAppPubIPs//,/ } --default-action Deny --output none
|
Note that the az webapp show command returns the IPs as a comma-separated list, but the az keyvault create command expects the IPs as space-separated list. Thus, we replace the commas with spaces in the list as we pass it to the az keyvault create command.
Thus far, we have ensured that the KeyVault only allows access to its secrets to the Managed Identity and public IP of the WebApp. The secrets that the application needs from the KeyVault are the TenantId, ClientId, and ClientSecret of the ADD account with permissions to access the Graph API and perform the required operations, ie., fetch/edit table rows from the OneDrive document and fetch and send emails as an Office 365 user.
To setup these secrets in the KeyVault the deployResourcesProject.sh script first captures the clientId (aka appId) at line 44 first when it creates the AAD Application Account:
44
|
adAppId=$(az ad app create --display-name $adAppName --is-fallback-public-client --sign-in-audience AzureADMyOrg --query appId)
|
Then the script captures the clientSecret when it resets the password for the ADD account:
47
|
adAppPw=$(az ad app credential reset --id $adAppId --query password -o tsv --only-show-errors)
|
And then the scripts add both values as Secrets to the KeyVault:
79 80 |
az keyvault secret set --vault-name $keyVaultName --name clientId --value $adAppId --output none
az keyvault secret set --vault-name $keyVaultName --name clientSecret --value $adAppPw --output none
|
Lastly, the script queries the tenantId of the user running the script, captures its value and sets it as a KeyVault secret.
82 83 84 85 |
tenantId=$(az account show --query tenantId)
# Add Tenant ID as a secret to the KeyVault
az keyvault secret set --vault-name $keyVaultName --name tenantId --value $tenantId --output none
|
Please note the application uses the Microsoft.Extensions.Configuration.Binder package to bind the settings in the configuration with properties in the Settings Class. Thus, the names of the secrets in the KeyVault, clientId, clientSecret, and tenantId must match the public properties of the Settings class for the binding to succeed.
Compare the secret names from the above az keyvault secret commands to the public properties of the Settings Class:
4 5 6 7 8 |
public class Settings
{
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? TenantId { get; set; }
|
The entire system requires two service accounts: A Service Principal to run the Azure DevOps pipelines, and an AAD Application account that connects to the Microsoft Graph API to perform the program's actions.
The system includes two Azure DevOps Pipelines: One to build and deploy the application whenever the source code is updated at the Github repository, and another to run the Webjob based on a schedule. Both pipelines use the same Service Principal account. This service account is created with only enough rights to perform its required operation, i.e., deploy and run a Webjob. The minimum access role required to do this is Website Contributor. The deployResourcesProject.sh creates the Service Principal at line 98.
98
|
spKey=$(az ad sp create-for-rbac --name $spName --role "Website Contributor" --scopes $rgId --only-show-errors --query password)
|
The az ad sp create-for-rbac command further restricts the access of the account by limiting its scope to the Resource Group that contains the WebApp. This means that the same service account cannot be used to Control WebJobs on other Resource Groups.
After the script creates the service principal, the script queries its ClientID (aka AppId) and uses it to create the AzureRM Service Endpoint that both pipelines use:
100
|
spClientId=$(az ad sp list --display-name $spName --query [].appId)
|
115 116 117 |
azRMSvcId=$(az devops service-endpoint azurerm create --azure-rm-service-principal-id $spClientId \
--azure-rm-subscription-id $subsId --azure-rm-subscription-name "$subsName" --azure-rm-tenant-id $tenantId \
--name AzureServiceConnection --project $projectId --query id)
|
Note that the name of the service endpoint, in this case AzureServiceConnection, must match the name specified in the pipelines. See below where we use the Service Endpoint in both the Deploy, azure-pipelines.yml, and Cron, cron-pipeline.yml, pipelines:
77 78 79 80 81 82 |
- task: AzureRmWebAppDeployment@4
inputs:
ConnectionType: 'AzureRM'
azureSubscription: 'AzureServiceConnection'
appType: 'webApp'
WebAppName: '$(WebAppName)'
|
19 20 21 22 23 24 |
- task: AzureCLI@2
inputs:
azureSubscription: 'AzureServiceConnection'
scriptType: 'bash'
scriptLocation: 'inlineScript'
inlineScript: 'az webapp webjob triggered run --name $(WebAppName) --resource-group $(ResGrpName) --webjob-name GraphExcelEmailerWebJob -o yamlc'
|
The other service for the system is Azure AD Application account the program uses to access the Microsoft Graph API. deployResourcesProject.sh creates this account on line 44:
44
|
adAppId=$(az ad app create --display-name $adAppName --is-fallback-public-client --sign-in-audience AzureADMyOrg --query appId)
|
We grant this account only enough rights to perform the Graph API tasks our program executes.
60
|
az ad app permission grant --id $adAppId --api 00000003-0000-0000-c000-000000000000 --scope Files.Read.All Files.ReadWrite.All Mail.ReadWrite Mail.Send User.Read.All --output none
|
Our program needs to Create and Send emails as any user, Read and Write files on all site collections, and Read all Users' Profiles. These rights allow application to be configured to access resources on behalf of different users by merely editing its configuration file, appsettings.json. In the appsettings.json you must identify the AAD account of the user on whose behalf the emails will be sent and who holds the Excel file in OneDrive. These are the full contents of appsettings.json:
1 2 3 4 | {
"documentPath": "/emails.xlsx",
"aduser": "demouser1@bcc.bz"
}
|
Once an application has been granted access to the Graph API, it can access a wide array of Azure and Office 365 resources, from OneDrive though Azure Active Directory accounts, device Management, DNS Records, Drives, Identity Governance and protection, reports, subscriptions, etc. Thus, the potential for automation of in an Azure infrastructure is great with the help of the Microsoft Graph API.
The get a sense of the number of resources that can be automated with Microsoft Graph, you just need to browse to the Graph Explorer, and click on Resources.
I hope you have found this post helpful. Please do not hesitate to contact Better Computing Consulting if you have any questions.
Thank you for reading.
IT Consultant
Better Computing Consulting
<< Automatic Azure StorageSyncService Migration and SMB over QUIC Deployment | Azure Virtual Desktop Infrastructure Deployment AAD FSLogix Golden Image Howto >> |
F: (310) 935-0341
Mon -Fri 9AM - 6PM Pacific Time