From a225e5938f0502a9b290913d62e2ae4849517d3a Mon Sep 17 00:00:00 2001 From: Morgan Gangwere <470584+indrora@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:24:54 -0800 Subject: [PATCH 1/5] chore: create 3.2 branch --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cf89a4..0da8f2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +- 3.2.0 + - Fancy new features here - 3.1.9 - Added optional entry parameter to indicate that existing tags should be preserved if certificate is replaced - bug fix for government cloud host name resolution From 85e3cf0c218dc489d809b929393c0ac9e61d8a45 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:28:05 -0500 Subject: [PATCH 2/5] feat: release 3.2, Added entry parameter to indicate the private key should not be exportable from KeyVault Co-authored-by: Keyfactor --- AzureKeyVault/AzureClient.cs | 5 ++- AzureKeyVault/Constants.cs | 1 + AzureKeyVault/Jobs/Management.cs | 23 +++++------ CHANGELOG.md | 6 +-- README.md | 66 ++++++++++++++++++++++++++++++-- integration-manifest.json | 15 +++++++- 6 files changed, 95 insertions(+), 21 deletions(-) diff --git a/AzureKeyVault/AzureClient.cs b/AzureKeyVault/AzureClient.cs index 3925c99..e470341 100644 --- a/AzureKeyVault/AzureClient.cs +++ b/AzureKeyVault/AzureClient.cs @@ -199,7 +199,7 @@ public virtual async Task CreateVault() } } - public virtual async Task ImportCertificateAsync(string certName, string contents, string pfxPassword, Dictionary tags) + public virtual async Task ImportCertificateAsync(string certName, string contents, string pfxPassword, Dictionary tags, bool nonExportable) { try { @@ -221,6 +221,7 @@ public virtual async Task ImportCertificateAsync( logger.LogTrace($"calling ImportCertificateAsync on the KeyVault certificate client to import certificate {certName}"); var options = new ImportCertificateOptions(certName, p12bytes); + options.Policy = new CertificatePolicy { Exportable = !nonExportable, ContentType = CertificateContentType.Pkcs12 }; if (tags.Any()) { @@ -388,7 +389,7 @@ public virtual (List, List) GetVaults() var warning = $"Exception thrown performing discovery on tenantId {searchTenantId} and subscription ID {searchSubscription}. Exception message: {ex.Message}"; logger.LogWarning(warning); - warnings.Add(warning); + warnings.Add(warning); } return (vaultNames, warnings); diff --git a/AzureKeyVault/Constants.cs b/AzureKeyVault/Constants.cs index b245237..3ae1cf8 100644 --- a/AzureKeyVault/Constants.cs +++ b/AzureKeyVault/Constants.cs @@ -16,6 +16,7 @@ static class AzureKeyVaultConstants static class EntryParameters { public const string TAGS = "CertificateTags"; public const string PRESERVE_TAGS = "PreserveExistingTags"; + public const string NON_EXPORTABLE = "NonExportable"; } static class JobTypes diff --git a/AzureKeyVault/Jobs/Management.cs b/AzureKeyVault/Jobs/Management.cs index 6a54a7e..3884503 100644 --- a/AzureKeyVault/Jobs/Management.cs +++ b/AzureKeyVault/Jobs/Management.cs @@ -17,7 +17,6 @@ using Keyfactor.Orchestrators.Extensions.Interfaces; using System.Collections.Generic; using Newtonsoft.Json; -using System.Security.AccessControl; namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault { @@ -46,11 +45,13 @@ public JobResult ProcessJob(ManagementJobConfiguration config) string tagsJSON; bool preserveTags; + bool nonExportable; logger.LogTrace("parsing entry parameters.. "); tagsJSON = config.JobProperties[EntryParameters.TAGS] as string ?? string.Empty; preserveTags = config.JobProperties[EntryParameters.PRESERVE_TAGS] as bool? ?? false; + nonExportable = config.JobProperties[EntryParameters.NON_EXPORTABLE] as bool? ?? false; switch (config.OperationType) { @@ -61,7 +62,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) case CertStoreOperationType.Add: logger.LogDebug($"Begin Management > Add..."); - complete = PerformAddition(config.JobCertificate.Alias, config.JobCertificate.PrivateKeyPassword, config.JobCertificate.Contents, tagsJSON, config.JobHistoryId, config.Overwrite, preserveTags); + complete = PerformAddition(config.JobCertificate.Alias, config.JobCertificate.PrivateKeyPassword, config.JobCertificate.Contents, tagsJSON, config.JobHistoryId, config.Overwrite, preserveTags, nonExportable); break; case CertStoreOperationType.Remove: logger.LogDebug($"Begin Management > Remove..."); @@ -103,7 +104,7 @@ protected async Task PerformCreateVault(long jobHistoryId) #endregion #region Add - protected virtual JobResult PerformAddition(string alias, string pfxPassword, string entryContents, string tagsJSON, long jobHistoryId, bool overwrite, bool preserveTags) + protected virtual JobResult PerformAddition(string alias, string pfxPassword, string entryContents, string tagsJSON, long jobHistoryId, bool overwrite, bool preserveTags, bool nonExportable) { var complete = new JobResult() { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = jobHistoryId }; @@ -138,16 +139,16 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st if (existing != null) { logger.LogTrace($"there is an existing cert.."); - } - existingTags = existing?.Properties.Tags as Dictionary ?? new Dictionary(); + existingTags = existing?.Properties.Tags as Dictionary ?? new Dictionary(); - logger.LogTrace("existing cert tags: "); - if (!existingTags.Any()) logger.LogTrace("(none)"); + logger.LogTrace("existing cert tags: "); + if (!existingTags.Any()) logger.LogTrace("(none)"); - foreach (var tag in existingTags) - { - logger.LogTrace(tag.Key + " : " + tag.Value); + foreach (var tag in existingTags) + { + logger.LogTrace(tag.Key + " : " + tag.Value); + } } // if overwrite is unchecked, check for an existing cert first @@ -173,7 +174,7 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st } } - var cert = AzClient.ImportCertificateAsync(alias, entryContents, pfxPassword, tagDict).Result; + var cert = AzClient.ImportCertificateAsync(alias, entryContents, pfxPassword, tagDict, nonExportable).Result; // Ensure the return object has a AKV version tag, and Thumbprint if (!string.IsNullOrEmpty(cert.Properties.Version) && diff --git a/CHANGELOG.md b/CHANGELOG.md index 0da8f2e..8ca7d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ - 3.2.0 - - Fancy new features here + - Added an optional entry parameter to indicate whether the private key of the cert should be not exportable when stored in KeyVault + - Now specifying the pkcs12 format when wirting certs to Azure KeyVault. This should prevent the error when a PEM cert was added outside of Command and then we attempt to update without specifying the format (Azure assumes PEM and throws an error if not). + - 3.1.9 - Added optional entry parameter to indicate that existing tags should be preserved if certificate is replaced - - bug fix for government cloud host name resolution - 3.1.8 - Fixed bug where enrollment would fail if the CertificateTags field was not defined as an entry parameter @@ -13,7 +14,6 @@ - Added support for Azure KeyVault Certificate Metadata via Entry Parameters - Fixed issue where an error would be returned during Inventory if 0 certificates were found - Converted to BouncyCastle crypto libraries - - 3.1.6 - Preventing CertStore parameters from getting used if present but empty. diff --git a/README.md b/README.md index 92a8541..c7f7431 100644 --- a/README.md +++ b/README.md @@ -658,32 +658,90 @@ the Keyfactor Command Portal ![AKV Custom Fields Tab](docsource/images/AKV-custom-fields-store-type-dialog.png) + + ###### Tenant Id + The ID of the primary Azure Tenant where the KeyVaults are hosted + + ![AKV Custom Field - TenantId](docsource/images/AKV-custom-field-TenantId-dialog.png) + + + + ###### SKU Type + The SKU type for newly created KeyVaults (only needed if needing to create new KeyVaults in your Azure subscription via Command) + + ![AKV Custom Field - SkuType](docsource/images/AKV-custom-field-SkuType-dialog.png) + + + + ###### Vault Region + The Azure Region to put newly created KeyVaults (only needed if needing to create new KeyVaults in your Azure subscription via Command) + + ![AKV Custom Field - VaultRegion](docsource/images/AKV-custom-field-VaultRegion-dialog.png) + + + + ###### Azure Cloud + The Azure Cloud where the KeyVaults are located (only necessary if not using the standard Azure Public cloud) + + ![AKV Custom Field - AzureCloud](docsource/images/AKV-custom-field-AzureCloud-dialog.png) + + + + ###### Private KeyVault Endpoint + The private endpoint of your vault instance (if a private endpoint is configured in Azure) + + ![AKV Custom Field - PrivateEndpoint](docsource/images/AKV-custom-field-PrivateEndpoint-dialog.png) + + + + + ##### Entry Parameters Tab | Name | Display Name | Description | Type | Default Value | Entry has a private key | Adding an entry | Removing an entry | Reenrolling an entry | | ---- | ------------ | ---- | ------------- | ----------------------- | ---------------- | ----------------- | ------------------- | ----------- | | CertificateTags | Certificate Tags | If desired, tags can be applied to the KeyVault entries. Provide them as a JSON string of key-value pairs ie: '{'tag-name': 'tag-content', 'other-tag-name': 'other-tag-content'}' | string | | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | | PreserveExistingTags | Preserve Existing Tags | If true, this will perform a union of any tags provided with enrollment with the tags on the existing cert with the same alias and apply the result to the new certificate. | Bool | False | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | + | NonExportable | Non Exportable Private Key | If true, this will mark the certificate as having a non-exportable private key when importing into Azure KeyVault | Bool | False | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | The Entry Parameters tab should look like this: ![AKV Entry Parameters Tab](docsource/images/AKV-entry-parameters-store-type-dialog.png) + + ##### Certificate Tags + If desired, tags can be applied to the KeyVault entries. Provide them as a JSON string of key-value pairs ie: '{'tag-name': 'tag-content', 'other-tag-name': 'other-tag-content'}' + + ![AKV Entry Parameter - CertificateTags](docsource/images/AKV-entry-parameters-store-type-dialog-CertificateTags.png) + + + ##### Preserve Existing Tags + If true, this will perform a union of any tags provided with enrollment with the tags on the existing cert with the same alias and apply the result to the new certificate. + + ![AKV Entry Parameter - PreserveExistingTags](docsource/images/AKV-entry-parameters-store-type-dialog-PreserveExistingTags.png) + + + ##### Non Exportable Private Key + If true, this will mark the certificate as having a non-exportable private key when importing into Azure KeyVault + + ![AKV Entry Parameter - NonExportable](docsource/images/AKV-entry-parameters-store-type-dialog-NonExportable.png) + + + ## Installation 1. **Download the latest Azure Key Vault Universal Orchestrator extension from GitHub.** - Navigate to the [Azure Key Vault Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/azurekeyvault-orchestrator/releases/latest). Refer to the compatibility matrix below to determine whether the `net6.0` or `net8.0` asset should be downloaded. Then, click the corresponding asset to download the zip archive. + Navigate to the [Azure Key Vault Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/azurekeyvault-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive. | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `azurekeyvault-orchestrator` .NET version to download | | --------- | ----------- | ----------- | ----------- | | Older than `11.0.0` | | | `net6.0` | | Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | - | `11.6` _and_ newer | `net8.0` | | `net8.0` | + | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | + | `11.6` _and_ newer | `net8.0` | | `net8.0` | Unzip the archive containing extension assemblies to a known location. diff --git a/integration-manifest.json b/integration-manifest.json index 1630b9b..11ceec8 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -46,7 +46,20 @@ "OnRemove": false, "OnReenrollment": false } - } + }, + { + "Name": "NonExportable", + "DisplayName": "Non Exportable Private Key", + "Description": "If true, this will mark the certificate as having a non-exportable private key when importing into Azure KeyVault", + "Type": "Bool", + "DefaultValue": "False", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + } + } ], "JobProperties": [], "LocalStore": false, From 119503cca456a204e966d08e4fe5d1e7a2c93b40 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:42:42 -0400 Subject: [PATCH 3/5] release: 3.2.1 Added entry parameter to indicate the private key should not be exportable from KeyVault Co-authored-by: Keyfactor --------- Co-authored-by: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Co-authored-by: Keyfactor * cleaned up docs, split RBAC permissions into seperate file for brevity * Update generated docs * Updated changelog, nuget package references * Explicit update of Newtonsoft.Json.Bson from 1.0.2 (used by Microsoft.AspNet.WebApi.Client) to 1.0.3 to address vulnerability --------- Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Keyfactor --- AzureKeyVault.sln | 1 + AzureKeyVault/AzureKeyVault.csproj | 31 +- CHANGELOG.md | 5 + README.md | 714 ++++++++--------------------- docsource/content.md | 372 +-------------- rbac.md | 261 +++++++++++ 6 files changed, 503 insertions(+), 881 deletions(-) create mode 100644 rbac.md diff --git a/AzureKeyVault.sln b/AzureKeyVault.sln index 3437469..ebd5192 100644 --- a/AzureKeyVault.sln +++ b/AzureKeyVault.sln @@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution docsource\content.md = docsource\content.md create_sp_azure.md = create_sp_azure.md integration-manifest.json = integration-manifest.json + rbac.md = rbac.md EndProjectSection EndProject Global diff --git a/AzureKeyVault/AzureKeyVault.csproj b/AzureKeyVault/AzureKeyVault.csproj index d23caba..85c44bd 100644 --- a/AzureKeyVault/AzureKeyVault.csproj +++ b/AzureKeyVault/AzureKeyVault.csproj @@ -17,26 +17,27 @@ - - - - - - - - - + + + + + + + + + - - + + - - - + + + + - + diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca7d18..520816e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ + +- 3.2.1 + - Documentation updates and improvements + - Updated NuGet packages + - 3.2.0 - Added an optional entry parameter to indicate whether the private key of the cert should be not exportable when stored in KeyVault - Now specifying the pkcs12 format when wirting certs to Azure KeyVault. This should prevent the error when a PEM cert was added outside of Command and then we attempt to update without specifying the format (Azure assumes PEM and throws an error if not). diff --git a/README.md b/README.md index c7f7431..19a3dd6 100644 --- a/README.md +++ b/README.md @@ -66,83 +66,15 @@ Before installing the Azure Key Vault Universal Orchestrator extension, we recom The high level steps required to configure the Azure Keyvault Orchestrator extension are: -1) [Migrating from the Windows Orchestrator for Azure KeyVault](#migrating-from-the-windows-orchestrator-for-azure-keyvault) +1) [Configure client access; permissions and authentication](#configure-the-azure-keyvault-for-client-access) -1) [Configure the Azure Keyvault for client access](#configure-the-azure-keyvault-for-client-access) - -1) [Create the Store Type in Keyfactor](#create-the-akv-certificate-store-type) +1) [Create the Store Type in Keyfactor](#AKV-Certificate-Store-Type) 1) [Install the Extension on the Orchestrator](#installation) 1) [Create the Certificate Store](#add-a-new-or-existing-azure-keyvault-certificate-store) -_Note that the certificate store type used by this Universal Orchestrator support for Azure Keyvault is not compatible -with the certificate store type used by with Windows Orchestrator version for Azure Keyvault. -If your Keyfactor instance has used the Windows Orchestrator for Azure Keyvault, a specific migration process is -required. -See [Migrating from the Windows Orchestrator for Azure KeyVault](#migrating-from-the-windows-orchestrator-for-azure-keyvault) -section below._ - -
- -

Migrating from the Windows Orchestrator for Azure KeyVault

-If you were previously using the Azure Keyvault extension for the **Windows** Orchestrator, it is necessary to remove the Store Type definition as well as any Certificate stores that use the previous store type. -This is because the store type parameters have changed in order to facilitate the Discovery and Create functionality. - -If you have an existing AKV store type that was created for use with the Windows Orchestrator, you will need to follow -the steps in one of the below sections in order to transfer the capability to the Universal Orchestrator. - -> :warning: -> Before removing the certificate stores, view their configuration details and copy the values. -> Copying the values in the store parameters will save time when re-creating the stores. - -Follow the below steps to remove the AKV capability from **each** active Windows Orchestrator that supports it: - -##### If the Windows Orchestrator should still manage other cert store types - -_If the Windows Orchestrator will still be used to manage some store types, we will remove only the Azure Keyvault -functionality._ - -1) On the Windows Orchestrator host machine, run the Keyfactor Agent Configuration Wizard -1) Proceed through the steps to "Select Features" -1) Expand "Cert Stores" and un-check "Azure Keyvault" -1) Click "Apply Configuration" - -1) Open the Keyfactor Platform and navigate to **Orchestrators > Management** -1) Confirm that "AKV" no longer appears under "Capabilities" -1) Navigate to **Orchestrators > Management**, select the orchestrator and click "DISAPPROVE" to disapprove it and - cancel pending jobs. -1) Navigate to **Locations > Certificate Stores** -1) Select any stores with the Category "Azure Keyvault" and click "DELETE" to remove them from Keyfactor. -1) Navigate to the Administrative menu (gear icon) and then **> Certificate Store Types** -1) Select Azure Keyvault, click "DELETE" and confirm. -1) Navigate to **Orchestrators > Management**, select the orchestrator and click "APPROVE" to re-approve it for use. - -1) Repeat these steps for any other Windows Orchestrators that support the AKV store type. - -##### If the Windows Orchestrator can be retired completely - -_If the Windows Orchestrator is being completely replaced with the Universal Orchestrator, we can remove all associated -stores and jobs._ - -1) Navigate to **Orchestrators > Management** and select the Windows Orchestrator from the list. -1) With the orchestrator selected, click the "RESET" button at the top of the list -1) Make sure the orchestrator is still selected, and click "DISAPPROVE". -1) Click "OK" to confirm that you will remove all jobs and certificate stores associated to this orchestrator. -1) Navigate to the Administrative (gear icon in the top right) and then **Certificate Store Types** -1) Select "Azure Keyvault", click "DELETE" and confirm. -1) Repeat these steps for any other Windows Orchestrators that support the AKV store type (if they can also be retired). - -Note: Any Azure Keyvault certificate stores removed can be re-added once the Universal Orchestrator is configured with -the AKV capability. - -#### Migrating from version 1.x or version 2.x of the Azure Keyvault Orchestrator Extension - -It is not necessary to re-create all of the certificate stores when migrating from a previous version of this extension, -though it is important to note that Azure KeyVaults found during a Discovery job -will return with latest store path format: `{subscription id}:{resource group name}:{new vault name}`. - -
+> :warning: If you are still using the (deprecated) Windows Orchestrator, you can find instructions for migrating by searching previous versions of this README. --- @@ -150,8 +82,8 @@ will return with latest store path format: `{subscription id}:{resource group na In order for this orchestrator extension to be able to interact with your instances of Azure Keyvault, it will need to authenticate with a identity that has sufficient permissions to perform the jobs. Microsoft Azure implements both Role -Based Access Control (RBAC) and the classic Access Policy method. RBAC is the preferred method, as it allows the -assignment of granular level, inheretable access control on both the contents of the KeyVaults, as well as higher-level +Based Access Control (RBAC) and the classic Access Policy method. RBAC is the preferred method, and currently the default used by Azure. +It allows the assignment of granular level, inheretable access control on both the contents of the KeyVaults, as well as higher-level management operations. For more information and a comparison of the two access control strategies, refer to [this article](learn.microsoft.com/en-us/azure/key-vault/general/rbac-access-policy). @@ -159,277 +91,12 @@ to [this article](learn.microsoft.com/en-us/azure/key-vault/general/rbac-access- Azure KeyVaults originally utilized access policies for permissions and since then, Microsoft has begun recommending Role Based Access Control (RBAC) as the preferred method of authorization. -As of this version, new KeyVaults created via this integration are created with Access Policy authorization. This will -change to RBAC in the next release. -The access control type the KeyVault implements can be changed in the KeyVault configuration within the Azure Portal. -New KeyVaults created via Keyfactor by way of this integration will be accessible for subsequent actions regardless of -the access control type. - -##### Configure Role Based Access Control (RBAC) - -In order to illustrate the minimum permissions that the authenticating entity (service principal or managed identity) -requires, -we have created 3 seperate custom role definitions that you can use as a reference when creating an RBAC role definition -in your Azure environment. - -The reason for 3 definitions is that certain orchestrator jobs, such as Create (new KeyVault) or Discovery require more -elevated permissions at a different scope than the basic certificate operations (Inventory, Add, Remove) performed -within a specific KeyVault. +New KeyVaults created via this integration are created with the default authorization method that is configured in the Azure environment. -If you know that you will utilize all of the capabilities of this integration; the last custom role definition contains -all necessary permissions for performing all of the Jobs (Discovery, Create KeyVault, Inventory/Add/Remove -certificates). - -##### Built-in vs. custom roles - -> :warning: The custom role definitions below are designed to contain the absolute minimum permissions required. They -> are not intended to be used verbatim without consulting your organization's security team and/or Azure Administrator. -> Keyfactor does not provide consulting on internal security practices. - -It is possible to use the built-in roles provided by Microsoft for these operations. The built-in roles may contain more -permissions than necessary. -Whether to create custom role definitions or use an existing or pre-built role will depend on your organization's -securuity requirements. -For each job type performed by this orchestrator, we've included the minimally sufficient built-in role name(s) along -with our custom role definitions that limit permissions to the specific actions and scopes necessary. - -
-

Create Vault permissions

- -In order to allow for the ability to create new Azure KeyVaults from within command, here is a role that defines the -necessary permissions to do so. If you will never be creating new Azure KeyVaults from within Command, then it is -unnecessary to provide the authenticating entity with these permissions. - -> :warning: When creating a new KeyVault, we grant the creating entity the built-in "Key Vault Certificates Officer" -> role in order to be able to perform subsequent actions on the contents of the -> KeyVault. [click here](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) -> to see the list of permissions included in the Key Vault Certificates Officer built-in role. - -- built-in roles (both are required): - - ["Key Vault Contributor"](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-contributor) - - ["Key Vault Access Administrator"](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-data-access-administrator) - -- lowest level scope required - a resource group that will contain the new KeyVault. - -- condition: - -```js -"((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" -``` - -the above condition limits the ability to assign roles to a single role only (Key Vault Certificates Officer). This is -more restrictive than the condition on the built-in role -of [Key Vault Access Administrator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-data-access-administrator). - -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorVaultCreator", - "description": "This role contains all of the necessary permissions to perform Inventory, Add and Remove operations on certificates on All KeyVaults within a Resource Group. It also contains sufficient permissions to create a new KeyVault within the resource group.", - "assignableScopes": [ - "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all subscriptions under management group - ], - "permissions": [ - { - "actions": [ - "Microsoft.KeyVault/vaults/*", - "Microsoft.Authorization/*/read", - "Microsoft.KeyVault/register/action", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/vaults/accessPolicies/*", - "Microsoft.Resources/deployments/*", - "Microsoft.KeyVault/locations/*/read", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.Management/managementGroups/read", - "Microsoft.Resources/subscriptions/read", - "Microsoft.Authorization/roleAssignments/*", - "Microsoft.KeyVault/operations/read" - ], - "notActions": [], - "dataActions": [], - "notDataActions": [], - "conditionVersion": "2.0", - "condition": "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" - } - ] - } -} -``` - -
-
-

Discover Vaults Permissions

- -If you would like this integration to search across your subscriptions to discover instances of existing Azure -KeyVaults, this role definition contains the necessary permissions for this. -If you are working with a smaller number of KeyVaults and/or do not plan on utilizing a Discovery job to retrieve all -KeyVaults across your subscriptions, the permissions defined in this role are not necessary. - -- built-in - role: ["Key Vault Reader"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-reader) -- lowest level scope - a resource group -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorVaultDiscovery", - "description": "This role contains all of the necessary permissions to search for KeyVaults across a subscription", - "assignableScopes": [ - "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all resources under management group - ], - "permissions": [ - { - "actions": [ - "Microsoft.Authorization/*/read", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/locations/*/read", - "Microsoft.KeyVault/vaults/read", - "Microsoft.KeyVault/operations/read" - ], - "notActions": [], - "dataActions": [ - ], - "notDataActions": [], - } - ] - } -} -``` - -
-
-

Inventory, Add, and Remove Certificate Permissions

- -This set of permissions is the minimum required to support the basic operations of performing an Inventory and -Add/Removal of certificates. - -- built-in - role: ["Key Vault Certificates Officer"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) -- lowest level scope - an individual keyvault -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorManageCerts", - "description": "This role contains all of the necessary permissions to perform Inventory, Add and Remove operations on certificates on All KeyVaults within the scope.", - "assignableScopes": [ - "/providers/Microsoft.Management/managementGroups/{groupId1}", // allow scope for all subscriptions under management group - "/subscriptions/{subscriptionId}", // allow to scoped to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}", // allow scope to a specific vault - "/subscriptions/{subscriptionId2}/resourceGroups/{resourceGroupName2}/providers/Microsoft.KeyVault/vaults/{vaultName2}", // .. and another - ], - "permissions": [ - { - "actions": [ - "Microsoft.Authorization/*/read", - "Microsoft.Resources/deployments/*", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/locations/*/read", - "Microsoft.KeyVault/vaults/*/read", - "Microsoft.KeyVault/operations/read", - ], - "notActions": [], - "dataActions": [ - "Microsoft.KeyVault/vaults/certificates/*", - "Microsoft.KeyVault/vaults/certificatecas/*", - "Microsoft.KeyVault/vaults/keys/*", - "Microsoft.KeyVault/vaults/secrets/readMetadata/action" - ], - "notDataActions": [] - } - ], - } -} -``` - -
- -
-

Combined permissions for all operations (Create, Discovery, Inventory, Add and Remove certificates)

- -This section defines a single custom role that contains the necessary permissions to perform all operations allowed by -this integration. The minimum scope allowable is an individual resource group. If this custom role is associated with -the authenticating identity, it will be able to discover existing KeyVaults, Create new ones, and perform inventory as -well as adding and removing certificates within the KeyVault. - -- minimally sufficient built-in roles (all are required): - - ["Key Vault Certificates Officer"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) - - ["Key Vault Contributor"](learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-contributor) - - ["Key Vault Access Administrator"](learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/) -- lowest level scope - an individual resource group -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorKeyVaultOperations", - "description": "This role contains all of the necessary permissions to perform Discovery, Create, Inventory, Add and Remove operations on certificates on All KeyVaults within The scope.", - "assignableScopes": [ - "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all subscriptions under management group - ], - "permissions": [ - { - "actions": [ - "Microsoft.KeyVault/vaults/*", - "Microsoft.Authorization/*/read", - "Microsoft.KeyVault/register/action", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/vaults/accessPolicies/*", - "Microsoft.Resources/deployments/*", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.Management/managementGroups/read", - "Microsoft.Resources/subscriptions/read", - "Microsoft.Authorization/roleAssignments/*", - "Microsoft.KeyVault/operations/read" - "Microsoft.KeyVault/locations/*/read", - "Microsoft.KeyVault/vaults/*/read", - ], - "notActions": [], - "dataActions": [ - "Microsoft.KeyVault/vaults/certificates/*", - "Microsoft.KeyVault/vaults/certificatecas/*", - "Microsoft.KeyVault/vaults/keys/*", - "Microsoft.KeyVault/vaults/secrets/*" - ], - "notDataActions": [], - "conditionVersion": "2.0", - "condition": "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" - } - ] - } -} -``` - -> :warning: You still may decide to split the capabilities into seperate roles in order to apply each of them to the -> lowest level scope -> required. We have tried to provide you with an absolute minimum set of required permissions necessary to perform each -> operation. Refer to -> your organization's security policies and/or consult with your information security team in order to determine which -> role combinations would -> be most appropriate for your needs. +The access control type the KeyVault implements can be changed in the KeyVault configuration within the Azure Portal. +New KeyVaults created via Keyfactor by way of this integration will use the default that is configured in your Azure environment (as of February 2026, if not specified, RBAC). -
+> :exclamation: Additional guidance regarding the minimum permissions needed for each job and sample RBAC policy definitions with descriptions can be found [here](rbac.md). #### Endpoint Access / Firewall @@ -460,7 +127,7 @@ The Azure KeyVault orchestrator plugin supports several authentication options: Steps for setting up each option are detailed below.
-

Authentication via Service Principal

+ Authentication via Service Principal For the Orchestrator to be able to interact with the instance of Azure Keyvault, we will need to create an entity in Azure that will encapsulate the permissions we would like to grant it. In Azure, these intermediate entities are @@ -503,7 +170,7 @@ We will store these values securely in Keyfactor in subsequent steps.
-

Authentication via User Assigned Managed Identity

+ Authentication via User Assigned Managed Identity Authentication has been somewhat simplified with the introduction of Azure Managed Identities. If the orchestrator is running on an Azure Virtual Machine, Managed identities allow an Azure administrator to @@ -529,13 +196,190 @@ Id field on the certificate store definition (the Client Secret can be left blan
-

Authentication via System Assigned Managed Identity

+Authentication via System Assigned Managed Identity In order to use a _System_ assigned managed identity, there is no need to enter the server credentials. If no server credentials are provided, the extension assumes authentication is via system assigned managed identity.
+### Running a Discovery Job + +Now that we have the extension registered on the Orchestrator, we can navigate back to the Keyfactor platform and finish +the setup. If there are existing Azure Key Vaults, complete the below steps to discover and add them. If there are no +existing key vaults to integrate and you will be creating a new one via the Keyfactor Platform, you can skip to the next +section. + +1) Navigate to Orchestrators > Management in the platform. + + ![Manage Orchestrators](/Images/orch-manage.png) + +1) Find the row corresponding to the orchestrator that we just installed the extension on. + +1) If the store type has been created and the integration installed on the orchestrator, you should see the _AKV_ + capability in the list. + + ![AKV Capability](/Images/akv-capability.png) + +1) Approve the orchestrator if necessary. + +##### Create the discovery job + +1) Navigate to "Locations > Certificate Stores" + + ![Locations Cert Stores](/Images/locations-certstores.png) + +1) Click the "Discover" tab, and then the "Schedule" button. + + ![Discovery Schedule](/Images/discover-schedule.png) + +1) You should see the form for creating the Discovery job. + + ![Discovery Form](/Images/discovery-form.png) + +##### Store the Server Credentials in Keyfactor + +> :warning: +> The steps for configuring discovery are different for each authentication type. + +- For System Assigned managed identity authentication this step can be skipped. No server credentials are necessary. The + store type should have been set up without "needs server" checked, so the form field should not be present. + +- For User assigned managed identity: + - `Client Machine` should be set to the GUID of the tenant ID of the instance of Azure Keyvault. + - `User` should be set to the Client ID of the managed identity. + - `Password` should be set to the value **"managed"**. + +- For Service principal authentication: + - `Client Machine` should be set to the GUID of the tenant ID of the instance of Azure Keyvault. **Note:** If using + a multi-tenant app registration, use the tenant ID of the Azure tenant where the key vault lives. + - `User` should be set to the service principal id + - `Password` should be set to the client secret. + +The first thing we'll need to do is store the server credentials that will be used by the extension. +The combination of fields required to interact with the Azure Keyvault are: + +- Tenant (or Directory) ID +- Application ID or user managed identity ID +- Client Secret (if using Service Principal Authentication) + +If not using system managed identity authentication, the integration expects the above values to be included in the +server credentials in the following way: + +- **Client Machine**: `` (GUID) + +- **User**: `` (if service principal authentication) `` (if user managed identity + authentication is used) + +- **Password**: `` (if service principal authentication), `managed` (if user managed identity + authentication is used) + +Follow these steps to store the values: + +1) Enter the _Tenant Id_ in the **Client Machine** field. + + ![Discovery Form](/Images/discovery-form-client-machine.png) + +1) Click "Change Credentials" to open up the Server Credentials form. + + ![Change Credentials](/Images/change-credentials-form.png) + +1) Click "UPDATE SERVER USERNAME" and Enter the appropriate values based on the authentication type. + + ![Set Username](/Images/server-creds-username.png) + +1) Enter again to confirm, and click save. + +1) Click "UPDATE SERVER PASSWORD" and update with the appropriate value (`` or `managed`) following the + same steps as above. + +1) Select a time to run the discovery job. + +1) Enter commma seperated list of tenant ID's in the "Directories to search" field.' + +> :warning: +> If nothing is entered here, the default Tenant ID included with the credentials will be used. For system managed +> identities, it is necessary to include the Tenant ID(s) in this field. + +1) Leave the remaining fields blank and click "SAVE". + +##### Approve the Certificate Store + +When the Discovery job runs successfully, it will list the existing Azure Keyvaults that are acessible by our service +principal. + +In this example, our job returned these Azure Keyvaults. + +![Discovery Results](/Images/discovery-result.png) + +The store path of each vault is the `::`: + +![Discovery Results](/Images/storepath.png) + +To add one of these results to Keyfactor as a certificate store: + +1) Double-click the row that corresponds to the Azure Keyvault in the discovery results (you can also select the row and + click "SAVE"). + +1) In the dialog window, enter values for any of the optional fields you have set up for your store type. + +1) Select a container to store the certificates for this cert store (optional) + +1) Select any value for SKU Type and Vault Region. These values are not used for existing KeyVaults. + +1) Click "SAVE". + +### Add an existing Azure Keyvault certificate store + +You can also add a certificate store that corresponds to an Azure Keyvault individually without the need to run the +discovery / approval workflow. +The steps to do this are: + +1) Navigate to "Locations > Certificate Stores" + +1) Click "ADD" + + ![Approve Cert Store](/Images/cert-store-add-button.png) + +1) Enter the values corresponding to the Azure Keyvault instance. + +- **Category**: Azure Keyvault +- **Container**: _optional_ +- **Client Machine**: If applicable; Tenant Id. + + - Note: These will only have to be entered once, even if adding multiple certificate stores. + - Follow the steps [here](#store-the-server-credentials-in-keyfactor) to enter them. + +- **Store Path**: This is the Subscription ID, Resource Group name, and Vault name in the following format: + `{subscription id}:{resource group name}:{new vault name}` + +- **SKU Type**: This field is only used when creating new vaults in Azure. If present, select any value, or leave blank. +- **Vault Region**: This field is also only used when creating new vaults. If present, select any value. + +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. + +![Resource Id](/Images/resource-id.png) + +- Use these values to create the store path +- Save the certificate store +- If an inventory schedule was provided, a new inventory job should appear in _Orchestrators > Jobs_. + +### Create a new Azure Keyvault + +- Enter a value for the store path in the following format: `{subscription id}:{resource group name}:{new vault name}` +- Make sure that the "Create Certificate Store" box is checked. +- Optionally choose values for the **SKUtype**, **Vault Region**, **Azure Cloud** and **Private Endpoint** (as applicable). + - The **SKUType** and **Vault Region** fields are _only_ used when creating new KeyVaults. +- Save the certificate store +-Navigate to _Orchestrators > Jobs_; you should see the "Management" job that was generated in order to create the Keyvault. +- Once this job completes, a new Keyvault should have been created + +> :warning: The identity you are using for authentication will need to have sufficient Azure permissions to be able to +> create new Keyvaults. + +--- + ## AKV Certificate Store Type @@ -663,6 +507,7 @@ the Keyfactor Command Portal The ID of the primary Azure Tenant where the KeyVaults are hosted ![AKV Custom Field - TenantId](docsource/images/AKV-custom-field-TenantId-dialog.png) + ![AKV Custom Field - TenantId](docsource/images/AKV-custom-field-TenantId-validation-options-dialog.png) @@ -670,6 +515,7 @@ the Keyfactor Command Portal The SKU type for newly created KeyVaults (only needed if needing to create new KeyVaults in your Azure subscription via Command) ![AKV Custom Field - SkuType](docsource/images/AKV-custom-field-SkuType-dialog.png) + ![AKV Custom Field - SkuType](docsource/images/AKV-custom-field-SkuType-validation-options-dialog.png) @@ -677,6 +523,7 @@ the Keyfactor Command Portal The Azure Region to put newly created KeyVaults (only needed if needing to create new KeyVaults in your Azure subscription via Command) ![AKV Custom Field - VaultRegion](docsource/images/AKV-custom-field-VaultRegion-dialog.png) + ![AKV Custom Field - VaultRegion](docsource/images/AKV-custom-field-VaultRegion-validation-options-dialog.png) @@ -684,6 +531,7 @@ the Keyfactor Command Portal The Azure Cloud where the KeyVaults are located (only necessary if not using the standard Azure Public cloud) ![AKV Custom Field - AzureCloud](docsource/images/AKV-custom-field-AzureCloud-dialog.png) + ![AKV Custom Field - AzureCloud](docsource/images/AKV-custom-field-AzureCloud-validation-options-dialog.png) @@ -691,6 +539,7 @@ the Keyfactor Command Portal The private endpoint of your vault instance (if a private endpoint is configured in Azure) ![AKV Custom Field - PrivateEndpoint](docsource/images/AKV-custom-field-PrivateEndpoint-dialog.png) + ![AKV Custom Field - PrivateEndpoint](docsource/images/AKV-custom-field-PrivateEndpoint-validation-options-dialog.png) @@ -713,18 +562,21 @@ the Keyfactor Command Portal If desired, tags can be applied to the KeyVault entries. Provide them as a JSON string of key-value pairs ie: '{'tag-name': 'tag-content', 'other-tag-name': 'other-tag-content'}' ![AKV Entry Parameter - CertificateTags](docsource/images/AKV-entry-parameters-store-type-dialog-CertificateTags.png) + ![AKV Entry Parameter - CertificateTags](docsource/images/AKV-entry-parameters-store-type-dialog-CertificateTags-validation-options.png) ##### Preserve Existing Tags If true, this will perform a union of any tags provided with enrollment with the tags on the existing cert with the same alias and apply the result to the new certificate. ![AKV Entry Parameter - PreserveExistingTags](docsource/images/AKV-entry-parameters-store-type-dialog-PreserveExistingTags.png) + ![AKV Entry Parameter - PreserveExistingTags](docsource/images/AKV-entry-parameters-store-type-dialog-PreserveExistingTags-validation-options.png) ##### Non Exportable Private Key If true, this will mark the certificate as having a non-exportable private key when importing into Azure KeyVault ![AKV Entry Parameter - NonExportable](docsource/images/AKV-entry-parameters-store-type-dialog-NonExportable.png) + ![AKV Entry Parameter - NonExportable](docsource/images/AKV-entry-parameters-store-type-dialog-NonExportable-validation-options.png) @@ -864,178 +716,6 @@ Please refer to the **Universal Orchestrator (remote)** usage section ([PAM prov > The content in this section can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). -## Discovering Certificate Stores with the Discovery Job -Now that we have the extension registered on the Orchestrator, we can navigate back to the Keyfactor platform and finish -the setup. If there are existing Azure Key Vaults, complete the below steps to discover and add them. If there are no -existing key vaults to integrate and you will be creating a new one via the Keyfactor Platform, you can skip to the next -section. - -1) Navigate to Orchestrators > Management in the platform. - - ![Manage Orchestrators](/Images/orch-manage.png) - -1) Find the row corresponding to the orchestrator that we just installed the extension on. - -1) If the store type has been created and the integration installed on the orchestrator, you should see the _AKV_ - capability in the list. - - ![AKV Capability](/Images/akv-capability.png) - -1) Approve the orchestrator if necessary. - -##### Create the discovery job - -1) Navigate to "Locations > Certificate Stores" - - ![Locations Cert Stores](/Images/locations-certstores.png) - -1) Click the "Discover" tab, and then the "Schedule" button. - - ![Discovery Schedule](/Images/discover-schedule.png) - -1) You should see the form for creating the Discovery job. - - ![Discovery Form](/Images/discovery-form.png) - -##### Store the Server Credentials in Keyfactor - -> :warning: -> The steps for configuring discovery are different for each authentication type. - -- For System Assigned managed identity authentication this step can be skipped. No server credentials are necessary. The - store type should have been set up without "needs server" checked, so the form field should not be present. - -- For User assigned managed identity: - - `Client Machine` should be set to the GUID of the tenant ID of the instance of Azure Keyvault. - - `User` should be set to the Client ID of the managed identity. - - `Password` should be set to the value **"managed"**. - -- For Service principal authentication: - - `Client Machine` should be set to the GUID of the tenant ID of the instance of Azure Keyvault. **Note:** If using - a multi-tenant app registration, use the tenant ID of the Azure tenant where the key vault lives. - - `User` should be set to the service principal id - - `Password` should be set to the client secret. - -The first thing we'll need to do is store the server credentials that will be used by the extension. -The combination of fields required to interact with the Azure Keyvault are: - -- Tenant (or Directory) ID -- Application ID or user managed identity ID -- Client Secret (if using Service Principal Authentication) - -If not using system managed identity authentication, the integration expects the above values to be included in the -server credentials in the following way: - -- **Client Machine**: `` (GUID) - -- **User**: `` (if service principal authentication) `` (if user managed identity - authentication is used) - -- **Password**: `` (if service principal authentication), `managed` (if user managed identity - authentication is used) - -Follow these steps to store the values: - -1) Enter the _Tenant Id_ in the **Client Machine** field. - - ![Discovery Form](/Images/discovery-form-client-machine.png) - -1) Click "Change Credentials" to open up the Server Credentials form. - - ![Change Credentials](/Images/change-credentials-form.png) - -1) Click "UPDATE SERVER USERNAME" and Enter the appropriate values based on the authentication type. - - ![Set Username](/Images/server-creds-username.png) - -1) Enter again to confirm, and click save. - -1) Click "UPDATE SERVER PASSWORD" and update with the appropriate value (`` or `managed`) following the - same steps as above. - -1) Select a time to run the discovery job. - -1) Enter commma seperated list of tenant ID's in the "Directories to search" field.' - -> :warning: -> If nothing is entered here, the default Tenant ID included with the credentials will be used. For system managed -> identities, it is necessary to include the Tenant ID(s) in this field. - -1) Leave the remaining fields blank and click "SAVE". - -##### Approve the Certificate Store - -When the Discovery job runs successfully, it will list the existing Azure Keyvaults that are acessible by our service -principal. - -In this example, our job returned these Azure Keyvaults. - -![Discovery Results](/Images/discovery-result.png) - -The store path of each vault is the `::`: - -![Discovery Results](/Images/storepath.png) - -To add one of these results to Keyfactor as a certificate store: - -1) Double-click the row that corresponds to the Azure Keyvault in the discovery results (you can also select the row and - click "SAVE"). - -1) In the dialog window, enter values for any of the optional fields you have set up for your store type. - -1) Select a container to store the certificates for this cert store (optional) - -1) Select any value for SKU Type and Vault Region. These values are not used for existing KeyVaults. - -1) Click "SAVE". - -#### Add a new or existing Azure Keyvault certificate store - -You can also add a certificate store that corresponds to an Azure Keyvault individually without the need to run the -discovery / approval workflow. -The steps to do this are: - -1) Navigate to "Locations > Certificate Stores" - -1) Click "ADD" - - ![Approve Cert Store](/Images/cert-store-add-button.png) - -1) Enter the values corresponding to the Azure Keyvault instance. - -- **Category**: Azure Keyvault -- **Container**: _optional_ -- **Client Machine**: If applicable; Tenant Id. - - - Note: These will only have to be entered once, even if adding multiple certificate stores. - - Follow the steps [here](#store-the-server-credentials-in-keyfactor) to enter them. - -- **Store Path**: This is the Subscription ID, Resource Group name, and Vault name in the following format: - `{subscription id}:{resource group name}:{new vault name}` - -- **SKU Type**: This field is only used when creating new vaults in Azure. If present, select any value, or leave blank. -- **Vault Region**: This field is also only used when creating new vaults. If present, select any value. - -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. - -![Resource Id](/Images/resource-id.png) - -- Use these values to create the store path - -If the Keyvault does not exist in Azure, and you would like to create it: - -- Enter a value for the store path in the following format: `{subscription id}:{resource group name}:{new vault name}` - -- For a non-existing Keyvault that you would like to create in Azure, make sure you have the "Create Certificate Store" - box checked. - -> :warning: The identity you are using for authentication will need to have sufficient Azure permissions to be able to -> create new Keyvaults. - ---- - - diff --git a/docsource/content.md b/docsource/content.md index 0fcc7e2..d75faed 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -21,83 +21,15 @@ operations. The high level steps required to configure the Azure Keyvault Orchestrator extension are: -1) [Migrating from the Windows Orchestrator for Azure KeyVault](#migrating-from-the-windows-orchestrator-for-azure-keyvault) +1) [Configure client access; permissions and authentication](#configure-the-azure-keyvault-for-client-access) -1) [Configure the Azure Keyvault for client access](#configure-the-azure-keyvault-for-client-access) - -1) [Create the Store Type in Keyfactor](#create-the-akv-certificate-store-type) +1) [Create the Store Type in Keyfactor](#AKV-Certificate-Store-Type) 1) [Install the Extension on the Orchestrator](#installation) 1) [Create the Certificate Store](#add-a-new-or-existing-azure-keyvault-certificate-store) -_Note that the certificate store type used by this Universal Orchestrator support for Azure Keyvault is not compatible -with the certificate store type used by with Windows Orchestrator version for Azure Keyvault. -If your Keyfactor instance has used the Windows Orchestrator for Azure Keyvault, a specific migration process is -required. -See [Migrating from the Windows Orchestrator for Azure KeyVault](#migrating-from-the-windows-orchestrator-for-azure-keyvault) -section below._ - -
- -

Migrating from the Windows Orchestrator for Azure KeyVault

-If you were previously using the Azure Keyvault extension for the **Windows** Orchestrator, it is necessary to remove the Store Type definition as well as any Certificate stores that use the previous store type. -This is because the store type parameters have changed in order to facilitate the Discovery and Create functionality. - -If you have an existing AKV store type that was created for use with the Windows Orchestrator, you will need to follow -the steps in one of the below sections in order to transfer the capability to the Universal Orchestrator. - -> :warning: -> Before removing the certificate stores, view their configuration details and copy the values. -> Copying the values in the store parameters will save time when re-creating the stores. - -Follow the below steps to remove the AKV capability from **each** active Windows Orchestrator that supports it: - -##### If the Windows Orchestrator should still manage other cert store types - -_If the Windows Orchestrator will still be used to manage some store types, we will remove only the Azure Keyvault -functionality._ - -1) On the Windows Orchestrator host machine, run the Keyfactor Agent Configuration Wizard -1) Proceed through the steps to "Select Features" -1) Expand "Cert Stores" and un-check "Azure Keyvault" -1) Click "Apply Configuration" - -1) Open the Keyfactor Platform and navigate to **Orchestrators > Management** -1) Confirm that "AKV" no longer appears under "Capabilities" -1) Navigate to **Orchestrators > Management**, select the orchestrator and click "DISAPPROVE" to disapprove it and - cancel pending jobs. -1) Navigate to **Locations > Certificate Stores** -1) Select any stores with the Category "Azure Keyvault" and click "DELETE" to remove them from Keyfactor. -1) Navigate to the Administrative menu (gear icon) and then **> Certificate Store Types** -1) Select Azure Keyvault, click "DELETE" and confirm. -1) Navigate to **Orchestrators > Management**, select the orchestrator and click "APPROVE" to re-approve it for use. - -1) Repeat these steps for any other Windows Orchestrators that support the AKV store type. - -##### If the Windows Orchestrator can be retired completely - -_If the Windows Orchestrator is being completely replaced with the Universal Orchestrator, we can remove all associated -stores and jobs._ - -1) Navigate to **Orchestrators > Management** and select the Windows Orchestrator from the list. -1) With the orchestrator selected, click the "RESET" button at the top of the list -1) Make sure the orchestrator is still selected, and click "DISAPPROVE". -1) Click "OK" to confirm that you will remove all jobs and certificate stores associated to this orchestrator. -1) Navigate to the Administrative (gear icon in the top right) and then **Certificate Store Types** -1) Select "Azure Keyvault", click "DELETE" and confirm. -1) Repeat these steps for any other Windows Orchestrators that support the AKV store type (if they can also be retired). - -Note: Any Azure Keyvault certificate stores removed can be re-added once the Universal Orchestrator is configured with -the AKV capability. - -#### Migrating from version 1.x or version 2.x of the Azure Keyvault Orchestrator Extension - -It is not necessary to re-create all of the certificate stores when migrating from a previous version of this extension, -though it is important to note that Azure KeyVaults found during a Discovery job -will return with latest store path format: `{subscription id}:{resource group name}:{new vault name}`. - -
+> :warning: If you are still using the (deprecated) Windows Orchestrator, you can find instructions for migrating by searching previous versions of this README. --- @@ -105,8 +37,8 @@ will return with latest store path format: `{subscription id}:{resource group na In order for this orchestrator extension to be able to interact with your instances of Azure Keyvault, it will need to authenticate with a identity that has sufficient permissions to perform the jobs. Microsoft Azure implements both Role -Based Access Control (RBAC) and the classic Access Policy method. RBAC is the preferred method, as it allows the -assignment of granular level, inheretable access control on both the contents of the KeyVaults, as well as higher-level +Based Access Control (RBAC) and the classic Access Policy method. RBAC is the preferred method, and currently the default used by Azure. +It allows the assignment of granular level, inheretable access control on both the contents of the KeyVaults, as well as higher-level management operations. For more information and a comparison of the two access control strategies, refer to [this article](learn.microsoft.com/en-us/azure/key-vault/general/rbac-access-policy). @@ -114,277 +46,14 @@ to [this article](learn.microsoft.com/en-us/azure/key-vault/general/rbac-access- Azure KeyVaults originally utilized access policies for permissions and since then, Microsoft has begun recommending Role Based Access Control (RBAC) as the preferred method of authorization. -As of this version, new KeyVaults created via this integration are created with Access Policy authorization. This will -change to RBAC in the next release. -The access control type the KeyVault implements can be changed in the KeyVault configuration within the Azure Portal. -New KeyVaults created via Keyfactor by way of this integration will be accessible for subsequent actions regardless of -the access control type. - -##### Configure Role Based Access Control (RBAC) - -In order to illustrate the minimum permissions that the authenticating entity (service principal or managed identity) -requires, -we have created 3 seperate custom role definitions that you can use as a reference when creating an RBAC role definition -in your Azure environment. - -The reason for 3 definitions is that certain orchestrator jobs, such as Create (new KeyVault) or Discovery require more -elevated permissions at a different scope than the basic certificate operations (Inventory, Add, Remove) performed -within a specific KeyVault. - -If you know that you will utilize all of the capabilities of this integration; the last custom role definition contains -all necessary permissions for performing all of the Jobs (Discovery, Create KeyVault, Inventory/Add/Remove -certificates). - -##### Built-in vs. custom roles - -> :warning: The custom role definitions below are designed to contain the absolute minimum permissions required. They -> are not intended to be used verbatim without consulting your organization's security team and/or Azure Administrator. -> Keyfactor does not provide consulting on internal security practices. - -It is possible to use the built-in roles provided by Microsoft for these operations. The built-in roles may contain more -permissions than necessary. -Whether to create custom role definitions or use an existing or pre-built role will depend on your organization's -securuity requirements. -For each job type performed by this orchestrator, we've included the minimally sufficient built-in role name(s) along -with our custom role definitions that limit permissions to the specific actions and scopes necessary. - -
-

Create Vault permissions

- -In order to allow for the ability to create new Azure KeyVaults from within command, here is a role that defines the -necessary permissions to do so. If you will never be creating new Azure KeyVaults from within Command, then it is -unnecessary to provide the authenticating entity with these permissions. - -> :warning: When creating a new KeyVault, we grant the creating entity the built-in "Key Vault Certificates Officer" -> role in order to be able to perform subsequent actions on the contents of the -> KeyVault. [click here](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) -> to see the list of permissions included in the Key Vault Certificates Officer built-in role. - -- built-in roles (both are required): - - ["Key Vault Contributor"](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-contributor) - - ["Key Vault Access Administrator"](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-data-access-administrator) - -- lowest level scope required - a resource group that will contain the new KeyVault. - -- condition: - -```js -"((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" -``` - -the above condition limits the ability to assign roles to a single role only (Key Vault Certificates Officer). This is -more restrictive than the condition on the built-in role -of [Key Vault Access Administrator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-data-access-administrator). - -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorVaultCreator", - "description": "This role contains all of the necessary permissions to perform Inventory, Add and Remove operations on certificates on All KeyVaults within a Resource Group. It also contains sufficient permissions to create a new KeyVault within the resource group.", - "assignableScopes": [ - "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all subscriptions under management group - ], - "permissions": [ - { - "actions": [ - "Microsoft.KeyVault/vaults/*", - "Microsoft.Authorization/*/read", - "Microsoft.KeyVault/register/action", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/vaults/accessPolicies/*", - "Microsoft.Resources/deployments/*", - "Microsoft.KeyVault/locations/*/read", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.Management/managementGroups/read", - "Microsoft.Resources/subscriptions/read", - "Microsoft.Authorization/roleAssignments/*", - "Microsoft.KeyVault/operations/read" - ], - "notActions": [], - "dataActions": [], - "notDataActions": [], - "conditionVersion": "2.0", - "condition": "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" - } - ] - } -} -``` +New KeyVaults created via this integration are created with the default authorization method that is configured in the Azure environment. -
-
-

Discover Vaults Permissions

- -If you would like this integration to search across your subscriptions to discover instances of existing Azure -KeyVaults, this role definition contains the necessary permissions for this. -If you are working with a smaller number of KeyVaults and/or do not plan on utilizing a Discovery job to retrieve all -KeyVaults across your subscriptions, the permissions defined in this role are not necessary. - -- built-in - role: ["Key Vault Reader"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-reader) -- lowest level scope - a resource group -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorVaultDiscovery", - "description": "This role contains all of the necessary permissions to search for KeyVaults across a subscription", - "assignableScopes": [ - "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all resources under management group - ], - "permissions": [ - { - "actions": [ - "Microsoft.Authorization/*/read", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/locations/*/read", - "Microsoft.KeyVault/vaults/read", - "Microsoft.KeyVault/operations/read" - ], - "notActions": [], - "dataActions": [ - ], - "notDataActions": [], - } - ] - } -} -``` - -
-
-

Inventory, Add, and Remove Certificate Permissions

- -This set of permissions is the minimum required to support the basic operations of performing an Inventory and -Add/Removal of certificates. - -- built-in - role: ["Key Vault Certificates Officer"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) -- lowest level scope - an individual keyvault -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorManageCerts", - "description": "This role contains all of the necessary permissions to perform Inventory, Add and Remove operations on certificates on All KeyVaults within the scope.", - "assignableScopes": [ - "/providers/Microsoft.Management/managementGroups/{groupId1}", // allow scope for all subscriptions under management group - "/subscriptions/{subscriptionId}", // allow to scoped to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}", // allow scope to a specific vault - "/subscriptions/{subscriptionId2}/resourceGroups/{resourceGroupName2}/providers/Microsoft.KeyVault/vaults/{vaultName2}", // .. and another - ], - "permissions": [ - { - "actions": [ - "Microsoft.Authorization/*/read", - "Microsoft.Resources/deployments/*", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/locations/*/read", - "Microsoft.KeyVault/vaults/*/read", - "Microsoft.KeyVault/operations/read", - ], - "notActions": [], - "dataActions": [ - "Microsoft.KeyVault/vaults/certificates/*", - "Microsoft.KeyVault/vaults/certificatecas/*", - "Microsoft.KeyVault/vaults/keys/*", - "Microsoft.KeyVault/vaults/secrets/readMetadata/action" - ], - "notDataActions": [] - } - ], - } -} -``` +The access control type the KeyVault implements can be changed in the KeyVault configuration within the Azure Portal. +New KeyVaults created via Keyfactor by way of this integration will use the default that is configured in your Azure environment (as of February 2026, if not specified, RBAC). -
+> :exclamation: Additional guidance regarding the minimum permissions needed for each job and sample RBAC policy definitions with descriptions can be found [here](rbac.md). -
-

Combined permissions for all operations (Create, Discovery, Inventory, Add and Remove certificates)

- -This section defines a single custom role that contains the necessary permissions to perform all operations allowed by -this integration. The minimum scope allowable is an individual resource group. If this custom role is associated with -the authenticating identity, it will be able to discover existing KeyVaults, Create new ones, and perform inventory as -well as adding and removing certificates within the KeyVault. - -- minimally sufficient built-in roles (all are required): - - ["Key Vault Certificates Officer"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) - - ["Key Vault Contributor"](learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-contributor) - - ["Key Vault Access Administrator"](learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/) -- lowest level scope - an individual resource group -- custom role definition: - -```js -{ - "properties": { - "roleName": "KeyfactorKeyVaultOperations", - "description": "This role contains all of the necessary permissions to perform Discovery, Create, Inventory, Add and Remove operations on certificates on All KeyVaults within The scope.", - "assignableScopes": [ - "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription - "/subscriptions/{subscriptionId2}", // and another.. etc. - "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group - "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. - "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all subscriptions under management group - ], - "permissions": [ - { - "actions": [ - "Microsoft.KeyVault/vaults/*", - "Microsoft.Authorization/*/read", - "Microsoft.KeyVault/register/action", - "Microsoft.KeyVault/checkNameAvailability/read", - "Microsoft.KeyVault/vaults/accessPolicies/*", - "Microsoft.Resources/deployments/*", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.Management/managementGroups/read", - "Microsoft.Resources/subscriptions/read", - "Microsoft.Authorization/roleAssignments/*", - "Microsoft.KeyVault/operations/read" - "Microsoft.KeyVault/locations/*/read", - "Microsoft.KeyVault/vaults/*/read", - ], - "notActions": [], - "dataActions": [ - "Microsoft.KeyVault/vaults/certificates/*", - "Microsoft.KeyVault/vaults/certificatecas/*", - "Microsoft.KeyVault/vaults/keys/*", - "Microsoft.KeyVault/vaults/secrets/*" - ], - "notDataActions": [], - "conditionVersion": "2.0", - "condition": "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" - } - ] - } -} -``` - -> :warning: You still may decide to split the capabilities into seperate roles in order to apply each of them to the -> lowest level scope -> required. We have tried to provide you with an absolute minimum set of required permissions necessary to perform each -> operation. Refer to -> your organization's security policies and/or consult with your information security team in order to determine which -> role combinations would -> be most appropriate for your needs. -
#### Endpoint Access / Firewall @@ -415,7 +84,7 @@ The Azure KeyVault orchestrator plugin supports several authentication options: Steps for setting up each option are detailed below.
-

Authentication via Service Principal

+ Authentication via Service Principal For the Orchestrator to be able to interact with the instance of Azure Keyvault, we will need to create an entity in Azure that will encapsulate the permissions we would like to grant it. In Azure, these intermediate entities are @@ -458,7 +127,7 @@ We will store these values securely in Keyfactor in subsequent steps.
-

Authentication via User Assigned Managed Identity

+ Authentication via User Assigned Managed Identity Authentication has been somewhat simplified with the introduction of Azure Managed Identities. If the orchestrator is running on an Azure Virtual Machine, Managed identities allow an Azure administrator to @@ -484,7 +153,7 @@ Id field on the certificate store definition (the Client Secret can be left blan
-

Authentication via System Assigned Managed Identity

+Authentication via System Assigned Managed Identity In order to use a _System_ assigned managed identity, there is no need to enter the server credentials. If no server credentials are provided, the extension assumes authentication is via system assigned managed identity. @@ -492,7 +161,7 @@ credentials are provided, the extension assumes authentication is via system ass
-## Discovery +### Running a Discovery Job Now that we have the extension registered on the Orchestrator, we can navigate back to the Keyfactor platform and finish the setup. If there are existing Azure Key Vaults, complete the below steps to discover and add them. If there are no @@ -618,7 +287,7 @@ To add one of these results to Keyfactor as a certificate store: 1) Click "SAVE". -#### Add a new or existing Azure Keyvault certificate store +### Add an existing Azure Keyvault certificate store You can also add a certificate store that corresponds to an Azure Keyvault individually without the need to run the discovery / approval workflow. @@ -651,13 +320,18 @@ Azure and clicking "Properties" in the left menu. ![Resource Id](/Images/resource-id.png) - Use these values to create the store path +- Save the certificate store +- If an inventory schedule was provided, a new inventory job should appear in _Orchestrators > Jobs_. -If the Keyvault does not exist in Azure, and you would like to create it: +### Create a new Azure Keyvault - Enter a value for the store path in the following format: `{subscription id}:{resource group name}:{new vault name}` - -- For a non-existing Keyvault that you would like to create in Azure, make sure you have the "Create Certificate Store" - box checked. +- Make sure that the "Create Certificate Store" box is checked. +- Optionally choose values for the **SKUtype**, **Vault Region**, **Azure Cloud** and **Private Endpoint** (as applicable). + - The **SKUType** and **Vault Region** fields are _only_ used when creating new KeyVaults. +- Save the certificate store +-Navigate to _Orchestrators > Jobs_; you should see the "Management" job that was generated in order to create the Keyvault. +- Once this job completes, a new Keyvault should have been created > :warning: The identity you are using for authentication will need to have sufficient Azure permissions to be able to > create new Keyvaults. diff --git a/rbac.md b/rbac.md new file mode 100644 index 0000000..9b58cd4 --- /dev/null +++ b/rbac.md @@ -0,0 +1,261 @@ + +### Configure Role Based Access Control (RBAC) + +In order to illustrate the minimum permissions that the authenticating entity (service principal or managed identity) +requires we have created 3 seperate custom role definitions that you can use as a reference when creating an RBAC role definition +in your Azure environment. + +The reason for 3 definitions is that certain orchestrator jobs, such as Create (new KeyVault) or Discovery require more +elevated permissions at a different scope than the basic certificate operations (Inventory, Add, Remove) performed +within a specific KeyVault. + +If you know that you will utilize all of the capabilities of this integration; the last custom role definition contains +all necessary permissions for performing all of the Jobs (Discovery, Create KeyVault, Inventory/Add/Remove +certificates). + +#### Built-in vs. custom roles + +> :warning: The custom role definitions below are designed to contain the absolute minimum permissions required. They +> are not intended to be used verbatim without consulting your organization's security team and/or Azure Administrator. +> Keyfactor does not provide consulting on internal security practices. + +It is possible to use the built-in roles provided by Microsoft for these operations. The built-in roles may contain more +permissions than necessary. +Whether to create custom role definitions or use an existing or pre-built role will depend on your organization's +security requirements. +For each job type performed by this orchestrator, we've included the minimally sufficient built-in role name(s) along +with our custom role definitions that limit permissions to the specific actions and scopes necessary. + +
+ Create Vault permissions + +In order to allow for the ability to create new Azure KeyVaults from within command, here is a role that defines the +necessary permissions to do so. If you will never be creating new Azure KeyVaults from within Command, then it is +unnecessary to provide the authenticating entity with these permissions. + +- built-in roles (both are required): + - ["Key Vault Contributor"](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-contributor) + - ["Key Vault Access Administrator"](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-data-access-administrator) + +- lowest level scope required - a resource group that will contain the new KeyVault. + +- condition: + +```js +"((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" +``` + +the above condition limits the ability to assign roles to a single role only (Key Vault Certificates Officer). This is +more restrictive than the condition on the built-in role +of [Key Vault Access Administrator](https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-data-access-administrator). + +- custom role definition: + +```js +{ + "properties": { + "roleName": "KeyfactorVaultCreator", + "description": "This role contains all of the necessary permissions to perform Inventory, Add and Remove operations on certificates on All KeyVaults within a Resource Group. It also contains sufficient permissions to create a new KeyVault within the resource group.", + "assignableScopes": [ + "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription + "/subscriptions/{subscriptionId2}", // and another.. etc. + "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group + "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. + "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all subscriptions under management group + ], + "permissions": [ + { + "actions": [ + "Microsoft.KeyVault/vaults/*", + "Microsoft.Authorization/*/read", + "Microsoft.KeyVault/register/action", + "Microsoft.KeyVault/checkNameAvailability/read", + "Microsoft.KeyVault/vaults/accessPolicies/*", + "Microsoft.Resources/deployments/*", + "Microsoft.KeyVault/locations/*/read", + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Management/managementGroups/read", + "Microsoft.Resources/subscriptions/read", + "Microsoft.Authorization/roleAssignments/*", + "Microsoft.KeyVault/operations/read" + ], + "notActions": [], + "dataActions": [], + "notDataActions": [], + "conditionVersion": "2.0", + "condition": "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" + } + ] + } +} +``` + +
+ +
+ Discover Vaults Permissions + +If you would like this integration to search across your subscriptions to discover instances of existing Azure +KeyVaults, this role definition contains the necessary permissions for this. +If you are working with a smaller number of KeyVaults and/or do not plan on utilizing a Discovery job to retrieve all +KeyVaults across your subscriptions, the permissions defined in this role are not necessary. + +- built-in + role: ["Key Vault Reader"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-reader) +- lowest level scope - a resource group +- custom role definition: + +```js +{ + "properties": { + "roleName": "KeyfactorVaultDiscovery", + "description": "This role contains all of the necessary permissions to search for KeyVaults across a subscription", + "assignableScopes": [ + "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription + "/subscriptions/{subscriptionId2}", // and another.. etc. + "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group + "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. + "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all resources under management group + ], + "permissions": [ + { + "actions": [ + "Microsoft.Authorization/*/read", + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.KeyVault/checkNameAvailability/read", + "Microsoft.KeyVault/locations/*/read", + "Microsoft.KeyVault/vaults/read", + "Microsoft.KeyVault/operations/read" + ], + "notActions": [], + "dataActions": [ + ], + "notDataActions": [], + } + ] + } +} +``` + +
+ +
+ Inventory, Add, and Remove Certificate Permissions + +This set of permissions is the minimum required to support the basic operations of performing an Inventory and +Add/Removal of certificates. + +- built-in + role: ["Key Vault Certificates Officer"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) +- lowest level scope - an individual keyvault +- custom role definition: + +```js +{ + "properties": { + "roleName": "KeyfactorManageCerts", + "description": "This role contains all of the necessary permissions to perform Inventory, Add and Remove operations on certificates on All KeyVaults within the scope.", + "assignableScopes": [ + "/providers/Microsoft.Management/managementGroups/{groupId1}", // allow scope for all subscriptions under management group + "/subscriptions/{subscriptionId}", // allow to scoped to a specific subscription + "/subscriptions/{subscriptionId2}", // and another.. etc. + "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group + "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.KeyVault/vaults/{vaultName}", // allow scope to a specific vault + "/subscriptions/{subscriptionId2}/resourceGroups/{resourceGroupName2}/providers/Microsoft.KeyVault/vaults/{vaultName2}", // .. and another + ], + "permissions": [ + { + "actions": [ + "Microsoft.Authorization/*/read", + "Microsoft.Resources/deployments/*", + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.KeyVault/checkNameAvailability/read", + "Microsoft.KeyVault/locations/*/read", + "Microsoft.KeyVault/vaults/*/read", + "Microsoft.KeyVault/operations/read", + ], + "notActions": [], + "dataActions": [ + "Microsoft.KeyVault/vaults/certificates/*", + "Microsoft.KeyVault/vaults/certificatecas/*", + "Microsoft.KeyVault/vaults/keys/*", + "Microsoft.KeyVault/vaults/secrets/readMetadata/action" + ], + "notDataActions": [] + } + ], + } +} +``` + +
+ +
+ Combined permissions for all operations (Create, Discovery, Inventory, Add and Remove certificates) + +This section defines a single custom role that contains the necessary permissions to perform all operations allowed by +this integration. The minimum scope allowable is an individual resource group. If this custom role is associated with +the authenticating identity, it will be able to discover existing KeyVaults, Create new ones, and perform inventory as +well as adding and removing certificates within the KeyVault. + +- minimally sufficient built-in roles (all are required): + - ["Key Vault Certificates Officer"](github.com/MicrosoftDocs/azure-docs/blob/main/articles/role-based-access-control/built-in-roles/security.md#key-vault-certificates-officer) + - ["Key Vault Contributor"](learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/security#key-vault-contributor) + - ["Key Vault Access Administrator"](learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/) +- lowest level scope - an individual resource group +- custom role definition: + +```js +{ + "properties": { + "roleName": "KeyfactorKeyVaultOperations", + "description": "This role contains all of the necessary permissions to perform Discovery, Create, Inventory, Add and Remove operations on certificates on All KeyVaults within The scope.", + "assignableScopes": [ + "/subscriptions/{subscriptionId1}", // allow to be applied to a specific subscription + "/subscriptions/{subscriptionId2}", // and another.. etc. + "/subscriptions/{subscriptionId}/resourcegroups/{resourceGroupName}", // allow to be scoped to a specific resource group + "/subscriptions/{subscriptionId2}/resourcegroups/{resourceGroupName2}", // and another.. + "/providers/Microsoft.Management/managementGroups/{groupId1}" // allow to be applied for all subscriptions under management group + ], + "permissions": [ + { + "actions": [ + "Microsoft.KeyVault/vaults/*", + "Microsoft.Authorization/*/read", + "Microsoft.KeyVault/register/action", + "Microsoft.KeyVault/checkNameAvailability/read", + "Microsoft.KeyVault/vaults/accessPolicies/*", + "Microsoft.Resources/deployments/*", + "Microsoft.Resources/subscriptions/resourceGroups/read", + "Microsoft.Management/managementGroups/read", + "Microsoft.Resources/subscriptions/read", + "Microsoft.Authorization/roleAssignments/*", + "Microsoft.KeyVault/operations/read" + "Microsoft.KeyVault/locations/*/read", + "Microsoft.KeyVault/vaults/*/read", + ], + "notActions": [], + "dataActions": [ + "Microsoft.KeyVault/vaults/certificates/*", + "Microsoft.KeyVault/vaults/certificatecas/*", + "Microsoft.KeyVault/vaults/keys/*", + "Microsoft.KeyVault/vaults/secrets/*" + ], + "notDataActions": [], + "conditionVersion": "2.0", + "condition": "((!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985})) AND ((!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals{a4417e6f-fecd-4de8-b567-7b0420556985}))" + } + ] + } +} +``` +
+ +> :warning: You still may decide to split the capabilities into seperate roles in order to apply each of them to the +> lowest level scope +> required. We have tried to provide you with an absolute minimum set of required permissions necessary to perform each +> operation. Refer to +> your organization's security policies and/or consult with your information security team in order to determine which +> role combinations would +> be most appropriate for your needs. \ No newline at end of file From 5589d336297abdda47f237d98944f36f4e6c6d53 Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:54:53 -0400 Subject: [PATCH 4/5] Returning entry parameters, documentation updates (#77) * Merge 3.2.0 to main (#74) * chore: create 3.2 branch * feat: release 3.2, Added entry parameter to indicate the private key should not be exportable from KeyVault Co-authored-by: Keyfactor --------- Co-authored-by: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Co-authored-by: Keyfactor * cleaned up docs, split RBAC permissions into seperate file for brevity * Update generated docs * Updated changelog, nuget package references * Explicit update of Newtonsoft.Json.Bson from 1.0.2 (used by Microsoft.AspNet.WebApi.Client) to 1.0.3 to address vulnerability * now returning the serialized certificate tags, as well as the exportable flag. Updated README image * Update generated docs --------- Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Keyfactor --- AzureKeyVault/AzureClient.cs | 22 ++- AzureKeyVault/Jobs/Management.cs | 2 +- CHANGELOG.md | 5 +- ...AKV-entry-parameters-store-type-dialog.png | Bin 24374 -> 20816 bytes .../bash/curl_create_store_types.sh | 185 ++++++++++++++++++ .../bash/kfutil_create_store_types.sh | 28 +++ .../powershell/kfutil_create_store_types.ps1 | 29 +++ .../restmethod_create_store_types.ps1 | 179 +++++++++++++++++ 8 files changed, 445 insertions(+), 5 deletions(-) create mode 100755 scripts/store_types/bash/curl_create_store_types.sh create mode 100755 scripts/store_types/bash/kfutil_create_store_types.sh create mode 100644 scripts/store_types/powershell/kfutil_create_store_types.ps1 create mode 100644 scripts/store_types/powershell/restmethod_create_store_types.ps1 diff --git a/AzureKeyVault/AzureClient.cs b/AzureKeyVault/AzureClient.cs index e470341..1647001 100644 --- a/AzureKeyVault/AzureClient.cs +++ b/AzureKeyVault/AzureClient.cs @@ -22,6 +22,8 @@ using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; using Microsoft.Extensions.Logging; +using Microsoft.VisualBasic; +using Newtonsoft.Json; namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault { @@ -38,8 +40,6 @@ private Uri AzureCloudEndpoint case "china": logger.LogTrace(AzureAuthorityHosts.AzureChina.ToString()); return AzureAuthorityHosts.AzureChina; - //case "germany": - // return AzureAuthorityHosts.AzureGermany; // germany is no longer a valid azure authority host as of 2021 case "government": logger.LogTrace(AzureAuthorityHosts.AzureGovernment.ToString()); return AzureAuthorityHosts.AzureGovernment; @@ -304,7 +304,23 @@ public virtual async Task> GetCertificatesAsyn { var cert = await CertClient.GetCertificateAsync(certificate.Name); logger.LogTrace($"got certificate details"); + logger.LogTrace($"cert properties: {JsonConvert.SerializeObject(cert.Value?.Properties)}"); + var itemEntryParams = new Dictionary(); + if (cert.Value?.Properties?.Tags != null && cert.Value.Properties.Tags.Count > 0) { // set tags entry parameter to value + itemEntryParams.Add(EntryParameters.TAGS, JsonConvert.SerializeObject(cert.Value.Properties.Tags)); + } + + if (cert.Value.Policy != null) // set nonexportable entry parameter to value + { + var exportable = cert.Value.Policy?.Exportable; + itemEntryParams.Add(EntryParameters.NON_EXPORTABLE, !exportable); + } + + itemEntryParams.Add(EntryParameters.PRESERVE_TAGS, null); // we can never know this; it's only evaluated on enrollment; set to null + + logger.LogTrace($"evaluated entry parameters to be returned: {JsonConvert.SerializeObject(itemEntryParams)}"); + inventoryItems.Add(new CurrentInventoryItem() { Alias = cert.Value.Name, @@ -312,7 +328,7 @@ public virtual async Task> GetCertificatesAsyn ItemStatus = OrchestratorInventoryItemStatus.Unknown, UseChainLevel = true, Certificates = new List() { Convert.ToBase64String(cert.Value.Cer) }, - Parameters = cert.Value.Properties.Tags as Dictionary + Parameters = itemEntryParams }); } catch (Exception ex) diff --git a/AzureKeyVault/Jobs/Management.cs b/AzureKeyVault/Jobs/Management.cs index 3884503..ed0d055 100644 --- a/AzureKeyVault/Jobs/Management.cs +++ b/AzureKeyVault/Jobs/Management.cs @@ -50,7 +50,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) logger.LogTrace("parsing entry parameters.. "); tagsJSON = config.JobProperties[EntryParameters.TAGS] as string ?? string.Empty; - preserveTags = config.JobProperties[EntryParameters.PRESERVE_TAGS] as bool? ?? false; + preserveTags = config.JobProperties[EntryParameters.PRESERVE_TAGS] as bool? ?? true; nonExportable = config.JobProperties[EntryParameters.NON_EXPORTABLE] as bool? ?? false; switch (config.OperationType) diff --git a/CHANGELOG.md b/CHANGELOG.md index 520816e..58cab0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ - +- 3.2.2 + - Updated screenshots in README + - Returning entry parameters along with inventory + - 3.2.1 - Documentation updates and improvements - Updated NuGet packages diff --git a/docsource/images/AKV-entry-parameters-store-type-dialog.png b/docsource/images/AKV-entry-parameters-store-type-dialog.png index 25d2c068d5ea9c1e7b2111f22166c449fa4dc867..4051ad693554f9cb5b633d1b4cd10db41dacb4ff 100644 GIT binary patch literal 20816 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 ADF6Tf literal 24374 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 /dev/null; then + echo "kfutil could not be found. Please install kfutil" + echo "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +fi + +if [ -z "$KEYFACTOR_HOSTNAME" ]; then + echo "KEYFACTOR_HOSTNAME not set — launching kfutil login" + kfutil login +fi + +kfutil store-types create --name "AKV" + +echo "Done. All store types created." diff --git a/scripts/store_types/powershell/kfutil_create_store_types.ps1 b/scripts/store_types/powershell/kfutil_create_store_types.ps1 new file mode 100644 index 0000000..51a3153 --- /dev/null +++ b/scripts/store_types/powershell/kfutil_create_store_types.ps1 @@ -0,0 +1,29 @@ +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +# Uncomment if kfutil is not in your PATH +# Set-Alias -Name kfutil -Value 'C:\Program Files\Keyfactor\kfutil\kfutil.exe' + +if ($null -eq (Get-Command "kfutil" -ErrorAction SilentlyContinue)) { + Write-Host "kfutil could not be found. Please install kfutil" + Write-Host "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +} + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Host "KEYFACTOR_HOSTNAME not set — launching kfutil login" + & kfutil login +} + +& kfutil store-types create --name "AKV" + +Write-Host "Done. All store types created." diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 new file mode 100644 index 0000000..2eeb0b3 --- /dev/null +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -0,0 +1,179 @@ +# Creates all 1 store types via the Keyfactor Command REST API +# using PowerShell Invoke-RestMethod. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Error "KEYFACTOR_HOSTNAME is required" + exit 1 +} + +$uri = "https://$($env:KEYFACTOR_HOSTNAME)/keyfactorapi/certificatestoretypes" +$headers = @{ + 'Content-Type' = "application/json" + 'x-keyfactor-requested-with' = "APIClient" +} + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if ($env:KEYFACTOR_AUTH_ACCESS_TOKEN) { + $headers['Authorization'] = "Bearer $($env:KEYFACTOR_AUTH_ACCESS_TOKEN)" +} elseif ($env:KEYFACTOR_AUTH_CLIENT_ID -and $env:KEYFACTOR_AUTH_CLIENT_SECRET -and $env:KEYFACTOR_AUTH_TOKEN_URL) { + Write-Host "Fetching OAuth token..." + $tokenBody = @{ + grant_type = 'client_credentials' + client_id = $env:KEYFACTOR_AUTH_CLIENT_ID + client_secret = $env:KEYFACTOR_AUTH_CLIENT_SECRET + } + $tokenResp = Invoke-RestMethod -Method Post -Uri $env:KEYFACTOR_AUTH_TOKEN_URL -Body $tokenBody + $headers['Authorization'] = "Bearer $($tokenResp.access_token)" +} elseif ($env:KEYFACTOR_USERNAME -and $env:KEYFACTOR_PASSWORD -and $env:KEYFACTOR_DOMAIN) { + $cred = [System.Convert]::ToBase64String( + [System.Text.Encoding]::ASCII.GetBytes( + "$($env:KEYFACTOR_USERNAME)@$($env:KEYFACTOR_DOMAIN):$($env:KEYFACTOR_PASSWORD)")) + $headers['Authorization'] = "Basic $cred" +} else { + Write-Error ("Authentication required. Set one of:`n" + + " KEYFACTOR_AUTH_ACCESS_TOKEN`n" + + " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL`n" + + " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN") + exit 1 +} + +function New-StoreType { + param([string]$Name, [string]$Body) + Write-Host "Creating $Name store type..." + try { + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $Body -ContentType "application/json" | Out-Null + Write-Host " OK" + } catch { + Write-Warning " FAILED: $($_.Exception.Message)" + } +} + +# --------------------------------------------------------------------------- +# AKV — The GUID of the tenant ID of the Azure Keyvault instance; for example, '12345678-1234-1234-1234-123456789abc'. +# --------------------------------------------------------------------------- +New-StoreType "AKV" @' +{ + "BlueprintAllowed": false, + "Capability": "AKV", + "CustomAliasAllowed": "Optional", + "EntryParameters": [ + { + "Name": "CertificateTags", + "DisplayName": "Certificate Tags", + "Description": "If desired, tags can be applied to the KeyVault entries. Provide them as a JSON string of key-value pairs ie: '{'tag-name': 'tag-content', 'other-tag-name': 'other-tag-content'}'", + "Type": "string", + "DefaultValue": "", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + } + }, + { + "Name": "PreserveExistingTags", + "DisplayName": "Preserve Existing Tags", + "Description": "If true, this will perform a union of any tags provided with enrollment with the tags on the existing cert with the same alias and apply the result to the new certificate.", + "Type": "Bool", + "DefaultValue": "False", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + } + }, + { + "Name": "NonExportable", + "DisplayName": "Non Exportable Private Key", + "Description": "If true, this will mark the certificate as having a non-exportable private key when importing into Azure KeyVault", + "Type": "Bool", + "DefaultValue": "False", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + } + } + ], + "JobProperties": [], + "LocalStore": false, + "Name": "Azure Keyvault", + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "PowerShell": false, + "PrivateKeyAllowed": "Optional", + "Properties": [ + { + "Name": "TenantId", + "DisplayName": "Tenant Id", + "Type": "String", + "DependsOn": "", + "Required": false + }, + { + "Name": "SkuType", + "DisplayName": "SKU Type", + "Type": "MultipleChoice", + "DependsOn": "", + "DefaultValue": "standard,premium", + "Required": false + }, + { + "Name": "VaultRegion", + "DisplayName": "Vault Region", + "Type": "MultipleChoice", + "DependsOn": "", + "DefaultValue": "eastus,eastus2,westus2,westus3,westus", + "Required": false + }, + { + "Name": "AzureCloud", + "DisplayName": "Azure Cloud", + "Type": "MultipleChoice", + "DependsOn": "", + "DefaultValue": "public,china,government", + "Required": false + }, + { + "Name": "PrivateEndpoint", + "DisplayName": "Private KeyVault Endpoint", + "Type": "String", + "DependsOn": "", + "Required": false + } + ], + "ServerRequired": true, + "ShortName": "AKV", + "StorePathDescription": "A string formatted as '{subscription id}:{resource group name}:{vault name}'; for example, '12345678-1234-1234-1234-123456789abc:myResourceGroup:myVault'.", + "StorePathType": "", + "StorePathValue": "", + "SupportedOperations": { + "Add": true, + "Create": true, + "Discovery": true, + "Enrollment": false, + "Remove": true + } +} +'@ + + +Write-Host "Completed." From 0c7f3bd7b296beb1264f7a47c38ea258c65cf57d Mon Sep 17 00:00:00 2001 From: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Date: Wed, 20 May 2026 20:04:13 -0400 Subject: [PATCH 5/5] Fix for key size 4096 error, check for empty vault name on create (#79) * Merge 3.2.0 to main (#74) * chore: create 3.2 branch * feat: release 3.2, Added entry parameter to indicate the private key should not be exportable from KeyVault Co-authored-by: Keyfactor --------- Co-authored-by: Joe VanWanzeele <76071503+joevanwanzeeleKF@users.noreply.github.com> Co-authored-by: Keyfactor * cleaned up docs, split RBAC permissions into seperate file for brevity * Update generated docs * Updated changelog, nuget package references * Explicit update of Newtonsoft.Json.Bson from 1.0.2 (used by Microsoft.AspNet.WebApi.Client) to 1.0.3 to address vulnerability * now returning the serialized certificate tags, as well as the exportable flag. Updated README image * added check for vault name parameter coming through as empty string. async safety. naming cleanup. * Update generated docs * replaced count() with count * Added helper method to determine key sized and curve in order to pass the value to AKV (the fix) * cleanup * Added unit tests * update changelog --------- Co-authored-by: Morgan Gangwere <470584+indrora@users.noreply.github.com> Co-authored-by: Keyfactor --- .../AzureKeyVault.Tests.csproj | 43 +++ AzureKeyVault.Tests/CertificateFixtures.cs | 154 +++++++++ .../DiscoveryAndInventoryTests.cs | 245 ++++++++++++++ AzureKeyVault.Tests/HelpersTests.cs | 162 +++++++++ AzureKeyVault.Tests/ManagementTests.cs | 316 ++++++++++++++++++ AzureKeyVault.sln | 6 + AzureKeyVault/AkvProperties.cs | 4 +- AzureKeyVault/AzureClient.cs | 139 ++++---- AzureKeyVault/Helpers.cs | 22 +- AzureKeyVault/Jobs/AzureKeyVaultJob.cs | 47 +-- AzureKeyVault/Jobs/Discovery.cs | 22 +- AzureKeyVault/Jobs/Inventory.cs | 12 +- AzureKeyVault/Jobs/Management.cs | 40 +-- CHANGELOG.md | 4 + 14 files changed, 1086 insertions(+), 130 deletions(-) create mode 100644 AzureKeyVault.Tests/AzureKeyVault.Tests.csproj create mode 100644 AzureKeyVault.Tests/CertificateFixtures.cs create mode 100644 AzureKeyVault.Tests/DiscoveryAndInventoryTests.cs create mode 100644 AzureKeyVault.Tests/HelpersTests.cs create mode 100644 AzureKeyVault.Tests/ManagementTests.cs diff --git a/AzureKeyVault.Tests/AzureKeyVault.Tests.csproj b/AzureKeyVault.Tests/AzureKeyVault.Tests.csproj new file mode 100644 index 0000000..9cff98f --- /dev/null +++ b/AzureKeyVault.Tests/AzureKeyVault.Tests.csproj @@ -0,0 +1,43 @@ + + + + net8.0 + Keyfactor.Extensions.Orchestrator.AzureKeyVault.Tests + Keyfactor.Extensions.Orchestrators.AKV.Tests + disable + enable + false + latest + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + diff --git a/AzureKeyVault.Tests/CertificateFixtures.cs b/AzureKeyVault.Tests/CertificateFixtures.cs new file mode 100644 index 0000000..09cb666 --- /dev/null +++ b/AzureKeyVault.Tests/CertificateFixtures.cs @@ -0,0 +1,154 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault.Tests +{ + ///

