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 = + "MIIQOAIBAzCCD+4GCSqGSIb3DQEHAaCCD98Egg/bMIIP1zCCBaoGCSqGSIb3DQEHBqCCBZswggWXAgEA" + + "MIIFkAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBAzLLQ0KtpG99NZcCP7" + + "uXO8AgJOIDAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQubgTXXe5E6+jzkOmpjAw/4CCBSAqhKTU" + + "kEFeUNwqNtI8UIkCYfxkR5ln82hzH7Wd+XE/Yx+fo9NshhlGh3riqzQwqG3+jeMubqIUiN9TpQfZakqe" + + "fXAGtJlVfFcwEKKn8rXRh+Z1EG7cUFESt59g9jBf9igc6OwAGTt8YgJtJwiF9WwtTCB+Rl228eLVm1n3" + + "pb6rQyZm6xYSQoaEUbOQN+U6294dE6ejUDxYVvx09vNsoWanhpYz0Jx4W6IQMMQxmdCLINNN0a9A0f2F" + + "xG4EJpkaQzJDE5bCbBOihoEasRrICnZ0MFvSNKvCAt70jwVNcvX0JOv7ahxXQj6wVHB6pWpthEiOObMn" + + "1yWB+CNeyegL8F+dGTvJb5GEa/+NkBD3tK+jqsN0uFEWSHcMxFiiKWgXNoALa7XS/qKRfoqhMG9584Dr" + + "bayNcEwAPGeFoXjtdSKYPu08ka74q6BApmBuXG0cvRx8VAQYrgtaolZRSd70JE+pVybt13MpToqNTufO" + + "ECiEGpfmzoobU0v3DXaQ79GA0XrXgF4KQ4GQN+kAzceqQz/kyRrBGCBZ7iC4D2aOBKZaTIePTPt/JodG" + + "VG1MNJ+nlhNXnRC/1cUtQXipActcC5fZas31GWFvjiJ+BOZR8huQrNkLYlVS6Ft3+gRzAU+m4j+nUBM2" + + "vCVmA/Q6Xu17XiN19ZhbjGV83WCMRszIsKNtL8typGAcWfgRfE7hsXYmMPHjRfitvr+GKDM1FktDlN0k" + + "WkIMp+GF/xrK1LMCA2ajn33ad5SI7KikXGoOH4Sxlc6MVTKiIJYbCMddzqzVj7nMRI+sRQgP/VwqWdQq" + + "d9fIcyQmZUDOYN7rG/RRYKcaAHl/ZeicfOxsTIpyzp2OnAQmh4OFfIYO0U91y9LO9HLfzoCkZdFbuSZW" + + "owzE6MWzYuhX6rHYSNXxgjSIgcFc0JAHIys12mV/sR/wOLRzGrL2dzWagixt5XEdrHMvog7zlXzfFvit" + + "kol/75RgZKZe1KiWAC0HuwWgqFnQHttwCrLBymbWJTpsKxv2aYoPWkv9dHHEmoV5CwG8J/BEJBi+A6K0" + + "XPnR43EF2rsuLIdE7sQ1Wf3S7Zx9msZZCCFF6BF6mSMsETERom6dxgToL+7IyWmVlZ/zIpS5IVTMHBd7" + + "KoW258VRU1pP3Pu1KkJBlyWwH1VPEQ2XCX8+dzZzLM2KBcmLJzUFNs19eQ9/RQarnuyvLnfq9L2Il0kB" + + "uKJrgvQ6TWT08a9KPtFqGjfSXAFxVzYpWuc3s/bZ0xAQT+uXw5NfjWo1l/NbRAiT5z+l4re6sqLQAcs/" + + "1rOUzL5clmEeo8hj8jNW98kEsr2yekgkJQlJ7RjkuuIrrwK+PQ48IJrmsvcS1icYHJGkIbe3TefQYM3E" + + "/jdrDxDYnco9jwH5Rk/oGoo1hsUmIYRJSnU9GCRfATyQiSsiw9fgXCKdlCkYc46UlKnFm5wAcBBwYCmY" + + "vr1mIoYcTTp9KRkggyXaxs1pGuGdwXCt+oUNunHdF6wD5vM8fYEiSBkwOUWSAoeDvFVlmRUZ/zVr1KXj" + + "XaklQrtLOcpr39svT+BHvd6t96qSnKBePnlR8cSQ7NhqjNDFGGQ99b1UuBfhJbaXtLTPbl7IOx7ymZH9" + + "UMhvIIBqSpcnTx5nYDHMkTzwoszxRrCZoaPploUtqJEmMS2z2TF3DM8jLt1gU8T/D5ydnIzMxMhwXjYK" + + "O3gP+mEsP6S8gozHK/9Mw0KMo/JlS2G583PStSp0xTTrq01zmssG0jubog+IBj/wMIIKJQYJKoZIhvcN" + + "AQcBoIIKFgSCChIwggoOMIIKCgYLKoZIhvcNAQwKAQKgggm5MIIJtTBfBgkqhkiG9w0BBQ0wUjAxBgkq" + + "hkiG9w0BBQwwJAQQ7gHUvW3HKZ9c2wfg7TJvNAICTiAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoE" + + "EKxfeDPuzII7mzmp/eyQH78EgglQKh8mDRRcW+NC+WrvSLTKw3iB4xk3qIVHIejFWTDRoAxxN9vezZ6I" + + "ffZKUTaL1yiIJrEsL7o4AKIwNCjJtnZGxETcb3jV7vO9jL6IiDEEhQWA6K+vEIG4m1HyrFnOu4AXvb3h" + + "jBlrlwIsEyDU++lRBtMCKKPlPLdIJY1bW/VsNkub3/QgSk3Yg75+BUeZTP8er5DDd3ME5+r8r9nmB8ez" + + "bq/A1aGbaURLzTXB26y8dAIwAQR8+Eix4WtKHms39Y0Nm85WMhqsKWQfrTSvpWIpX2A9sirLN5RD1jD4" + + "7lL0YkaM4iR4xM2RwB/Qb/FNgEI+aJS9oPfQ4tkmilImauY+Qf4m8rEnFFBbemsKfRVzYlVe3Zg2HTIU" + + "PaitMdKnahSbl+cNLMBlHK9tjo9/ucZVx7ctQCHQHky0OJ8OjR/33uYBCiJmexeN3dwIfweKV5t4pjWM" + + "xkZiJS94LL2C4EXdLRdJnmEC4zV5aKYRslpGCW3JjSFVSxxIfU17jRFdM4mgUnvl7lonvXDAD4F1XgLd" + + "5WLPusDrQRhJZU9zNGdphNuvrSoSbcZFLmNkFA7D0A3A2uQgu4HQ3dUfZwRerBP+Nv8QVy8cLGLP+gjq" + + "5UF5d/OjFgtDkVLE1eCpzED2LovRKtW3mM4HkIfCJ2I9rm7e/MWPOjBhK9CN7MI+sqVVzO0WFeZBY0mW" + + "vw414MczTGFTvjFr6RGtm2WYrPbmwDWxz/rI8/RPtaRtE51rZMpGa1cI/OLwnrPq1AmnHYAT2e0sBlRs" + + "0W6rjq8pYdbUB3/glE34cKKAy2uqY7/lQE4qcXKrC05OrC5uWbzfJYYTbmTUV47i3qT7WdqeRF3J5omF" + + "8W70eC3GtudXzcaI2nlhomDlfvI4y7x9deZu70RKSs6q0tJghzIX/W5njO0kVhSeqndsB4IUsdWZdgJy" + + "Kkxq2W34ijAHEdkjkz/8tLXg6W/Eoktli48NDD8l77HSghz2amJygsneoVhlvm2dgXV8iH+HPPapJws6" + + "B8adhFn01JXkztHcfZSaAbcqf6KoRqccJjdivjLtqm33rF4XHbx5lQX7s0Ar4tOQ/BHY6Ls5sMXeOKnL" + + "K/5dUyqjsskN6CfbTyA4kSybvnt6J1uiO6HarLD+WsA8UElDSeVZ0v6cH7xAX+QNEs7+1SMbnfqLGe5Z" + + "ObaFLfTdthF2Nch9bYX4pK5To9hhXIRYinECJz1fJ6g2GgtjMCejW73Tin3mej3PCT7gXrvIKJTW86yd" + + "8KlCDJEfq4BQ9msoYwWYbCzJrBhQ14aXNu1Tebo/cOXRwATGbby44Hqnerx5WrIiGx7GL7sT0Q4ytN49" + + "H4bu91wi1oKlHuO2h44ddh2hQof1So5OHAc6+Gg4MvIEXLeHbf+Bx0pxU8rpKJmbY5wCZBQGbvbhURth" + + "8PcnEBm0MvfzQZxLT/9Hu8MTTtYiIQFb0oMCkd7U3ISMNsfJ3ld8aTZUTS41UCjK0S3EAuQQuLgq5u5y" + + "vW/bKdFiIhA+IgEBj8TTyyuCkLlRQMw8CWg0yob/JR5RMwnEXVdsWlDF16pvpgP2dPSXY4KEB9WL/x11" + + "BrrqZ6pRLJfL2oS9NlpewdpcIy+0QdQkTnJiBX6A2S48+6sXMqXh/LEMy8TW8Vo9oE4f3FC96egux51O" + + "0aG6auWoR/KTPwB4yOIB9GZYG517zzAx1R5ejhS6+eWzywtSY8LcCn8mkEk1Qm2sm5JBKMnsLS2ZfZtz" + + "E9yYjOY+Lzd59PLJTNyqqE/Ok277mBR26A54zvSd7Kl0bc6j0c3Tzufn8VqCpYlno2Eyv1+CPF6MIowi" + + "X+2SOWLPSLEVLE096S/BWHX6eaNw/OtmKZD0y/hfk9fOE8gALSUjsjWz+7kJsQpsjR1et7D6xamq+mvI" + + "tZJUgeIYgwao+cKvPQpLA4BeCGbkHHpfwdMW5ps745bGkWur9Vx8/RotnTlQETFHia8fzDpJwjyug9Dw" + + "7iAjs6SmA0X3eLGJFbPCuJ7iNUuIBiBcD39XXIZgKWBcdHfuWOk5iuoOk/JiS5r52EXt+MtLgmoS5zUC" + + "QGV/1g7YqpUSduRTNf2e877CDbyO5E4TyzxQ6Hi2j4vK58uXj4tJseIFKJBJ7C6eSAurBKra3V0r/Nwq" + + "gNSDHBCuxp3gPx/IUg3HYhkudA2Fscf+zDaUbZoe113QVZSXwr0ZG0bpUkKTrU6/3UChjXZYimF4sgjn" + + "AzyafqI44zmSqIlkYxSmrWaWhu9P5Eqs4IklTHoyL8CoD+lRfvzaO+1Mihcmh1ApHRDiHBl4l2jI4orn" + + "vO16iI9i4gakrsdfXJFl99jvlP2vY0/X1h0ng9QNtYxa86VkP0jghsN2S4Fh7+wqzN7weT2ByJGV7ISB" + + "c+uSRyEBvD+6C8U2Vlc3EQtX/ZAiwV1Sx/3tyHU0ZQufTutAxAjnaAPK2v+OJUflpRPuf8/yZ/r8yyS6" + + "VWXQxFOkk6/qYqOv2exMoFTmsm5MJ8ejGb8bjyz6rcar/nDbCFsU5jIja5ddfchstxi3nfe2/M2M3prh" + + "zbTjI0p1aVNoj/6bRvMTvsUbdYOL4UeN+WOlxmHci09eoUXVaYKbOSAyHufb6CRLdKgdw5+mBcO3bTd+" + + "xLECK/tPtY5RpqZbpezMepXWgsXIrC8VooyLWnTXxkjmb3aeN3ZAai3h6GYMiDPR5Cjounw7b6OrkbTC" + + "NxQsNXTkjPz4zd8Tei8XfP1jKK6ch+T91iKQqC6YK3xWVZi26utFarm+q1/ZIw48rFnQPAtmAupNw1mr" + + "CFi1XF2kjtVB0ImFZE3NEJIXNuQb5pFZstlXM06pYRptZXIJWT9XEAq9GfBmA0Akapi3/bvoFCMcydGY" + + "a1b0f0kD4NNiqgnqCHt6tNCeIy/u4cXddMLV390QwX3R5pVkFug9iXThFUmg3ZgA0iOa5xfYMq98MkRy" + + "+Yq37BiJIe5nEpaDKFKaU+WE39tQLok0pnaLew0cxOjqGt9/3HBQ0LQDKzbM1JGlSgTmaVIw0cgoPtTj" + + "L19QU/asPzFvs85g8gVKK6DaS5D6k/63znFYPgxrS5aix8JLIeaxPbJnaji1s8sMwz+px2itiHFycs/L" + + "13Uc+HGQPI9FVdD7x0CWaqkj/1Qmi4QcZsEUE0DXAVyO/yWg0EZXnpUk3eIJ17uN1+Leo/mToZgEPeAq" + + "FgPJN80xPjAXBgkqhkiG9w0BCRQxCh4IAHQAZQBzAHQwIwYJKoZIhvcNAQkVMRYEFEmkAouDi5BReB7K" + + "MWQz6LqUu0YVMEEwMTANBglghkgBZQMEAgEFAAQg8h9ZAz5PXqFZRlYwFjaKVQBphQidZEDE9heqP+zb" + + "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