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 d04cbc9..ffa2a22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,11 @@
+- 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
+
- 3.2.1
- Documentation updates and improvements
- Updated NuGet packages