Skip to content

Add feature to automatically update App Service hostname bindings with management #13

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AzureKeyVault.sln
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
readme_source.md = readme_source.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureKeyVaultBindingTests", "AzureKeyVaultBindingTests\AzureKeyVaultBindingTests.csproj", "{B6024A50-BD43-4D30-8291-AC5D536115BD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -21,6 +23,10 @@ Global
{2CEC2ACF-E636-45DA-A0B5-3FC4D9F4EFCA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2CEC2ACF-E636-45DA-A0B5-3FC4D9F4EFCA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2CEC2ACF-E636-45DA-A0B5-3FC4D9F4EFCA}.Release|Any CPU.Build.0 = Release|Any CPU
{B6024A50-BD43-4D30-8291-AC5D536115BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B6024A50-BD43-4D30-8291-AC5D536115BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B6024A50-BD43-4D30-8291-AC5D536115BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B6024A50-BD43-4D30-8291-AC5D536115BD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
1 change: 1 addition & 0 deletions AzureKeyVault/AkvProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public class AkvProperties
public string ApplicationId { get; set; }
public string ObjectId { get; set; }
public string ClientSecret { get; set; }
public bool AutoUpdateAppServiceBindings { get; set; }
}
}
283 changes: 283 additions & 0 deletions AzureKeyVault/AzureAppServicesClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
using System.Collections.Generic;
using System.Linq;
using Azure;
using Azure.Core;
using Azure.Identity;
using Azure.ResourceManager;
using Azure.ResourceManager.AppService;
using Azure.ResourceManager.AppService.Models;
using Azure.ResourceManager.Resources;
using Azure.Security.KeyVault.Certificates;
using Keyfactor.Logging;
using Microsoft.Extensions.Logging;

namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault
{
public class AzureAppServicesClient
{
// Environment variables required to authenticate using DefaultAzureCredential:
// - AZURE_CLIENT_ID → The app ID value.
// - AZURE_TENANT_ID → The tenant ID value.
// - AZURE_CLIENT_SECRET → The password/credential generated for the app.

// The methods in this class mostly return lists because certificate resources representing the same certificate
// can exist in multiple resource groups. More specifically, App Service Certificate resources are bound to
// a specific resource group and region combination. This means that if a certificate DNS hostname list matches
// hostnames in multiple resource groups, the certificate needs to be imported and bound multiple times.

public AzureAppServicesClient(AkvProperties properties)
{
// Each AzureAppServicesClient represents a single resource group (and therefore subscription).

Log = LogHandler.GetClassLogger<AzureAppServicesClient>();
Log.LogDebug("Initializing Azure App Services client");

string subscriptionId = string.IsNullOrEmpty(properties.SubscriptionId)
? new ResourceIdentifier(properties.StorePath).SubscriptionId
: properties.SubscriptionId;

// Construct Azure Resource Management client using ClientSecretCredential based on properties inside AkvProperties;
ArmClient = new ArmClient(new ClientSecretCredential(properties.TenantId, properties.ApplicationId,
properties.ClientSecret));

// Get subscription resource defined by resource ID
Subscription = ArmClient.GetDefaultSubscription();
Log.LogDebug("Found subscription called \"{SubscriptionDisplayName}\" ({SubscriptionId})",
Subscription.Data.DisplayName, Subscription.Data.SubscriptionId);
}

public AzureAppServicesClient(string subscriptionId, string tenantId = "")
{
// Each AzureAppServicesClient represents a single resource group (and therefore subscription).
// Each AzureAppServicesClient represents a single subscription.

Log = LogHandler.GetClassLogger<AzureAppServicesClient>();
Log.LogDebug("Initializing Azure App Services client");

// Create Azure Resource Management client
Log.LogDebug("Getting Azure token using DefaultAzureCredential");
if (string.IsNullOrEmpty(tenantId))
ArmClient = new ArmClient(new DefaultAzureCredential());
else
ArmClient = new ArmClient(new DefaultAzureCredential(new DefaultAzureCredentialOptions
{ TenantId = tenantId }));

// Get subscription resource defined by resource ID
// TODO this should actually be getDefaultSubscription() but that doesn't work for some reason
Subscription = ArmClient.GetSubscriptions().Get(subscriptionId);
Log.LogDebug("Found subscription called \"{SubscriptionName}\" ({SubscriptionId})",
Subscription.Data.DisplayName, Subscription.Data.SubscriptionId);
}

private ArmClient ArmClient { get; }
private SubscriptionResource Subscription { get; }
private ILogger Log { get; }

private string FilterWildcard(string dnsName)
{
return dnsName.Contains("*") ? dnsName.Replace("*", "") : dnsName;
}

public IEnumerable<WebSiteResource> GetSiteResourceFromHostname(string hostname)
{
// Use LINQ syntax to find the site with matching hostname.
// Search across every resource group in the subscription.
return (
from resourceGroup in Subscription.GetResourceGroups().GetAll()
from site in resourceGroup.GetWebSites().GetAll()
from hostnameBinding in site.Data.HostNames
where hostnameBinding.Contains(hostname)
select site).ToList();
}

#region Removal

public void RemoveCertificate(string thumbprint)
{
foreach (AppCertificateResource certificateResource in GetCertificateResourceByThumbprint(thumbprint))
certificateResource?.Delete(WaitUntil.Completed);
}

#endregion

#region Download

public IEnumerable<AppCertificateResource> GetCertificateResourceByThumbprint(string thumbprint)
{
// Query across all resource groups in the subscription to find the certificate with the matching name
// Note that the same certificate could be deployed across multiple resource groups, so there could be
// more than 1 AppCertificateResource returned.
return (
from resourceGroup in Subscription.GetResourceGroups().GetAll()
from cert in resourceGroup.GetAppCertificates().GetAll()
// AppCertificateResource wraps thumbprint in '"' so we need to remove them before comparing
where $"{cert.Data.Thumbprint}".Replace("\"", "") == thumbprint
select cert
).ToList();
}

#endregion

#region Import

public IEnumerable<AppCertificateResource> ImportCertificateFromAzureKeyVault(
ResourceIdentifier keyVaultResourceId,
KeyVaultCertificateWithPolicy akvCertificateName)
{
return from dnsName in akvCertificateName.Policy.SubjectAlternativeNames.DnsNames
select GetSiteResourceFromHostname(dnsName)
into sites // Get the site resource for each DNS name
from site in sites
where site != null // Filter out any sites that don't match
select ImportCertificateFromAzureKeyVault(site.Id, keyVaultResourceId,
akvCertificateName.Name); // Import the certificate into the site
}

public AppCertificateResource ImportCertificateFromAzureKeyVault(ResourceIdentifier appServiceResourceId,
ResourceIdentifier keyVaultResourceId, string keyVaultSecretName)
{
// Get Azure Web Site resource using resource ID to get location and app service plan ID
WebSiteResource site = ArmClient.GetWebSiteResource(appServiceResourceId).Get();
Log.LogDebug("Got WebSiteResource for {Name}", site.Data.Name);

// Get location from Web Site resource
AzureLocation location = site.Data.Location;
ResourceIdentifier appServicePlanId = site.Data.AppServicePlanId;

Log.LogDebug("Importing certificate with name {Name} from Key Vault {VaultName}", keyVaultSecretName,
keyVaultResourceId.Name);

// Get resource group resource
string resourceGroupId =
$"/subscriptions/{appServiceResourceId.SubscriptionId}/resourceGroups/{appServiceResourceId.ResourceGroupName}";
ResourceGroupResource resourceGroup =
ArmClient.GetResourceGroupResource(new ResourceIdentifier(resourceGroupId)).Get();

return resourceGroup.GetAppCertificates().CreateOrUpdate(WaitUntil.Completed,
keyVaultSecretName,
new AppCertificateData(location)
{
KeyVaultSecretName = keyVaultSecretName,
KeyVaultId = keyVaultResourceId,
ServerFarmId = appServicePlanId
}).WaitForCompletion();
}

#endregion

#region Bindings

public IEnumerable<string> GetHostnameBindings(ResourceIdentifier appServiceResourceId)
{
// Get Web Site resource using resource ID
WebSiteResource site = ArmClient.GetWebSiteResource(appServiceResourceId).Get();
Log.LogDebug("Found {0} hostname bindings for {1}", site.Data.HostNames.Count, site.Data.Name);

// Get hostname bindings for Web Site resource
return site.Data.HostNames.ToList();
}

public SiteHostNameBindingResource UpdateCertificateBinding(ResourceIdentifier appServiceResourceId,
AppCertificateResource certificateResource, HostNameBindingSslState? sslState)
{
// Get Azure Web Site resource using resource ID to get location and app service plan ID
WebSiteResource appServiceResource = ArmClient.GetWebSiteResource(appServiceResourceId).Get();

SiteHostNameBindingCollection bindings = appServiceResource.GetSiteHostNameBindings();

// Try to add certificate to each matching hostname
foreach (string host in appServiceResource.Data.HostNames)
{
if (!certificateResource.Data.HostNames.Contains(host)) continue;

bindings.CreateOrUpdate(WaitUntil.Completed, host, new HostNameBindingData
{
SiteName = appServiceResource.Data.RepositorySiteName,
AzureResourceName = appServiceResource.Data.Name,
SslState = sslState,
Thumbprint = certificateResource.Data.Thumbprint
});
Log.LogDebug("Bound certificate with name {Name} to hostname {Hostname}", certificateResource.Data.Name,
host);
}

return IsCertificateBoundToAppService(appServiceResource, certificateResource);
}

public IEnumerable<string> UpdateCertificateBinding(AppCertificateResource certificate)
{
// Iterate through all DNS SANS attached to certificate, and try to update bindings for each.
return (
from host in certificate.Data.HostNames
select GetSiteResourceFromHostname(host)
into sites
from site in sites
where site != null
select UpdateCertificateBinding(site.Id, certificate, HostNameBindingSslState.SniEnabled).Data.Name
).ToList();
}

public IEnumerable<string> RemoveCertificateBinding(KeyVaultCertificateWithPolicy akvCertificateName)
{
string thumb = akvCertificateName.Properties.X509Thumbprint.Aggregate("", (current, b) => current + b.ToString("X2"));;

// Search for the site that the certificate is bound to across all resource groups in the subscription
// Then, remove the certificate binding from the site.

// Returns a list of thumbprints with size according to the number of binding removals.
return (
from appCert in GetCertificateResourceByThumbprint(thumb) // Get list of cert resources
from resourceGroupResource in Subscription.GetResourceGroups().GetAll()
from webSiteResource in resourceGroupResource.GetWebSites().GetAll()
from binding in webSiteResource.GetSiteHostNameBindings().GetAll()
where appCert.Data.HostNames.Any(host => binding.Data.Name.Contains(host))
select RemoveCertificateBinding(webSiteResource.Id, appCert)
).ToList();
}

public string RemoveCertificateBinding(ResourceIdentifier appServiceResourceId,
AppCertificateResource certificateResource)
{
WebSiteResource appServiceResource = ArmClient.GetWebSiteResource(appServiceResourceId).Get();
string removalBindingThumbprint = null;

foreach (SiteHostNameBindingResource binding in appServiceResource.GetSiteHostNameBindings().GetAll())
{
if ($"{binding.Data.Thumbprint}" != $"{certificateResource.Data.Thumbprint}") continue;

removalBindingThumbprint = $"{binding.Data.Thumbprint}";
binding.Update(WaitUntil.Completed, new HostNameBindingData
{
SiteName = binding.Data.SiteName,
AzureResourceName = binding.Data.Name,
SslState = HostNameBindingSslState.Disabled,
Thumbprint = null
});
Log.LogDebug("Removed certificate binding for {Name}", binding.Data.Name);
}

return removalBindingThumbprint;
}

public SiteHostNameBindingResource IsCertificateBoundToAppService(WebSiteResource appServiceResource,
AppCertificateResource cert)
{
if (cert == null) return null;

// Get bindings from Web Site resource
foreach (SiteHostNameBindingResource binding in appServiceResource.GetSiteHostNameBindings().GetAll())
if (binding != null && Equals($"{cert.Data.Thumbprint}", $"{binding.Data.Thumbprint}"))
{
Log.LogDebug("Certificate with thumbprint {Thumb} is bound to {Name}", cert.Data.Thumbprint,
appServiceResource.Data.Name);
return binding;
}

Log.LogDebug("Certificate with thumbprint {Thumb} is not bound to {Name}", cert.Data.Thumbprint,
appServiceResource.Data.Name);
return null;
}

#endregion
}
}
6 changes: 6 additions & 0 deletions AzureKeyVault/AzureClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ public virtual async Task<IEnumerable<CurrentInventoryItem>> GetCertificatesAsyn
}
return inventoryItems;
}