+ /// Pre-generated PFX fixtures for unit tests. All are self-signed with CN=test, + /// password "test", and a 10-year validity. + /// + public static class CertificateFixtures + { + public const string PfxPassword = "test"; + + /// RSA 2048-bit key, password "test". + public const string Rsa2048Base64 = + "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"; + + /// RSA 4096-bit key, password "test". + public const string Rsa4096Base64 = + "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" + + "VrUECLbuFmx3IYjnAgIIAA=="; + + /// EC P-256 key, password "test". + public const string EcP256Base64 = + "MIID9QIBAzCCA6sGCSqGSIb3DQEHAaCCA5wEggOYMIIDlDCCAioGCSqGSIb3DQEHBqCCAhswggIXAgEA" + + "MIICEAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDv8yCHkzvdUE3Yf5Pr" + + "mggHAgJOIDAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQ5sV8M1KI9munRJOOtaHYLoCCAaDCymkD" + + "U+6i5kdjJh2ImDPukY7PF7ZV704AZsf4XHL0p7V14VraNU84sM5FTfjIIL98GRynAgG3CluFYI1/Wx4I" + + "+apT9daRTSsm7G/5/KlGZUBDhEw1AYLw9F6/hZSAjS8BrOnCQoqsaMf9AqgT0Z1jObYvc7qPFG1/WqqH" + + "24OO1SeGwZWsFdb/fWU6wbzo6wwWEPSvfa83pBnV/esCFW4EX+/TL6YSs1f0QMfPX4flqBK2uIvkPq2e" + + "/MjBz4C7ohBUW8kUseeoghVfuqfweOU+51zDFBOzLyPrb6b07GKRfyUyEKpWniulF/y3oCTL9LiYb1tT" + + "yQ0c+nZlDL1P13Y12XPPuaZsOZ/b9eKG7UqZKx7oPJ4ATnVuu5/Q83+82ZCoIrZaeD/hZ7Ezp2gLcFM3" + + "u0tqP4ZNLZQBUchTZCILLrEDAIYiyaxYXedPkL3OLm8wGSNUu6sycjKDiTMzAKotyyX6CMhCJmkN+HbN" + + "dZiUrhNm45syJ4Lhd042GKlKhQBIOkd1Rdq6ma3lxHtSc79eiYcdP7+9PEbEWAfj25f61zCCAWIGCSqG" + + "SIb3DQEHAaCCAVMEggFPMIIBSzCCAUcGCyqGSIb3DQEMCgECoIH3MIH0MF8GCSqGSIb3DQEFDTBSMDEG" + + "CSqGSIb3DQEFDDAkBBBt1HypQHJDjdHiqCKR5B4rAgJOIDAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQB" + + "KgQQll2ZmfA8+/pYMUA4CGR1MgSBkLyuPMAIjyOCemT8XA7TrTtZoPhNIgErPweT7PbubSMisSBy6lhY" + + "Nq06OiZcKCPWJb1Yro1vtSBVeBjsQwj8nSv29nPPU8GwIeVX915Rmy99lGFVG9JTJfFFkiGDEgIYB9va" + + "tFeAiRMLi1EVd1Kkg4xmMpEhCvxyhKHdWvieU6Qt8Svq6oj3tgFc77efqX8gNzE+MBcGCSqGSIb3DQEJ" + + "FDEKHggAdABlAHMAdDAjBgkqhkiG9w0BCRUxFgQU8ICl9s/YnYxF58wsmg02r6ISuYowQTAxMA0GCWCG" + + "SAFlAwQCAQUABCDTORWFSDlGh/5xzv2nxZJDzEpBnblB11NFRPYd1jaB3wQIS1iv32GDo4MCAggA"; + } +} diff --git a/AzureKeyVault.Tests/DiscoveryAndInventoryTests.cs b/AzureKeyVault.Tests/DiscoveryAndInventoryTests.cs new file mode 100644 index 0000000..0c977be --- /dev/null +++ b/AzureKeyVault.Tests/DiscoveryAndInventoryTests.cs @@ -0,0 +1,245 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault.Tests +{ + /// + /// Bypasses InitializeStore (which requires a fully-populated dynamic config) + /// since we pre-set AzClient directly in tests. + /// + public class TestableDiscovery : Discovery + { + public TestableDiscovery(IPAMSecretResolver resolver) : base(resolver) { } + public override void InitializeStore(dynamic config) { /* no-op — AzClient already set */ } + } + + public class TestableInventory : Inventory + { + public TestableInventory(IPAMSecretResolver resolver) : base(resolver) { } + public override void InitializeStore(dynamic config) { /* no-op — AzClient already set */ } + } + + public class DiscoveryTests + { + private const long JobHistoryId = 99; + + // ── Success ─────────────────────────────────────────────────────────── + + [Fact] + public void Discovery_VaultsFound_NoWarnings_ReturnsSuccess() + { + var vaults = new List { "sub1:rg1:vault1", "sub1:rg1:vault2" }; + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()).Returns((vaults, new List())); + + List submitted = null; + var result = job.ProcessJob(BuildConfig(), v => { submitted = v?.ToList(); return true; }); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + submitted.Should().BeEquivalentTo(vaults); + } + + [Fact] + public void Discovery_NoVaults_NoWarnings_ReturnsSuccess() + { + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()).Returns((new List(), new List())); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + } + + // ── Warning ─────────────────────────────────────────────────────────── + + [Fact] + public void Discovery_SomeVaults_SomeWarnings_ReturnsWarning() + { + var vaults = new List { "sub1:rg1:vault1" }; + var warnings = new List { "Could not access tenant xyz" }; + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()).Returns((vaults, warnings)); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Warning); + result.FailureMessage.Should().Contain("xyz"); + } + + // ── Failure ─────────────────────────────────────────────────────────── + + [Fact] + public void Discovery_NoVaults_WithWarnings_ReturnsFail() + { + var warnings = new List { "auth failed for tenant abc" }; + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()).Returns((new List(), warnings)); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("abc"); + } + + [Fact] + public void Discovery_GetVaultsThrows_ReturnsFail() + { + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()).Throws(new Exception("network timeout")); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("network timeout"); + } + + // ── Truncation ──────────────────────────────────────────────────────── + + [Fact] + public void Discovery_LongFailureMessage_IsTruncated() + { + var longWarning = new string('x', 4500); + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()) + .Returns((new List(), new List { longWarning })); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Length.Should().BeLessThanOrEqualTo(4000); + result.FailureMessage.Should().Contain("truncated"); + } + + [Fact] + public void Discovery_SuccessResult_IsNeverTruncated() + { + // Regression: the old code ran the truncation check unconditionally, + // which could mangle a success result. Verify it no longer does. + var vaults = new List { "sub1:rg1:vault1" }; + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetVaults()).Returns((vaults, new List())); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + result.FailureMessage.Should().NotContain("truncated"); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static Discovery BuildJob(out Mock mockClient) + { + mockClient = new Mock(); + var resolverMock = new Mock(); + resolverMock.Setup(r => r.Resolve(It.IsAny())).Returns(s => s); + + return new TestableDiscovery(resolverMock.Object) + { + AzClient = mockClient.Object, + VaultProperties = new AkvProperties + { + TenantId = "test-tenant-id", + TenantIdsForDiscovery = new List { "test-tenant-id" } + }, + Logger = LogHandler.GetClassLogger() + }; + } + + private static DiscoveryJobConfiguration BuildConfig() => + new DiscoveryJobConfiguration + { + JobHistoryId = JobHistoryId, + ClientMachine = "test-tenant-id", + JobProperties = new Dictionary { { "dirs", "test-tenant-id" } } + }; + } + + public class InventoryTests + { + private const long JobHistoryId = 77; + + // ── Success ─────────────────────────────────────────────────────────── + + [Fact] + public void Inventory_CertsReturned_CallsCallbackAndSucceeds() + { + var inventoryItems = new List + { + new CurrentInventoryItem { Alias = "cert1", PrivateKeyEntry = true }, + new CurrentInventoryItem { Alias = "cert2", PrivateKeyEntry = true } + }; + + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetCertificatesAsync()).ReturnsAsync(inventoryItems); + + List submitted = null; + var result = job.ProcessJob(BuildConfig(), items => { submitted = items?.ToList(); return true; }); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + submitted.Should().HaveCount(2); + submitted.Should().Contain(i => i.Alias == "cert1"); + submitted.Should().Contain(i => i.Alias == "cert2"); + } + + [Fact] + public void Inventory_EmptyVault_CallsCallbackWithEmptyList() + { + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetCertificatesAsync()) + .ReturnsAsync(new List()); + + List submitted = null; + var result = job.ProcessJob(BuildConfig(), items => { submitted = items?.ToList(); return true; }); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + submitted.Should().BeEmpty(); + } + + // ── Failure ─────────────────────────────────────────────────────────── + + [Fact] + public void Inventory_GetCertificatesThrows_ReturnsFail() + { + var job = BuildJob(out var mockClient); + mockClient.Setup(c => c.GetCertificatesAsync()) + .ThrowsAsync(new Exception("vault access denied")); + + var result = job.ProcessJob(BuildConfig(), _ => true); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("vault access denied"); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static Inventory BuildJob(out Mock mockClient) + { + mockClient = new Mock(); + var resolverMock = new Mock(); + resolverMock.Setup(r => r.Resolve(It.IsAny())).Returns(s => s); + + return new TestableInventory(resolverMock.Object) + { + AzClient = mockClient.Object, + VaultProperties = new AkvProperties { VaultName = "test-vault" }, + Logger = LogHandler.GetClassLogger() + }; + } + + private static InventoryJobConfiguration BuildConfig() => + new InventoryJobConfiguration { JobHistoryId = JobHistoryId }; + } +} diff --git a/AzureKeyVault.Tests/HelpersTests.cs b/AzureKeyVault.Tests/HelpersTests.cs new file mode 100644 index 0000000..787c7e5 --- /dev/null +++ b/AzureKeyVault.Tests/HelpersTests.cs @@ -0,0 +1,162 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using FluentAssertions; +using Org.BouncyCastle.Pkcs; +using Xunit; + +namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault.Tests +{ + public class HelpersTests + { + // ── ConvertPfxToPasswordlessPkcs12 ──────────────────────────────────── + + [Fact] + public void ConvertPfx_Rsa2048_ReturnsCorrectKeyTypeAndSize() + { + var result = Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.Rsa2048Base64, CertificateFixtures.PfxPassword); + + result.KeyType.Should().Be("RSA"); + result.KeySize.Should().Be(2048); + } + + [Fact] + public void ConvertPfx_Rsa4096_ReturnsCorrectKeyTypeAndSize() + { + var result = Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.Rsa4096Base64, CertificateFixtures.PfxPassword); + + result.KeyType.Should().Be("RSA"); + result.KeySize.Should().Be(4096); + } + + [Fact] + public void ConvertPfx_EcP256_ReturnsCorrectKeyTypeAndNullSize() + { + var result = Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.EcP256Base64, CertificateFixtures.PfxPassword); + + result.KeyType.Should().Be("EC"); + result.KeySize.Should().BeNull(); + } + + [Fact] + public void ConvertPfx_Rsa2048_OutputIsValidPasswordlessPkcs12() + { + var result = Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.Rsa2048Base64, CertificateFixtures.PfxPassword); + + result.CertBytes.Should().NotBeNullOrEmpty(); + + var store = new Pkcs12StoreBuilder().Build(); + var action = () => store.Load( + new System.IO.MemoryStream(result.CertBytes), null); + + action.Should().NotThrow("output should be a valid passwordless PKCS#12"); + } + + [Fact] + public void ConvertPfx_Rsa4096_OutputIsValidPasswordlessPkcs12() + { + var result = Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.Rsa4096Base64, CertificateFixtures.PfxPassword); + + result.CertBytes.Should().NotBeNullOrEmpty(); + + var store = new Pkcs12StoreBuilder().Build(); + var action = () => store.Load( + new System.IO.MemoryStream(result.CertBytes), null); + + action.Should().NotThrow(); + } + + [Fact] + public void ConvertPfx_OutputContainsPrivateKey() + { + var result = Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.Rsa2048Base64, CertificateFixtures.PfxPassword); + + var store = new Pkcs12StoreBuilder().Build(); + store.Load(new System.IO.MemoryStream(result.CertBytes), null); + + bool hasKeyEntry = false; + foreach (string alias in store.Aliases) + { + if (store.IsKeyEntry(alias)) + { + hasKeyEntry = true; + break; + } + } + + hasKeyEntry.Should().BeTrue("the private key should be preserved in the output"); + } + + [Fact] + public void ConvertPfx_WrongPassword_Throws() + { + var action = () => Helpers.ConvertPfxToPasswordlessPkcs12( + CertificateFixtures.Rsa2048Base64, "wrong-password"); + + action.Should().Throw("an incorrect password should fail to decrypt the PFX"); + } + + [Fact] + public void ConvertPfx_InvalidBase64_Throws() + { + var action = () => Helpers.ConvertPfxToPasswordlessPkcs12( + "not-valid-base64!!!", CertificateFixtures.PfxPassword); + + action.Should().Throw(); + } + + // ── IsValidJson ─────────────────────────────────────────────────────── + + [Fact] + public void IsValidJson_ValidObject_ReturnsTrue() + { + "{\"key\": \"value\"}".IsValidJson().Should().BeTrue(); + } + + [Fact] + public void IsValidJson_ValidArray_ReturnsTrue() + { + "[\"a\", \"b\", \"c\"]".IsValidJson().Should().BeTrue(); + } + + [Fact] + public void IsValidJson_EmptyObject_ReturnsTrue() + { + "{}".IsValidJson().Should().BeTrue(); + } + + [Fact] + public void IsValidJson_InvalidJson_ReturnsFalse() + { + "this is not json".IsValidJson().Should().BeFalse(); + } + + [Fact] + public void IsValidJson_MalformedJson_ReturnsFalse() + { + "{\"key\": }".IsValidJson().Should().BeFalse(); + } + + [Fact] + public void IsValidJson_EmptyString_ReturnsFalse() + { + "".IsValidJson().Should().BeFalse(); + } + + [Fact] + public void IsValidJson_TagsStyleJson_ReturnsTrue() + { + // Mirrors the actual tags format used in Management jobs + "{\"env\": \"prod\", \"owner\": \"team-platform\"}".IsValidJson().Should().BeTrue(); + } + } +} diff --git a/AzureKeyVault.Tests/ManagementTests.cs b/AzureKeyVault.Tests/ManagementTests.cs new file mode 100644 index 0000000..c3871c3 --- /dev/null +++ b/AzureKeyVault.Tests/ManagementTests.cs @@ -0,0 +1,316 @@ +// Copyright 2025 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + +using System; +using System.Collections.Generic; +using System.Reflection; +using Azure.Security.KeyVault.Certificates; +using FluentAssertions; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Moq; +using Xunit; + +namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault.Tests +{ + /// + /// Thin subclass that promotes the protected PerformAddition/PerformRemoval + /// methods to public so the test project can call them directly. + /// + public class TestableManagement : Management + { + public TestableManagement(IPAMSecretResolver resolver) : base(resolver) { } + + public JobResult CallPerformAddition( + string alias, string pfxPassword, string entryContents, + string tagsJSON, long jobHistoryId, bool overwrite, + bool preserveTags, bool nonExportable) + => PerformAddition(alias, pfxPassword, entryContents, + tagsJSON, jobHistoryId, overwrite, preserveTags, nonExportable); + + public JobResult CallPerformRemoval(string alias, string tagsJSON, long jobHistoryId) + => PerformRemoval(alias, tagsJSON, jobHistoryId); + } + + public class ManagementTests + { + private const string Alias = "my-cert"; + private const string EmptyTags = ""; + private const long JobHistoryId = 42; + + // ── Add: success cases ──────────────────────────────────────────────── + + [Fact] + public void Add_Rsa2048_NewCert_Succeeds() + { + var job = BuildJob(out var mockClient); + SetupImportSuccess(mockClient, Alias); + + var result = job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + } + + [Fact] + public void Add_Rsa4096_NewCert_Succeeds() + { + var job = BuildJob(out var mockClient); + SetupImportSuccess(mockClient, Alias); + + var result = job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa4096Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + } + + [Fact] + public void Add_EcCert_NewCert_Succeeds() + { + var job = BuildJob(out var mockClient); + SetupImportSuccess(mockClient, Alias); + + var result = job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.EcP256Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + } + + [Fact] + public void Add_WithTags_PassesTagsToImport() + { + var job = BuildJob(out var mockClient); + SetupImportSuccess(mockClient, Alias); + + var tagsJson = "{\"env\": \"prod\", \"owner\": \"platform\"}"; + job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + tagsJson, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + mockClient.Verify(c => c.ImportCertificateAsync( + Alias, + It.IsAny(), + CertificateFixtures.PfxPassword, + It.Is>(d => + d.ContainsKey("env") && d["env"] == "prod" && + d.ContainsKey("owner") && d["owner"] == "platform"), + false), Times.Once); + } + + // ── Add: failure / warning cases ────────────────────────────────────── + + [Fact] + public void Add_EmptyAlias_ReturnsFail() + { + var job = BuildJob(out _); + + var result = job.CallPerformAddition( + alias: "", CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("alias"); + } + + [Fact] + public void Add_NullAlias_ReturnsFail() + { + var job = BuildJob(out _); + + var result = job.CallPerformAddition( + alias: null, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("alias"); + } + + [Fact] + public void Add_OverwriteFalse_CertExists_ReturnsWarning() + { + var job = BuildJob(out var mockClient); + mockClient + .Setup(c => c.GetCertificate(Alias)) + .ReturnsAsync(MakeFakeCertificate(Alias)); + + var result = job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + EmptyTags, JobHistoryId, overwrite: false, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Warning); + result.FailureMessage.Should().Contain(Alias); + result.FailureMessage.Should().Contain("overwrite"); + mockClient.Verify(c => c.ImportCertificateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Fact] + public void Add_OverwriteTrue_CertExists_Succeeds() + { + var job = BuildJob(out var mockClient); + mockClient + .Setup(c => c.GetCertificate(Alias)) + .ReturnsAsync(MakeFakeCertificate(Alias)); + SetupImportSuccess(mockClient, Alias); + + var result = job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + } + + [Fact] + public void Add_ImportThrows_ReturnsFailWithMessage() + { + var job = BuildJob(out var mockClient); + mockClient + .Setup(c => c.ImportCertificateAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ThrowsAsync(new Exception("AKV import failed")); + + var result = job.CallPerformAddition( + Alias, CertificateFixtures.PfxPassword, CertificateFixtures.Rsa2048Base64, + EmptyTags, JobHistoryId, overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("AKV import failed"); + } + + [Fact] + public void Add_NoPfxPassword_ReturnsFail() + { + var job = BuildJob(out _); + + var result = job.CallPerformAddition( + Alias, pfxPassword: "", entryContents: CertificateFixtures.Rsa2048Base64, + tagsJSON: EmptyTags, jobHistoryId: JobHistoryId, + overwrite: true, preserveTags: false, nonExportable: false); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("PFX"); + } + + // ── Remove ──────────────────────────────────────────────────────────── + + [Fact] + public void Remove_ValidAlias_Succeeds() + { + var job = BuildJob(out var mockClient); + SetupDeleteSuccess(mockClient, Alias); + + var result = job.CallPerformRemoval(Alias, EmptyTags, JobHistoryId); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Success); + } + + [Fact] + public void Remove_EmptyAlias_ReturnsFail() + { + var job = BuildJob(out _); + + var result = job.CallPerformRemoval(alias: "", EmptyTags, JobHistoryId); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("alias"); + } + + [Fact] + public void Remove_DeleteThrows_ReturnsFailWithMessage() + { + var job = BuildJob(out var mockClient); + mockClient + .Setup(c => c.DeleteCertificateAsync(Alias)) + .ThrowsAsync(new Exception("vault unreachable")); + + var result = job.CallPerformRemoval(Alias, EmptyTags, JobHistoryId); + + result.Result.Should().Be(OrchestratorJobStatusJobResult.Failure); + result.FailureMessage.Should().Contain("vault unreachable"); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private static TestableManagement BuildJob(out Mock mockClient) + { + mockClient = new Mock(); + mockClient + .Setup(c => c.GetCertificate(It.IsAny())) + .ReturnsAsync((KeyVaultCertificateWithPolicy)null); + + var resolverMock = new Mock(); + resolverMock.Setup(r => r.Resolve(It.IsAny())).Returns(s => s); + + return new TestableManagement(resolverMock.Object) + { + AzClient = mockClient.Object, + VaultProperties = new AkvProperties { VaultName = "test-vault" }, + Logger = LogHandler.GetClassLogger() + }; + } + + /// + /// Creates a minimal KeyVaultCertificateWithPolicy for "cert exists" scenarios + /// (overwrite/tags tests). Uses CertificateModelFactory — the SDK's test helper. + /// + private static KeyVaultCertificateWithPolicy MakeFakeCertificate(string name) + { + var props = CertificateModelFactory.CertificateProperties(name: name); + return CertificateModelFactory.KeyVaultCertificateWithPolicy(properties: props); + } + + private static void SetupImportSuccess(Mock mockClient, string alias) + { + // Build a cert with Version set so PerformAddition's success check passes. + // X509Thumbprint isn't a factory parameter in this SDK version so we set it + // via reflection after construction. + var props = CertificateModelFactory.CertificateProperties( + name: alias, + version: "abc123def456"); + var cert = CertificateModelFactory.KeyVaultCertificateWithPolicy(properties: props); + + // Set X509Thumbprint — try all known field name patterns across SDK versions + var thumbField = FindField(typeof(CertificateProperties), "_x509Thumbprint") + ?? FindField(typeof(CertificateProperties), "_X509Thumbprint") + ?? FindField(typeof(CertificateProperties), "k__BackingField"); + thumbField?.SetValue(cert.Properties, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }); + + mockClient + .Setup(c => c.ImportCertificateAsync( + alias, It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(cert); + } + + private static void SetupDeleteSuccess(Mock mockClient, string alias) + { + var props = CertificateModelFactory.CertificateProperties(name: alias); + var deletedCert = CertificateModelFactory.DeletedCertificate(properties: props); + + var opMock = new Mock(); + opMock.Setup(o => o.Value).Returns(deletedCert); + mockClient.Setup(c => c.DeleteCertificateAsync(alias)).ReturnsAsync(opMock.Object); + } + + /// Searches the type hierarchy for a private/protected field by name. + private static FieldInfo FindField(Type type, string fieldName) + { + while (type != null) + { + var f = type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); + if (f != null) return f; + type = type.BaseType; + } + return null; + } + } +} diff --git a/AzureKeyVault.sln b/AzureKeyVault.sln index ebd5192..e9547fc 100644 --- a/AzureKeyVault.sln +++ b/AzureKeyVault.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution rbac.md = rbac.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureKeyVault.Tests", "AzureKeyVault.Tests\AzureKeyVault.Tests.csproj", "{1F688DB4-1CC6-4F69-BF6D-BE1BC1950DAA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -25,6 +27,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 + {1F688DB4-1CC6-4F69-BF6D-BE1BC1950DAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F688DB4-1CC6-4F69-BF6D-BE1BC1950DAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F688DB4-1CC6-4F69-BF6D-BE1BC1950DAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F688DB4-1CC6-4F69-BF6D-BE1BC1950DAA}.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 3eae38e..d456af4 100644 --- a/AzureKeyVault/AkvProperties.cs +++ b/AzureKeyVault/AkvProperties.cs @@ -22,7 +22,7 @@ public class AkvProperties public string VaultRegion { get; set; } public bool PremiumSKU { get; set; } public List TenantIdsForDiscovery { get; set; } - internal protected bool UseAzureManagedIdentity + internal bool UseAzureManagedIdentity { get { @@ -36,7 +36,7 @@ internal protected bool UseAzureManagedIdentity public string PrivateEndpoint { get; set; } - internal protected string VaultEndpoint + internal string VaultEndpoint { //return the default endpoint suffix for the Azure Cloud instance of the KeyVault. get { diff --git a/AzureKeyVault/AzureClient.cs b/AzureKeyVault/AzureClient.cs index 1647001..d00fce4 100644 --- a/AzureKeyVault/AzureClient.cs +++ b/AzureKeyVault/AzureClient.cs @@ -6,10 +6,6 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Azure; using Azure.Core; using Azure.Identity; @@ -22,8 +18,11 @@ using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; using Microsoft.Extensions.Logging; -using Microsoft.VisualBasic; using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault { @@ -34,22 +33,22 @@ private Uri AzureCloudEndpoint { get { - logger.LogTrace($"the AzureCloud is {VaultProperties.AzureCloud}, so we will use the following endpoint for authentication: "); + Logger.LogTrace($"the AzureCloud is {VaultProperties.AzureCloud}, so we will use the following endpoint for authentication: "); switch (VaultProperties.AzureCloud?.Trim()?.ToLowerInvariant()) { case "china": - logger.LogTrace(AzureAuthorityHosts.AzureChina.ToString()); + Logger.LogTrace(AzureAuthorityHosts.AzureChina.ToString()); return AzureAuthorityHosts.AzureChina; case "government": - logger.LogTrace(AzureAuthorityHosts.AzureGovernment.ToString()); + Logger.LogTrace(AzureAuthorityHosts.AzureGovernment.ToString()); return AzureAuthorityHosts.AzureGovernment; default: - logger.LogTrace(AzureAuthorityHosts.AzurePublicCloud.ToString()); + Logger.LogTrace(AzureAuthorityHosts.AzurePublicCloud.ToString()); return AzureAuthorityHosts.AzurePublicCloud; } } } - ILogger logger { get; set; } + ILogger Logger { get; set; } private protected virtual CertificateClient CertClient { @@ -57,32 +56,32 @@ private protected virtual CertificateClient CertClient { if (_certClient != null) { - logger.LogTrace("getting previously initialized certificate client"); + Logger.LogTrace("getting previously initialized certificate client"); return _certClient; } - logger.LogTrace("initializing new instance of client."); + Logger.LogTrace("initializing new instance of client."); TokenCredential cred; // check to see if they have selected to use an Azure Managed Identity for authentication. if (this.VaultProperties.UseAzureManagedIdentity) { - logger.LogTrace("Entering the managed identity workflow"); + Logger.LogTrace("Entering the managed identity workflow"); var credentialOptions = new DefaultAzureCredentialOptions { AuthorityHost = AzureCloudEndpoint, AdditionallyAllowedTenants = { "*" } }; if (!string.IsNullOrEmpty(this.VaultProperties.ClientId)) // are they using a user assigned identity instead of a system assigned one (default)? { - logger.LogTrace("client ID provided, so it is a user assigned managed identity (instead of system assigned)"); + Logger.LogTrace("client ID provided, so it is a user assigned managed identity (instead of system assigned)"); credentialOptions.ManagedIdentityClientId = VaultProperties.ClientId; } cred = new DefaultAzureCredential(credentialOptions); } else { - logger.LogTrace("Using a service principal to authenticate, generating the credentials"); + Logger.LogTrace("Using a service principal to authenticate, generating the credentials"); cred = new ClientSecretCredential(VaultProperties.TenantId, VaultProperties.ClientId, VaultProperties.ClientSecret, new ClientSecretCredentialOptions() { AuthorityHost = AzureCloudEndpoint, AdditionallyAllowedTenants = { "*" } }); - logger.LogTrace("generated credentials"); + Logger.LogTrace("generated credentials"); } _certClient = new CertificateClient(new Uri(VaultProperties.VaultURL), credential: cred); @@ -95,29 +94,29 @@ internal protected virtual ArmClient getArmClient(string tenantId) { TokenCredential credential; var credentialOptions = new DefaultAzureCredentialOptions { AuthorityHost = AzureCloudEndpoint, AdditionallyAllowedTenants = { "*" } }; - logger.LogTrace($"creating an ARM client for management operations with authorityhost {AzureCloudEndpoint.ToString()}"); + Logger.LogTrace($"creating an ARM client for management operations with authorityhost {AzureCloudEndpoint.ToString()}"); if (this.VaultProperties.UseAzureManagedIdentity) { - logger.LogTrace("getting management client for a managed identity"); + Logger.LogTrace("getting management client for a managed identity"); if (!string.IsNullOrEmpty(tenantId)) credentialOptions.TenantId = tenantId; if (!string.IsNullOrEmpty(this.VaultProperties.ClientId)) // they have selected a managed identity and provided a client ID, so it is a user assigned identity { - logger.LogTrace("It is a user assigned managed identity"); + Logger.LogTrace("It is a user assigned managed identity"); credentialOptions.ManagedIdentityClientId = VaultProperties.ClientId; } credential = new DefaultAzureCredential(credentialOptions); } else { - logger.LogTrace($"getting credentials for a service principal identity with id {VaultProperties.ClientId} in Azure Tenant {credentialOptions.TenantId}"); + Logger.LogTrace($"getting credentials for a service principal identity with id {VaultProperties.ClientId} in Azure Tenant {credentialOptions.TenantId}"); credential = new ClientSecretCredential(tenantId, VaultProperties.ClientId, VaultProperties.ClientSecret, credentialOptions); - logger.LogTrace("got credentials for service principal identity"); + Logger.LogTrace("got credentials for service principal identity"); } _mgmtClient = new ArmClient(credential); - logger.LogTrace("created management client"); + Logger.LogTrace("created management client"); return _mgmtClient; } @@ -127,7 +126,7 @@ internal protected virtual ArmClient KvManagementClient { if (_mgmtClient != null) { - logger.LogTrace("getting previously initialized management client"); + Logger.LogTrace("getting previously initialized management client"); return _mgmtClient; } return getArmClient(VaultProperties.TenantId); @@ -141,12 +140,12 @@ public AzureClient(AkvProperties props) { VaultProperties = props; - logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); } public virtual async Task DeleteCertificateAsync(string certName) { - logger.LogTrace("calling method to delete certificate"); + Logger.LogTrace("calling method to delete certificate"); return await CertClient.StartDeleteCertificateAsync(certName); } @@ -154,9 +153,9 @@ public virtual async Task CreateVault() { try { - logger.LogTrace($"Begin create vault in Subscription {VaultProperties.SubscriptionId} with storepath = {VaultProperties.StorePath}"); + Logger.LogTrace($"Begin create vault in Subscription {VaultProperties.SubscriptionId} with storepath = {VaultProperties.StorePath}"); - logger.LogTrace($"getting subscription info for provided subscription id {VaultProperties.SubscriptionId}"); + Logger.LogTrace($"getting subscription info for provided subscription id {VaultProperties.SubscriptionId}"); SubscriptionResource subscription = KvManagementClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(VaultProperties.SubscriptionId)); ResourceGroupResource resourceGroup = subscription.GetResourceGroup(VaultProperties.ResourceGroupName); @@ -168,14 +167,14 @@ public virtual async Task CreateVault() { try { - logger.LogTrace($"no Vault Region location specified for new Vault, Getting available regions for resource group {resourceGroup.Data.Name}."); + Logger.LogTrace($"no Vault Region location specified for new Vault, Getting available regions for resource group {resourceGroup.Data.Name}."); var locOptions = await resourceGroup.GetAvailableLocationsAsync(); - logger.LogTrace($"got location options for subscription {subscription.Data.SubscriptionId}", locOptions); + Logger.LogTrace($"got location options for subscription {subscription.Data.SubscriptionId}", locOptions); loc = locOptions.Value.FirstOrDefault(); } catch (Exception ex) { - logger.LogError($"error retrieving default Azure Location: {ex.Message}"); + Logger.LogError($"error retrieving default Azure Location: {ex.Message}"); throw; } } @@ -194,7 +193,7 @@ public virtual async Task CreateVault() } catch (Exception ex) { - logger.LogError($"Error when trying to create Azure Keyvault {ex.Message}"); + Logger.LogError($"Error when trying to create Azure Keyvault {ex.Message}"); throw; } } @@ -203,25 +202,31 @@ public virtual async Task ImportCertificateAsync( { try { - logger.LogTrace("checking to see if the certificate exists and has been deleted"); + Logger.LogTrace("checking to see if the certificate exists and has been deleted"); if (CertClient.GetDeletedCertificates().FirstOrDefault(i => i.Name == certName) != null) { - logger.LogTrace("certificate to import has been previously deleted, starting recovery operation."); + Logger.LogTrace("certificate to import has been previously deleted, starting recovery operation."); RecoverDeletedCertificateOperation recovery = await CertClient.StartRecoverDeletedCertificateAsync(certName); - recovery.WaitForCompletion(); + await recovery.WaitForCompletionAsync(); } - logger.LogTrace($"converting to pkcs12 without password for importing to keyvault"); + Logger.LogTrace($"converting to pkcs12 without password for importing to keyvault"); - var p12bytes = Helpers.ConvertPfxToPasswordlessPkcs12(contents, pfxPassword); + var pkcs12 = Helpers.ConvertPfxToPasswordlessPkcs12(contents, pfxPassword); - logger.LogTrace($"got a byte array with length {p12bytes.Length}"); + Logger.LogTrace($"got byte array of length {pkcs12.CertBytes.Length}, key type: {pkcs12.KeyType}, key size: {pkcs12.KeySize}"); - logger.LogTrace($"calling ImportCertificateAsync on the KeyVault certificate client to import certificate {certName}"); + Logger.LogTrace($"calling ImportCertificateAsync on the KeyVault certificate client to import certificate {certName}"); - var options = new ImportCertificateOptions(certName, p12bytes); - options.Policy = new CertificatePolicy { Exportable = !nonExportable, ContentType = CertificateContentType.Pkcs12 }; + var options = new ImportCertificateOptions(certName, pkcs12.CertBytes); + options.Policy = new CertificatePolicy + { + Exportable = !nonExportable, + ContentType = CertificateContentType.Pkcs12, + KeyType = pkcs12.KeyType == "EC" ? CertificateKeyType.Ec : CertificateKeyType.Rsa, + KeySize = pkcs12.KeyType == "RSA" ? pkcs12.KeySize : null + }; if (tags.Any()) { @@ -237,7 +242,7 @@ public virtual async Task ImportCertificateAsync( } catch (Exception ex) { - logger.LogError($"There was an error importing the certificate: {ex.Message}"); + Logger.LogError($"There was an error importing the certificate: {ex.Message}"); throw; } } @@ -245,7 +250,7 @@ public virtual async Task ImportCertificateAsync( public virtual async Task GetCertificate(string alias) { KeyVaultCertificateWithPolicy cert = null; - logger.LogTrace($"Attempting to retreive certificate with alias {alias} from the KeyVault."); + Logger.LogTrace($"Attempting to retreive certificate with alias {alias} from the KeyVault."); try { cert = await CertClient.GetCertificateAsync(alias); } catch (RequestFailedException rEx) @@ -253,13 +258,13 @@ public virtual async Task GetCertificate(string a if (rEx.ErrorCode == "CertificateNotFound") { // the request was successful, the cert does not exist. - logger.LogTrace($"The certificate with alias {alias} was not found: {rEx.Message}"); + Logger.LogTrace($"The certificate with alias {alias} was not found: {rEx.Message}"); return null; } } catch (Exception ex) { - logger.LogError($"Error retreiving certificate with alias {alias}. {ex.Message}", ex); + Logger.LogError($"Error retreiving certificate with alias {alias}. {ex.Message}", ex); throw; } @@ -272,18 +277,18 @@ public virtual async Task> GetCertificatesAsyn AsyncPageable inventory = null; try { - logger.LogTrace("calling GetPropertiesOfCertificates() on the Certificate Client"); + Logger.LogTrace("calling GetPropertiesOfCertificates() on the Certificate Client"); inventory = CertClient.GetPropertiesOfCertificatesAsync(); - logger.LogTrace($"got a pageable response"); + Logger.LogTrace($"got a pageable response"); } catch (Exception ex) { - logger.LogError($"Error performing inventory. {ex.Message}", ex); + Logger.LogError($"Error performing inventory. {ex.Message}", ex); throw; } - logger.LogTrace("iterating over result pages for complete list.."); + Logger.LogTrace("iterating over result pages for complete list.."); var fullInventoryList = new List(); var failedCount = 0; @@ -291,20 +296,20 @@ public virtual async Task> GetCertificatesAsyn await foreach (var cert in inventory) { - logger.LogTrace($"adding cert with ID: {cert.Id} to the list."); + Logger.LogTrace($"adding cert with ID: {cert.Id} to the list."); fullInventoryList.Add(cert); // convert to list from pages } - logger.LogTrace($"compiled full inventory list of {fullInventoryList.Count()} certificate(s)"); + Logger.LogTrace($"compiled full inventory list of {fullInventoryList.Count} certificate(s)"); foreach (var certificate in fullInventoryList) { - logger.LogTrace($"getting details for the individual certificate with id: {certificate.Id} and name: {certificate.Name}"); + Logger.LogTrace($"getting details for the individual certificate with id: {certificate.Id} and name: {certificate.Name}"); try { var cert = await CertClient.GetCertificateAsync(certificate.Name); - logger.LogTrace($"got certificate details"); - logger.LogTrace($"cert properties: {JsonConvert.SerializeObject(cert.Value?.Properties)}"); + Logger.LogTrace($"got certificate details"); + Logger.LogTrace($"cert properties: {JsonConvert.SerializeObject(cert.Value?.Properties)}"); var itemEntryParams = new Dictionary(); if (cert.Value?.Properties?.Tags != null && cert.Value.Properties.Tags.Count > 0) { // set tags entry parameter to value @@ -319,7 +324,7 @@ public virtual async Task> GetCertificatesAsyn itemEntryParams.Add(EntryParameters.PRESERVE_TAGS, null); // we can never know this; it's only evaluated on enrollment; set to null - logger.LogTrace($"evaluated entry parameters to be returned: {JsonConvert.SerializeObject(itemEntryParams)}"); + Logger.LogTrace($"evaluated entry parameters to be returned: {JsonConvert.SerializeObject(itemEntryParams)}"); inventoryItems.Add(new CurrentInventoryItem() { @@ -335,19 +340,19 @@ public virtual async Task> GetCertificatesAsyn { failedCount++; innerException = ex; - logger.LogError($"Failed to retreive details for certificate {certificate.Name}. Exception: {ex.Message}"); + Logger.LogError($"Failed to retrieve details for certificate {certificate.Name}. Exception: {ex.Message}"); // continuing with inventory instead of throwing, in case there's an issue with a single certificate } } - if (failedCount == fullInventoryList.Count() && failedCount > 0) + if (failedCount == fullInventoryList.Count && failedCount > 0) { - throw new Exception("Unable to retreive details for certificates.", innerException); + throw new Exception("Unable to retrieve details for certificates.", innerException); } if (failedCount > 0) { - logger.LogWarning($"{failedCount} of {fullInventoryList.Count()} certificates were not able to be retreieved. Please review the errors."); + Logger.LogWarning($"{failedCount} of {fullInventoryList.Count} certificates were not able to be retrieved. Please review the errors."); } return inventoryItems; @@ -362,28 +367,28 @@ public virtual (List, List) GetVaults() try { - if (VaultProperties.TenantIdsForDiscovery == null || VaultProperties.TenantIdsForDiscovery.Count() < 1) + if (VaultProperties.TenantIdsForDiscovery == null || VaultProperties.TenantIdsForDiscovery.Count < 1) { throw new Exception("no tenant ID's provided."); } VaultProperties.TenantIdsForDiscovery.ForEach(tenantId => { searchTenantId = tenantId; - logger.LogTrace($"getting ARM client for tenantId {tenantId}"); + Logger.LogTrace($"getting ARM client for tenantId {tenantId}"); var mgmtClient = getArmClient(tenantId); - logger.LogTrace($"getting all available subscriptions in tenant with ID {tenantId}"); + Logger.LogTrace($"getting all available subscriptions in tenant with ID {tenantId}"); var allSubs = mgmtClient.GetSubscriptions(); - logger.LogTrace($"got {allSubs.Count()} subscriptions"); + Logger.LogTrace($"got {allSubs.Count()} subscriptions"); foreach (var sub in allSubs) { searchSubscription = sub.Id.SubscriptionId; - logger.LogTrace($"searching for vaults in subscription with ID {sub.Data.SubscriptionId}"); + Logger.LogTrace($"searching for vaults in subscription with ID {sub.Data.SubscriptionId}"); var vaults = sub.GetKeyVaults(); - logger.LogTrace($"found {vaults.Count()} vaults."); + Logger.LogTrace($"found {vaults.Count()} vaults."); foreach (var vault in vaults) { @@ -393,7 +398,7 @@ public virtual (List, List) GetVaults() var resourceGroupName = splitId[3]; var vaultName = splitId.Last(); var vaultStorePath = $"{subId}:{resourceGroupName}:{vaultName}"; - logger.LogTrace($"found keyvault, using storepath {vaultStorePath}"); + Logger.LogTrace($"found keyvault, using storepath {vaultStorePath}"); vaultNames.Add($"{subId}:{resourceGroupName}:{vaultName}"); } } @@ -401,10 +406,10 @@ public virtual (List, List) GetVaults() } catch (Exception ex) { - logger.LogTrace($"Exception thrown during discovery. Log warning and continue."); + Logger.LogTrace($"Exception thrown during discovery. Log warning and continue."); var warning = $"Exception thrown performing discovery on tenantId {searchTenantId} and subscription ID {searchSubscription}. Exception message: {ex.Message}"; - logger.LogWarning(warning); + Logger.LogWarning(warning); warnings.Add(warning); } diff --git a/AzureKeyVault/Helpers.cs b/AzureKeyVault/Helpers.cs index fd934be..8a997be 100644 --- a/AzureKeyVault/Helpers.cs +++ b/AzureKeyVault/Helpers.cs @@ -6,6 +6,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. +using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; using System; @@ -14,6 +15,8 @@ namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault { + public record Pkcs12ConversionResult(byte[] CertBytes, string KeyType, int? KeySize); + public static class Helpers { public static bool IsValidJson(this string jsonString) @@ -34,7 +37,7 @@ public static bool IsValidJson(this string jsonString) } return true; } - public static byte[] ConvertPfxToPasswordlessPkcs12(string base64Pfx, string pfxPassword) + public static Pkcs12ConversionResult ConvertPfxToPasswordlessPkcs12(string base64Pfx, string pfxPassword) { // Decode the Base64-encoded PFX data byte[] pfxBytes = Convert.FromBase64String(base64Pfx); @@ -46,11 +49,26 @@ public static byte[] ConvertPfxToPasswordlessPkcs12(string base64Pfx, string pfx store.Load(inputStream, pfxPassword.ToCharArray()); string alias = null; + string keyType = "RSA"; + int? keySize = 2048; + foreach (string a in store.Aliases) { if (store.IsKeyEntry(a)) { alias = a; + var privateKey = store.GetKey(a).Key; + + if (privateKey is RsaKeyParameters rsaKey) + { + keyType = "RSA"; + keySize = rsaKey.Modulus.BitLength; + } + else if (privateKey is ECKeyParameters) + { + keyType = "EC"; + keySize = null; + } break; } } @@ -81,7 +99,7 @@ public static byte[] ConvertPfxToPasswordlessPkcs12(string base64Pfx, string pfx // Save the new PKCS#12 store without a password newStore.Save(outputStream, null, new SecureRandom()); - return outputStream.ToArray(); + return new Pkcs12ConversionResult(outputStream.ToArray(), keyType, keySize); } } } diff --git a/AzureKeyVault/Jobs/AzureKeyVaultJob.cs b/AzureKeyVault/Jobs/AzureKeyVaultJob.cs index 3279932..6a6977c 100644 --- a/AzureKeyVault/Jobs/AzureKeyVaultJob.cs +++ b/AzureKeyVault/Jobs/AzureKeyVaultJob.cs @@ -8,11 +8,14 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Keyfactor.Orchestrators.Extensions; using Keyfactor.Orchestrators.Extensions.Interfaces; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +[assembly: InternalsVisibleTo("Keyfactor.Extensions.Orchestrators.AKV.Tests")] + namespace Keyfactor.Extensions.Orchestrator.AzureKeyVault { public abstract class AzureKeyVaultJob : IOrchestratorJobExtension @@ -21,9 +24,9 @@ public abstract class AzureKeyVaultJob : IOrchestratorJobExtension internal protected virtual AzureClient AzClient { get; set; } internal protected virtual AkvProperties VaultProperties { get; set; } internal protected IPAMSecretResolver PamSecretResolver { get; set; } - internal protected ILogger logger { get; set; } + internal protected ILogger Logger { get; set; } - public void InitializeStore(dynamic config) + public virtual void InitializeStore(dynamic config) { try { @@ -31,27 +34,27 @@ public void InitializeStore(dynamic config) if (config.GetType().GetProperty("ClientMachine") != null) // Discovery job VaultProperties.TenantId = config.ClientMachine; - if (!string.IsNullOrEmpty(VaultProperties.TenantId)) { logger.LogTrace($"Got tenant ID {VaultProperties.TenantId} from ClientMachine field."); } + if (!string.IsNullOrEmpty(VaultProperties.TenantId)) { Logger.LogTrace($"Got tenant ID {VaultProperties.TenantId} from ClientMachine field."); } // ClientId can be omitted for system assigned managed identities, required for user assigned or service principal auth - VaultProperties.ClientId = PAMUtilities.ResolvePAMField(PamSecretResolver, logger, "Server UserName", config.ServerUsername); + VaultProperties.ClientId = PAMUtilities.ResolvePAMField(PamSecretResolver, Logger, "Server UserName", config.ServerUsername); - logger.LogTrace($"Using client id {VaultProperties.ClientId}"); + Logger.LogTrace($"Using client id {VaultProperties.ClientId}"); // ClientSecret can be omitted for managed identities, required for service principal auth - VaultProperties.ClientSecret = PAMUtilities.ResolvePAMField(PamSecretResolver, logger, "Server Password", config.ServerPassword); + VaultProperties.ClientSecret = PAMUtilities.ResolvePAMField(PamSecretResolver, Logger, "Server Password", config.ServerPassword); if (VaultProperties.ClientSecret == null) { - logger.LogTrace("No client secret provided, assuming Managed Identity authentication"); + Logger.LogTrace("No client secret provided, assuming Managed Identity authentication"); } else { - logger.LogTrace("client secret provided, assuming Service Principal authentication"); + Logger.LogTrace("client secret provided, assuming Service Principal authentication"); } if (config.GetType().GetProperty("CertificateStoreDetails") != null) // anything except a discovery job { - logger.LogTrace("CertificateStoreDetails is not empty, (non-Discovery job) applying values.."); + Logger.LogTrace("CertificateStoreDetails is not empty, (non-Discovery job) applying values.."); VaultProperties.StorePath = config.CertificateStoreDetails?.StorePath; dynamic properties = JsonConvert.DeserializeObject(config.CertificateStoreDetails.Properties.ToString()); @@ -60,7 +63,7 @@ public void InitializeStore(dynamic config) if (storePathFields.Length == 3) { //using the latest (3 fields) - logger.LogTrace("storepath split by `:` into 3 parts. subscription ID: {{subscription id}}:{{resource group name}}:{{vault name}}."); + Logger.LogTrace("storepath split by `:` into 3 parts. subscription ID: {{subscription id}}:{{resource group name}}:{{vault name}}."); VaultProperties.SubscriptionId = storePathFields[0].Trim(); VaultProperties.ResourceGroupName = storePathFields[1].Trim(); VaultProperties.VaultName = storePathFields[2]?.Trim(); @@ -69,7 +72,7 @@ public void InitializeStore(dynamic config) // support legacy store path : if (storePathFields.Length == 2) { // using previous version (2 fields) - logger.LogTrace($"storepath split by `:` into 2 parts. {storePathFields}. Using {{subscription id}}:{{vault name}} format."); + Logger.LogTrace($"storepath split by `:` into 2 parts. {storePathFields}. Using {{subscription id}}:{{vault name}} format."); VaultProperties.SubscriptionId = storePathFields[0].Trim(); VaultProperties.VaultName = storePathFields[1].Trim(); VaultProperties.ResourceGroupName = properties.ResourceGroupName; @@ -82,36 +85,36 @@ public void InitializeStore(dynamic config) var legacyPathComponents = VaultProperties.StorePath.Split('/', StringSplitOptions.RemoveEmptyEntries); if (legacyPathComponents.Length == 8) // they are using the full resource path { - logger.LogTrace($"full resource identifier provided {storePathFields}. Using {{subscription id}}:{{vault name}} format."); + Logger.LogTrace($"full resource identifier provided {storePathFields}. Using {{subscription id}}:{{vault name}} format."); VaultProperties.SubscriptionId = legacyPathComponents[1]; VaultProperties.ResourceGroupName = legacyPathComponents[3]; VaultProperties.VaultName = legacyPathComponents[7]; } } - logger.LogTrace($"Parsed storepath: Subscription ID: {VaultProperties.SubscriptionId}, ResourceGroupName: {VaultProperties.ResourceGroupName}, VaultName: {VaultProperties.VaultName}"); + Logger.LogTrace($"Parsed storepath: Subscription ID: {VaultProperties.SubscriptionId}, ResourceGroupName: {VaultProperties.ResourceGroupName}, VaultName: {VaultProperties.VaultName}"); VaultProperties.SubscriptionId = properties.SubscriptionId ?? VaultProperties.SubscriptionId; VaultProperties.ResourceGroupName = !string.IsNullOrEmpty(properties.ResourceGroupName as string) ? properties.ResourceGroupName : VaultProperties.ResourceGroupName; - VaultProperties.VaultName = properties.VaultName ?? VaultProperties.VaultName; // check the field in case of legacy paths. - + VaultProperties.VaultName = !string.IsNullOrEmpty(properties.VaultName as string) ? properties.VaultName : VaultProperties.VaultName; + VaultProperties.TenantId = !string.IsNullOrEmpty(VaultProperties.TenantId) ? VaultProperties.TenantId : config.CertificateStoreDetails?.ClientMachine; // Client Machine could be null in the case of managed identity. That's ok. VaultProperties.AzureCloud = properties.AzureCloud; - logger.LogTrace($"Azure Cloud: {VaultProperties.AzureCloud}"); + Logger.LogTrace($"Azure Cloud: {VaultProperties.AzureCloud}"); VaultProperties.PrivateEndpoint = properties.PrivateEndpoint; - logger.LogTrace($"Private Endpoint: {VaultProperties.PrivateEndpoint}"); + Logger.LogTrace($"Private Endpoint: {VaultProperties.PrivateEndpoint}"); string skuType = !string.IsNullOrEmpty(properties.SkuType as string) ? properties.SkuType : null; VaultProperties.PremiumSKU = skuType?.ToLower() == "premium"; - + VaultProperties.VaultRegion = !string.IsNullOrEmpty(properties.VaultRegion as string) ? properties.VaultRegion : VaultProperties.VaultRegion; VaultProperties.VaultRegion = VaultProperties.VaultRegion?.ToLower(); } else // discovery job : Discovery only works on the Global Public Azure cloud because we do not have a way to pass the Azure Cloud instance value during a discovery job. { - logger.LogTrace("Discovery job - getting tenant ids from directories to search field."); + Logger.LogTrace("Discovery job - getting tenant ids from directories to search field."); VaultProperties.TenantIdsForDiscovery = new List(); var dirs = config.JobProperties?["dirs"] as string; - logger.LogTrace($"Directories to search: {dirs}"); + Logger.LogTrace($"Directories to search: {dirs}"); if (!string.IsNullOrEmpty(dirs)) { @@ -131,11 +134,11 @@ public void InitializeStore(dynamic config) } catch (Exception ex) { - logger.LogError($"Error initializing store: {ex.Message}"); + Logger.LogError($"Error initializing store: {ex.Message}"); throw; } - logger.LogTrace($"Initialization complete, configuration values set."); + Logger.LogTrace($"Initialization complete, configuration values set."); } } } diff --git a/AzureKeyVault/Jobs/Discovery.cs b/AzureKeyVault/Jobs/Discovery.cs index 6d0fdbe..8554f9d 100644 --- a/AzureKeyVault/Jobs/Discovery.cs +++ b/AzureKeyVault/Jobs/Discovery.cs @@ -23,12 +23,12 @@ public class Discovery : AzureKeyVaultJob, IDiscoveryJobExtension public Discovery(IPAMSecretResolver resolver) { PamSecretResolver = resolver; - logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); } public JobResult ProcessJob(DiscoveryJobConfiguration config, SubmitDiscoveryUpdate sdr) { - logger.LogDebug($"Begin Discovery job"); + Logger.LogDebug($"Begin Discovery job"); InitializeStore(config); var complete = new JobResult() { JobHistoryId = config.JobHistoryId, Result = OrchestratorJobStatusJobResult.Failure }; @@ -49,17 +49,17 @@ public JobResult ProcessJob(DiscoveryJobConfiguration config, SubmitDiscoveryUpd // if there are no warnings return vaults and status of success if (warnings == null || !warnings.Any()) { - logger.LogTrace("discovery completed with no warnings or errors."); + Logger.LogTrace("discovery completed with no warnings or errors."); complete.Result = OrchestratorJobStatusJobResult.Success; - complete.FailureMessage = $"Discovery job completed successfully. Found {keyVaults?.Count() ?? 0} KeyVaults."; + complete.FailureMessage = $"Discovery job completed successfully. Found {keyVaults?.Count ?? 0} KeyVaults."; } // if there are warnings, but vaults were found, return Vaults and status of warn - if (warnings?.Count() > 0 && keyVaults.Count() > 0) + if (warnings?.Count > 0 && keyVaults.Count > 0) { - logger.LogTrace("discovery completed with warnings."); + Logger.LogTrace("discovery completed with warnings."); complete.Result = OrchestratorJobStatusJobResult.Warning; - complete.FailureMessage = $"Discovery job completed with errors. Found {keyVaults?.Count() ?? 0} KeyVaults.\nThe following errors occurred: \n"; + complete.FailureMessage = $"Discovery job completed with errors. Found {keyVaults?.Count ?? 0} KeyVaults.\nThe following errors occurred: \n"; complete.FailureMessage = complete.FailureMessage + string.Join('\n', warnings); if (complete.FailureMessage.Length > 4000) { @@ -69,18 +69,18 @@ public JobResult ProcessJob(DiscoveryJobConfiguration config, SubmitDiscoveryUpd // if there are warnings and no vaults were found, return status of error - if (warnings?.Count() > 0 && keyVaults?.Count() == 0) + if (warnings?.Count > 0 && keyVaults?.Count == 0) { - logger.LogTrace("discovery completed with errors and no vaults found (failed)."); + Logger.LogTrace("discovery completed with errors and no vaults found (failed)."); complete.Result = OrchestratorJobStatusJobResult.Failure; complete.FailureMessage = $"Discovery job failed with the following errors: \n"; complete.FailureMessage = complete.FailureMessage + string.Join('\n', warnings); } // need to truncate failure message if it exceeds the max length of 4000 - if (complete.FailureMessage.Length > 4000) + if (complete.Result != OrchestratorJobStatusJobResult.Success && complete.FailureMessage?.Length > 4000) { - logger.LogTrace($"Failure message length of {complete.FailureMessage.Length} exceeds the maximum of 4000; truncating."); + Logger.LogTrace($"Failure message length of {complete.FailureMessage.Length} exceeds the maximum of 4000; truncating."); complete.FailureMessage = complete.FailureMessage.Substring(0, 3500) + "\n results truncated. Please see the Orchestrator logs for more details."; } diff --git a/AzureKeyVault/Jobs/Inventory.cs b/AzureKeyVault/Jobs/Inventory.cs index 39e079b..0f28c63 100644 --- a/AzureKeyVault/Jobs/Inventory.cs +++ b/AzureKeyVault/Jobs/Inventory.cs @@ -23,12 +23,12 @@ public class Inventory : AzureKeyVaultJob, IInventoryJobExtension public Inventory(IPAMSecretResolver resolver) { PamSecretResolver = resolver; - logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); } public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpdate callBack) { - logger.LogDebug($"Begin Inventory..."); + Logger.LogDebug($"Begin Inventory..."); InitializeStore(config); @@ -36,16 +36,16 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd try { - logger.LogTrace($"Making Request to get certificates from vault at {VaultProperties.VaultURL}"); + Logger.LogTrace($"Making Request to get certificates from vault at {VaultProperties.VaultURL}"); - inventoryItems = AzClient.GetCertificatesAsync().Result?.ToList(); + inventoryItems = AzClient.GetCertificatesAsync().GetAwaiter().GetResult()?.ToList(); - logger.LogTrace($"Found {inventoryItems.Count} Total Certificates in Azure Key Vault."); + Logger.LogTrace($"Found {inventoryItems.Count} Total Certificates in Azure Key Vault."); } catch (Exception ex) { - logger.LogTrace($"an error occured when performing inventory: {ex.Message}"); + Logger.LogTrace($"an error occurred when performing inventory: {ex.Message}"); return new JobResult { Result = OrchestratorJobStatusJobResult.Failure, diff --git a/AzureKeyVault/Jobs/Management.cs b/AzureKeyVault/Jobs/Management.cs index ed0d055..e8a69b3 100644 --- a/AzureKeyVault/Jobs/Management.cs +++ b/AzureKeyVault/Jobs/Management.cs @@ -26,16 +26,16 @@ public class Management : AzureKeyVaultJob, IManagementJobExtension public Management(IPAMSecretResolver resolver) { PamSecretResolver = resolver; - logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); } public JobResult ProcessJob(ManagementJobConfiguration config) { - logger.LogDebug($"Begin Management job"); + Logger.LogDebug($"Begin Management job"); InitializeStore(config); - logger.LogTrace($"raw entry parameters from command: {JsonConvert.SerializeObject(config.JobProperties)}"); + Logger.LogTrace($"raw entry parameters from command: {JsonConvert.SerializeObject(config.JobProperties)}"); JobResult complete = new JobResult() { @@ -47,7 +47,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config) bool preserveTags; bool nonExportable; - logger.LogTrace("parsing entry parameters.. "); + Logger.LogTrace("parsing entry parameters.. "); tagsJSON = config.JobProperties[EntryParameters.TAGS] as string ?? string.Empty; preserveTags = config.JobProperties[EntryParameters.PRESERVE_TAGS] as bool? ?? true; @@ -56,16 +56,16 @@ public JobResult ProcessJob(ManagementJobConfiguration config) switch (config.OperationType) { case CertStoreOperationType.Create: - logger.LogDebug($"Begin Management > Create..."); - complete = PerformCreateVault(config.JobHistoryId).Result; + Logger.LogDebug($"Begin Management > Create..."); + complete = PerformCreateVault(config.JobHistoryId).GetAwaiter().GetResult(); break; case CertStoreOperationType.Add: - logger.LogDebug($"Begin Management > Add..."); + Logger.LogDebug($"Begin Management > Add..."); complete = PerformAddition(config.JobCertificate.Alias, config.JobCertificate.PrivateKeyPassword, config.JobCertificate.Contents, tagsJSON, config.JobHistoryId, config.Overwrite, preserveTags, nonExportable); break; case CertStoreOperationType.Remove: - logger.LogDebug($"Begin Management > Remove..."); + Logger.LogDebug($"Begin Management > Remove..."); complete = PerformRemoval(config.JobCertificate.Alias, tagsJSON, config.JobHistoryId); break; } @@ -114,7 +114,7 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st { if (!tagsJSON.IsValidJson()) { - logger.LogError($"the entry parameter provided for Certificate Tags: \" {tagsJSON} \", does not seem to be valid JSON."); + Logger.LogError($"the entry parameter provided for Certificate Tags: \" {tagsJSON} \", does not seem to be valid JSON."); throw new Exception($"the string \" {tagsJSON} \" is not a valid json string. Please enter a valid json string for CertificateTags in the entry parameter or leave empty for no tags to be applied."); } else @@ -134,20 +134,20 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st try { var existingTags = new Dictionary(); - logger.LogTrace($"checking for an existing cert with the alias {alias}"); - var existing = AzClient.GetCertificate(alias).Result; + Logger.LogTrace($"checking for an existing cert with the alias {alias}"); + var existing = AzClient.GetCertificate(alias).GetAwaiter().GetResult(); if (existing != null) { - logger.LogTrace($"there is an existing cert.."); + Logger.LogTrace($"there is an existing cert.."); existingTags = existing?.Properties.Tags as Dictionary ?? new Dictionary(); - logger.LogTrace("existing cert tags: "); - if (!existingTags.Any()) logger.LogTrace("(none)"); + Logger.LogTrace("existing cert tags: "); + if (!existingTags.Any()) Logger.LogTrace("(none)"); foreach (var tag in existingTags) { - logger.LogTrace(tag.Key + " : " + tag.Value); + Logger.LogTrace(tag.Key + " : " + tag.Value); } } @@ -157,7 +157,7 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st if (existing != null) { var message = $"A certificate named {alias} already exists and the overwrite checkbox was unchecked. No action was taken."; - logger.LogWarning(message); + Logger.LogWarning(message); complete.Result = OrchestratorJobStatusJobResult.Warning; complete.FailureMessage = message; return complete; @@ -174,7 +174,7 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st } } - var cert = AzClient.ImportCertificateAsync(alias, entryContents, pfxPassword, tagDict, nonExportable).Result; + var cert = AzClient.ImportCertificateAsync(alias, entryContents, pfxPassword, tagDict, nonExportable).GetAwaiter().GetResult(); // Ensure the return object has a AKV version tag, and Thumbprint if (!string.IsNullOrEmpty(cert.Properties.Version) && @@ -191,7 +191,7 @@ protected virtual JobResult PerformAddition(string alias, string pfxPassword, st } catch (Exception ex) { - complete.FailureMessage = $"An error occured while adding {alias} to {ExtensionName}: " + ex.Message; + complete.FailureMessage = $"An error occurred while adding {alias} to {ExtensionName}: " + ex.Message; if (ex.InnerException != null) complete.FailureMessage += " - " + ex.InnerException.Message; @@ -221,7 +221,7 @@ protected virtual JobResult PerformRemoval(string alias, string tagsJSON, long j try { - var result = AzClient.DeleteCertificateAsync(alias).Result; + var result = AzClient.DeleteCertificateAsync(alias).GetAwaiter().GetResult(); if (result.Value.Name == alias) { @@ -235,7 +235,7 @@ protected virtual JobResult PerformRemoval(string alias, string tagsJSON, long j catch (Exception ex) { - complete.FailureMessage = $"An error occured while removing {alias} from {ExtensionName}: " + ex.Message; + complete.FailureMessage = $"An error occurred while removing {alias} from {ExtensionName}: " + ex.Message; } return complete; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 58cab0c..ffa2a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +- 3.2.3 + - Bug fix: there was an issue where we were not passing the Key Size to Azure, and it was causing an error when the default didn't match + - Now checking for empty vault name property to avoid overriding an existing value during Store Creation - [Issue 39](https://github.com/Keyfactor/azurekeyvault-orchestrator/issues/39#issuecomment-4298537246) + - 3.2.2 - Updated screenshots in README - Returning entry parameters along with inventory