diff --git a/AzureKeyVault.sln b/AzureKeyVault.sln index 75246d0..6c0dd0b 100644 --- a/AzureKeyVault.sln +++ b/AzureKeyVault.sln @@ -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 @@ -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 diff --git a/AzureKeyVault/AkvProperties.cs b/AzureKeyVault/AkvProperties.cs index 2b034b6..d025f6d 100644 --- a/AzureKeyVault/AkvProperties.cs +++ b/AzureKeyVault/AkvProperties.cs @@ -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; } } } diff --git a/AzureKeyVault/AzureAppServicesClient.cs b/AzureKeyVault/AzureAppServicesClient.cs new file mode 100644 index 0000000..5cd0db4 --- /dev/null +++ b/AzureKeyVault/AzureAppServicesClient.cs @@ -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(); + 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(); + 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 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 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 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 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 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 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 + } +} \ No newline at end of file diff --git a/AzureKeyVault/AzureClient.cs b/AzureKeyVault/AzureClient.cs index a685c7d..15ff59e 100644 --- a/AzureKeyVault/AzureClient.cs +++ b/AzureKeyVault/AzureClient.cs @@ -167,6 +167,12 @@ public virtual async Task> GetCertificatesAsyn } return inventoryItems; } + + public virtual KeyVaultCertificateWithPolicy GetCertificate(string secretName) + { + var cert = CertClient.GetCertificate(secretName); + return cert.Value; + } public virtual async Task> GetVaults() { diff --git a/AzureKeyVault/AzureKeyVault.csproj b/AzureKeyVault/AzureKeyVault.csproj index a4d065c..4784bc5 100644 --- a/AzureKeyVault/AzureKeyVault.csproj +++ b/AzureKeyVault/AzureKeyVault.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -11,32 +11,31 @@ false false + bin + bin - - - - - - - - - - - - - - + + + + + + + + + + + - PreserveNewest + Always diff --git a/AzureKeyVault/Jobs/AzureKeyVaultJob.cs b/AzureKeyVault/Jobs/AzureKeyVaultJob.cs index cfafce0..5808566 100644 --- a/AzureKeyVault/Jobs/AzureKeyVaultJob.cs +++ b/AzureKeyVault/Jobs/AzureKeyVaultJob.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Collections.Generic; using Keyfactor.Orchestrators.Extensions; using Newtonsoft.Json; @@ -23,6 +24,7 @@ public abstract class AzureKeyVaultJob : IOrchestratorJobExtension internal protected virtual AzureClient AzClient { get; set; } internal protected virtual AkvProperties VaultProperties { get; set; } + protected AzureAppServicesClient AppServicesClient { get; set; } public void InitializeStore(dynamic config) { @@ -41,9 +43,20 @@ public void InitializeStore(dynamic config) dynamic properties = JsonConvert.DeserializeObject(config.CertificateStoreDetails.Properties.ToString()); VaultProperties.ResourceGroupName = properties.ResourceGroupName; VaultProperties.VaultName = properties.VaultName; + + VaultProperties.AutoUpdateAppServiceBindings = (bool)properties.AutoUpdateBindings; + // Make binding variable safe in case Keyfactor expects an older version of the extension + if (properties.GetType().GetProperty("AutoUpdateBindings") != null) + VaultProperties.AutoUpdateAppServiceBindings = (bool)properties.AutoUpdateBindings; } AzClient ??= new AzureClient(VaultProperties); - } + + // If the store was configured to auto-update app service bindings, create a client to do so + if (VaultProperties.AutoUpdateAppServiceBindings) + { + AppServicesClient ??= new AzureAppServicesClient(VaultProperties); + } + } } } diff --git a/AzureKeyVault/Jobs/Management.cs b/AzureKeyVault/Jobs/Management.cs index d0115a5..031bbd0 100644 --- a/AzureKeyVault/Jobs/Management.cs +++ b/AzureKeyVault/Jobs/Management.cs @@ -14,8 +14,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Azure.Core; +using Azure.Security.KeyVault.Certificates; using Keyfactor.Logging; using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; @@ -95,42 +98,61 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st { var complete = new JobResult() { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = jobHistoryId }; - if (!string.IsNullOrWhiteSpace(pfxPassword)) // This is a PFX Entry + if (string.IsNullOrWhiteSpace(pfxPassword)) // This is a PFX Entry { - if (string.IsNullOrWhiteSpace(alias)) - { - complete.FailureMessage = "You must supply an alias for the certificate."; - return complete; - } + complete.FailureMessage = "Certificate to add must be in a .PFX file format."; + return complete; + } - try - { - var cert = AzClient.ImportCertificateAsync(alias, entryContents, pfxPassword).Result; + if (string.IsNullOrWhiteSpace(alias)) + { + complete.FailureMessage = "You must supply an alias for the certificate."; + return complete; + } - // Ensure the return object has a AKV version tag, and Thumbprint - if (!string.IsNullOrEmpty(cert.Properties.Version) && - !string.IsNullOrEmpty(string.Concat(cert.Properties.X509Thumbprint.Select(i => i.ToString("X2")))) - ) - { - complete.Result = OrchestratorJobStatusJobResult.Success; - } - else + try + { + // Import certificate into Azure Key Vault + var cert = AzClient.ImportCertificateAsync(alias, entryContents, pfxPassword).Result; + + // Update app service bindings if necessary + if (VaultProperties.AutoUpdateAppServiceBindings) + { + List bindingList = ( + // Import certificate from Azure Key Vault to Azure App Service + from certificateResource in AppServicesClient.ImportCertificateFromAzureKeyVault(new ResourceIdentifier(VaultProperties.StorePath), cert) + // Bind certificate to any Azure App Service that has a hostname matching the certificate's DNS SAN + select AppServicesClient.UpdateCertificateBinding(certificateResource) into bindings + from binding in bindings + select binding + ).ToList(); + + // Print the list of bindings that were updated + if (bindingList.Count > 0) { - // uploadCollection is either not null or an exception was thrown. - complete.FailureMessage = $"Unable to add {alias} to {ExtensionName}. Check your network connection, ensure the password is correct, and that your API connection information is correct."; + logger.LogInformation($"Updated bindings for the following Azure App Services: {string.Join(", ", bindingList)}"); } } - catch (Exception ex) + + // Ensure the return object has a AKV version tag, and Thumbprint + if (!string.IsNullOrEmpty(cert.Properties.Version) && + !string.IsNullOrEmpty(string.Concat(cert.Properties.X509Thumbprint.Select(i => i.ToString("X2")))) + ) { - complete.FailureMessage = $"An error occured while adding {alias} to {ExtensionName}: " + ex.Message; - - if (ex.InnerException != null) - complete.FailureMessage += " - " + ex.InnerException.Message; + complete.Result = OrchestratorJobStatusJobResult.Success; + } + else + { + // uploadCollection is either not null or an exception was thrown. + complete.FailureMessage = $"Unable to add {alias} to {ExtensionName}. Check your network connection, ensure the password is correct, and that your API connection information is correct."; } } - else // Non-PFX + catch (Exception ex) { - complete.FailureMessage = "Certificate to add must be in a .PFX file format."; + complete.FailureMessage = $"An error occured while adding {alias} to {ExtensionName}: " + ex.Message; + + if (ex.InnerException != null) + complete.FailureMessage += " - " + ex.InnerException.Message; } return complete; @@ -152,6 +174,23 @@ protected virtual JobResult PerformRemoval(string alias, long jobHistoryId) try { + if (VaultProperties.AutoUpdateAppServiceBindings) + { + // If auto binding is enabled for the store, we need to remove the binding from the app service + // before we can delete the certificate from AKV. + + // Get the certificate object using the alias that it's stored under. + KeyVaultCertificateWithPolicy akvCertObject = AzClient.GetCertificate(alias); + + // RemoveCertificateBinding returns a list of thumbprints associated with certificate bindings + // that were removed. + foreach (string bindingRemovalThumb in AppServicesClient.RemoveCertificateBinding(akvCertObject)) + { + // Finally, foreach thumbprint that was removed, delete the certificate from Azure App Service. + AppServicesClient.RemoveCertificate(bindingRemovalThumb); + } + } + var result = AzClient.DeleteCertificateAsync(alias).Result; if (result.Value.Name == alias) diff --git a/AzureKeyVaultBindingTests/AzureKeyVaultBindingTests.csproj b/AzureKeyVaultBindingTests/AzureKeyVaultBindingTests.csproj new file mode 100644 index 0000000..aae3f2a --- /dev/null +++ b/AzureKeyVaultBindingTests/AzureKeyVaultBindingTests.csproj @@ -0,0 +1,20 @@ + + + + Exe + netcoreapp3.1 + enable + + + + + + + + + + + + + + diff --git a/AzureKeyVaultBindingTests/Program.cs b/AzureKeyVaultBindingTests/Program.cs new file mode 100644 index 0000000..aba2551 --- /dev/null +++ b/AzureKeyVaultBindingTests/Program.cs @@ -0,0 +1,166 @@ +using System; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Azure; +using Azure.Core; +using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.AppService; +using Azure.ResourceManager.Resources; +using Azure.Security.KeyVault.Certificates; +using Keyfactor.Extensions.Orchestrator.AzureKeyVault; + +namespace AzureKeyVaultBindingTests +{ + internal class Program + { + private readonly ResourceIdentifier _appServiceResourceId = + new ResourceIdentifier(Environment.GetEnvironmentVariable("APP_SERVICE_ID") ?? string.Empty); + + private readonly ResourceIdentifier _keyVaultResourceId = + new ResourceIdentifier(Environment.GetEnvironmentVariable("AKV_ID") ?? string.Empty); + + private readonly ResourceIdentifier _resourceGroupId = + new ResourceIdentifier(Environment.GetEnvironmentVariable("RESOURCE_GROUP_ID") ?? string.Empty); + + public Program() + { + Console.Write("Configuring clients\n"); + + AkvProperties vaultProperties = new AkvProperties + { + TenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID") ?? string.Empty, + ResourceGroupName = _resourceGroupId.ResourceGroupName, + VaultName = Environment.GetEnvironmentVariable("AKV_NAME") ?? string.Empty, + ApplicationId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID") ?? string.Empty, + ClientSecret = Environment.GetEnvironmentVariable("AZURE_CLIENT_SECRET") ?? string.Empty, + AutoUpdateAppServiceBindings = true, + StorePath = _keyVaultResourceId + }; + KeyVaultClient = new AzureClient(vaultProperties); + Console.Write("Created KeyVaultClient\n"); + + AppServiceClient = new AzureAppServicesClient(vaultProperties); + Console.Write("Created AppServiceClient\n"); + } + + private AzureAppServicesClient AppServiceClient { get; } + private AzureClient KeyVaultClient { get; } + + private static void Main(string[] args) + { + // Test the Azure Key Vault binding capability + + // 1. Set up the Azure Key Vault client and Azure App Service client + Program p = new Program(); + + // 2. Get a bound hostname from the app service that isn't the default hostname + string hostname = p.GetHostname(); + Console.Write("Using hostname " + hostname + "\n"); + + // 3. Enroll a certificate for the hostname with one SAN + X509Certificate2 selfSignedCert = p.GetSelfSignedCert(hostname); + + // 4. Upload the certificate to the Azure Key Vault + string certName = "AKVTest" + Guid.NewGuid().ToString().Substring(0, 6); + KeyVaultCertificateWithPolicy akvCert = p.UploadCertToAkv(selfSignedCert, certName); + + // 5. Bind the certificate to the app service + p.BindCertificateToAppService(akvCert); + + // 6. Verify the certificate is bound to the app service + p.VerifyCertificateBinding(certName); + + // 7. Unbind the certificate from the app service + p.DeleteCertificateBinding(certName); + + // 8. Delete the certificate from the Azure Key Vault + p.DeleteCertFromAkv(certName); + } + + public string GetHostname() + { + return AppServiceClient.GetHostnameBindings(_appServiceResourceId) + .First(host => !host.Contains("azurewebsites.net")); + } + + public X509Certificate2 GetSelfSignedCert(string hostname) + { + RSA rsa = RSA.Create(2048); + CertificateRequest req = new CertificateRequest($"CN={hostname}", rsa, HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + SubjectAlternativeNameBuilder subjectAlternativeNameBuilder = new SubjectAlternativeNameBuilder(); + subjectAlternativeNameBuilder.AddDnsName(hostname); + req.CertificateExtensions.Add(subjectAlternativeNameBuilder.Build()); + req.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | + X509KeyUsageFlags.DigitalSignature, false)); + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("2.5.29.32.0"), new Oid("1.3.6.1.5.5.7.3.1") }, false)); + + X509Certificate2 selfSignedCert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(5)); + Console.Write( + $"Created self-signed certificate for {hostname} with thumbprint {selfSignedCert.Thumbprint}\n"); + return selfSignedCert; + } + + public KeyVaultCertificateWithPolicy UploadCertToAkv(X509Certificate2 cert, string certName) + { + string password = Guid.NewGuid().ToString().Substring(0, 10); + // Export cert to PFX and base64 encode it + string pfxBytes = Convert.ToBase64String(cert.Export(X509ContentType.Pfx, password)); + Task createTask = + KeyVaultClient.ImportCertificateAsync(certName, pfxBytes, password); + createTask.Wait(); + KeyVaultCertificateWithPolicy akvCert = createTask.Result; + Console.Write("Uploaded certificate to Azure Key Vault\n"); + + AppServiceClient.RemoveCertificateBinding(akvCert); + return akvCert; + } + + + public void BindCertificateToAppService(KeyVaultCertificateWithPolicy cert) + { + // Import certificate from Azure Key Vault into any applicable app service. + foreach (AppCertificateResource importedCert in AppServiceClient.ImportCertificateFromAzureKeyVault( + _keyVaultResourceId, cert)) + { + AppServiceClient.UpdateCertificateBinding(importedCert); + } + } + + public void VerifyCertificateBinding(string name) + { + KeyVaultCertificateWithPolicy akvCertObject = KeyVaultClient.GetCertificate(name); + string thumbprint = + akvCertObject.Properties.X509Thumbprint.Aggregate("", (current, b) => current + b.ToString("X2")); + + foreach (WebSiteResource site in AppServiceClient.GetSiteResourceFromHostname(GetHostname())) + foreach (AppCertificateResource certResource in AppServiceClient.GetCertificateResourceByThumbprint( + thumbprint)) + { + SiteHostNameBindingResource bind = AppServiceClient.IsCertificateBoundToAppService(site, certResource); + Console.Write("Certificate is {0}bound to app service\n", bind == null ? "not " : ""); + } + } + + public void DeleteCertificateBinding(string name) + { + KeyVaultCertificateWithPolicy cert = KeyVaultClient.GetCertificate(name); + AppServiceClient.RemoveCertificateBinding(cert); + AppServiceClient.RemoveCertificate(cert.Name); + Console.Write("Deleted certificate binding\n"); + } + + public void DeleteCertFromAkv(string certName) + { + Task deleteTask = KeyVaultClient.DeleteCertificateAsync(certName); + deleteTask.Wait(); + Console.Write("Deleted certificate from Azure Key Vault\n"); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index a905269..e37882d 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ ___ ## Platform Specific Notes -The minimum version of the Universal Orchestrator Framework needed to run this version of the extension is - The Keyfactor Universal Orchestrator may be installed on either Windows or Linux based platforms. The certificate operations supported by a capability may vary based what platform the capability is installed on. The table below indicates what capabilities are supported based on which platform the encompassing Universal Orchestrator is running. | Operation | Win | Linux | |-----|-----|------| @@ -44,6 +42,7 @@ The Keyfactor Universal Orchestrator may be installed on either Windows or Linux + --- @@ -299,6 +298,8 @@ Now we can navigate to the Keyfactor platform and create the store type for Azur 1) Navigate to the _Custom Fields_ tab and add the following fields - Name: "**VaultName**", Display Name: "**Vault Name**", Required: **true** (checked) - Name: "**ResourceGroupName**", Display Name: "**Resource Group Name**", Required: **true** (checked) + - Name: "**AutoUpdateBindings**", Display Name: "**Auto Update AppService Bindings**", Type: **bool**, Default Value: **false**, Required: **false** (unchecked) + - See the [auto update bindings](#auto-app-service-binding-updates) and [associated permission section](#permissions-required-for-auto-app-service-binding-updates) for more information. ### Install the Extension on the Orchestrator @@ -437,6 +438,8 @@ The steps to do this are: - **Store Path**: This is the Azure Resource Identifier for the Keyvault. Copied from Azure, or created a new Keyvault (see below). - **VaultName**: This is the name of the new or existing Azure Keyvault. - **ResourceGroupName**: The name of the Azure Resource Group that contains the Keyvault. +- **AutoUpdateBindings**: Set to _true_ if you would like the extension to automatically update the certificate bindings when a certificate is imported into the Keyvault. + - Note: If this is set to _true_, the Azure Key Vault and Service Principal need additional policies. See the [auto update bindings](#auto-app-service-binding-updates) and [associated permission section](#permissions-required-for-auto-app-service-binding-updates) for more information. If the vault already exists in azure: The store path can be found by navigating to the existing Keyvault resource in Azure and clicking "Properties" in the left menu. @@ -453,6 +456,35 @@ If the Keyvault does not exist in Azure, and you would like to create it: ![Add Vault](/Images/add-vault.png) +### Auto App Service Binding Updates + +Azure App Service resources can be configured to use custom domains and TLS certificates to secure them. Azure's turnkey solution to manage App Service TLS certificates is to purchase a certificate through Azure App Service Certificate Orders. +When a certificate is purchased through Azure App Service Certificate Orders, the certificate is automatically added to the Key Vault associated with the App Service resource. The certificate is also automatically added to the App Service resource's hostname bindings. +If a certificate was already purchased through Azure App Service Certificate Orders, Azure Key Vault automatically rotates and otherwise manages the certificate and its usage. However, if a certificate is obtained in another way and then imported into +an Azure Key Vault, Azure will not assist in the management of the certificate, including automatic updating of App Service hostname bindings, which is not ideal. This is where the Keyfactor Azure Key Vault integration can help. For example, a certificate may be +used within an organization for multiple purposes, including for TLS on an Azure App Service. + +When the "AutoUpdateBindings" option is enabled, the Keyfactor Azure Key Vault extension will automatically find App Services with bound hostnames that match the certificate's DNS SANs and update the hostname bindings. Then, when the certificate is EOL, +Keyfactor will automatically remove the TLS bindings and remove the App Service Certificate's reference to Azure Key Vault and associated Key Vault certificate entry. Furthermore, if Keyfactor is configured to automatically renew certificates, the +workflow for removing and re-adding bindings is handled automatically. + +This feature is designed to be constrained to the permissions set for the service principal used. Specifically, the extension can only update hostname bindings for App Services inside Resource Groups that the Service Principal has access to. For example, if +App Services exist in a resource group outside of the desired scope of management, the extension will not update the bindings for those resources. + +#### Permissions Required for Auto App Service Binding Updates + +To enable this feature, create **Access Policies** to grant _read_ permission to the `Microsoft.Azure.WebSites` and `Microsoft.Azure.CertificateRegistration` resource providers. +This should be done using the same method used [to assign access to an individual key vault](#assign-permissions-for-an-individual-key-vault-via-access-policy), where the application name +is replaced by the object ID or name shown below: + +- Microsoft Azure App Service/Microsoft.Azure.WebSites + - Object ID: abfa0a7c-a6b6-4736-8310-5855508787cd (Same for all Azure subscriptions) + - Object ID: 6a02c803-dafd-4136-b4c3-5a6f318b4714 (Azure Government cloud environments) +- Microsoft.Azure.CertificateRegistration + - Object ID: ed47c2a1-bd23-4341-b39c-f4fd69138dd3 + +To access App Services resources, the service principal created for the Keyfactor Azure Key Vault extension needs to have _maintain_ permissions over any resource groups that contain App Services resources that will be managed by Keyfactor. +This can be done by adding the service principal to any desired resource group access policies (IAM). --- diff --git a/readme_source.md b/readme_source.md index d5e8b69..0e7af31 100644 --- a/readme_source.md +++ b/readme_source.md @@ -250,6 +250,8 @@ Now we can navigate to the Keyfactor platform and create the store type for Azur 1) Navigate to the _Custom Fields_ tab and add the following fields - Name: "**VaultName**", Display Name: "**Vault Name**", Required: **true** (checked) - Name: "**ResourceGroupName**", Display Name: "**Resource Group Name**", Required: **true** (checked) + - Name: "**AutoUpdateBindings**", Display Name: "**Auto Update AppService Bindings**", Type: **bool**, Default Value: **false**, Required: **false** (unchecked) + - See the [auto update bindings](#auto-app-service-binding-updates) and [associated permission section](#permissions-required-for-auto-app-service-binding-updates) for more information. ### Install the Extension on the Orchestrator @@ -388,6 +390,8 @@ The steps to do this are: - **Store Path**: This is the Azure Resource Identifier for the Keyvault. Copied from Azure, or created a new Keyvault (see below). - **VaultName**: This is the name of the new or existing Azure Keyvault. - **ResourceGroupName**: The name of the Azure Resource Group that contains the Keyvault. +- **AutoUpdateBindings**: Set to _true_ if you would like the extension to automatically update the certificate bindings when a certificate is imported into the Keyvault. + - Note: If this is set to _true_, the Azure Key Vault and Service Principal need additional policies. See the [auto update bindings](#auto-app-service-binding-updates) and [associated permission section](#permissions-required-for-auto-app-service-binding-updates) for more information. If the vault already exists in azure: The store path can be found by navigating to the existing Keyvault resource in Azure and clicking "Properties" in the left menu. @@ -404,6 +408,35 @@ If the Keyvault does not exist in Azure, and you would like to create it: ![Add Vault](/Images/add-vault.png) +### Auto App Service Binding Updates + +Azure App Service resources can be configured to use custom domains and TLS certificates to secure them. Azure's turnkey solution to manage App Service TLS certificates is to purchase a certificate through Azure App Service Certificate Orders. +When a certificate is purchased through Azure App Service Certificate Orders, the certificate is automatically added to the Key Vault associated with the App Service resource. The certificate is also automatically added to the App Service resource's hostname bindings. +If a certificate was already purchased through Azure App Service Certificate Orders, Azure Key Vault automatically rotates and otherwise manages the certificate and its usage. However, if a certificate is obtained in another way and then imported into +an Azure Key Vault, Azure will not assist in the management of the certificate, including automatic updating of App Service hostname bindings, which is not ideal. This is where the Keyfactor Azure Key Vault integration can help. For example, a certificate may be +used within an organization for multiple purposes, including for TLS on an Azure App Service. + +When the "AutoUpdateBindings" option is enabled, the Keyfactor Azure Key Vault extension will automatically find App Services with bound hostnames that match the certificate's DNS SANs and update the hostname bindings. Then, when the certificate is EOL, +Keyfactor will automatically remove the TLS bindings and remove the App Service Certificate's reference to Azure Key Vault and associated Key Vault certificate entry. Furthermore, if Keyfactor is configured to automatically renew certificates, the +workflow for removing and re-adding bindings is handled automatically. + +This feature is designed to be constrained to the permissions set for the service principal used. Specifically, the extension can only update hostname bindings for App Services inside Resource Groups that the Service Principal has access to. For example, if +App Services exist in a resource group outside of the desired scope of management, the extension will not update the bindings for those resources. + +#### Permissions Required for Auto App Service Binding Updates + +To enable this feature, create **Access Policies** to grant _read_ permission to the `Microsoft.Azure.WebSites` and `Microsoft.Azure.CertificateRegistration` resource providers. +This should be done using the same method used [to assign access to an individual key vault](#assign-permissions-for-an-individual-key-vault-via-access-policy), where the application name +is replaced by the object ID or name shown below: + +- Microsoft Azure App Service/Microsoft.Azure.WebSites + - Object ID: abfa0a7c-a6b6-4736-8310-5855508787cd (Same for all Azure subscriptions) + - Object ID: 6a02c803-dafd-4136-b4c3-5a6f318b4714 (Azure Government cloud environments) +- Microsoft.Azure.CertificateRegistration + - Object ID: ed47c2a1-bd23-4341-b39c-f4fd69138dd3 + +To access App Services resources, the service principal created for the Keyfactor Azure Key Vault extension needs to have _maintain_ permissions over any resource groups that contain App Services resources that will be managed by Keyfactor. +This can be done by adding the service principal to any desired resource group access policies (IAM). ---