public virtual KeyVaultCertificateWithPolicy GetCertificate(string secretName)
{
var cert = CertClient.GetCertificate(secretName);
return cert.Value;
}

public virtual async Task<List<string>> GetVaults()
{
Expand Down
31 changes: 15 additions & 16 deletions AzureKeyVault/AzureKeyVault.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
Expand All @@ -11,32 +11,31 @@
<PackageLicenseFile></PackageLicenseFile>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<BaseOutputPath>bin</BaseOutputPath>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DefineConstants></DefineConstants>
<OutputPath>bin</OutputPath>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.5.0" />
<PackageReference Include="Azure.ResourceManager" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.ResourceManager.Resources" Version="1.0.0-beta.3" />
<PackageReference Include="Azure.Security.KeyVault.Administration" Version="4.1.0" />
<PackageReference Include="Azure.Security.KeyVault.Certificates" Version="4.3.0" />
<PackageReference Include="Keyfactor.Common" Version="2.3.6" />
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />
<PackageReference Include="Keyfactor.Orchestrators.Common" Version="3.1.2" />
<PackageReference Include="Keyfactor.Orchestrators.IOrchestratorJobExtensions" Version="0.6.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.7" />
<PackageReference Include="Microsoft.Azure.Management.Fluent" Version="1.38.0" />
<PackageReference Include="Microsoft.Azure.Management.KeyVault" Version="3.1.0" />
<PackageReference Include="Microsoft.Azure.Management.ResourceManager.Fluent" Version="1.38.0" />
<PackageReference Include="Microsoft.WindowsAzure.Common" Version="1.4.1" />
<PackageReference Include="Azure.Core" Version="1.26.0" />
<PackageReference Include="Azure.Identity" Version="1.8.0" />
<PackageReference Include="Azure.ResourceManager" Version="1.3.2" />
<PackageReference Include="Azure.ResourceManager.AppService" Version="1.0.0" />
<PackageReference Include="Azure.Security.KeyVault.Administration" Version="4.2.0" />
<PackageReference Include="Azure.Security.KeyVault.Certificates" Version="4.4.0" />
<PackageReference Include="Keyfactor.Logging" Version="1.1.1" />
<PackageReference Include="keyfactor.orchestrators.common" Version="3.1.2" />
<PackageReference Include="keyfactor.orchestrators.iorchestratorjobextensions" Version="0.7.0" />
<PackageReference Include="Microsoft.Azure.Management.KeyVault" Version="3.1.0" />
<PackageReference Include="Microsoft.Azure.Management.ResourceManager.Fluent" Version="1.38.1" />
</ItemGroup>

<ItemGroup>
<None Update="manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

Expand Down
Loading