diff --git a/CHANGELOG.md b/CHANGELOG.md index e31ffdc..e3dfd0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +2.6.0 +Features: +- Improve management job performance by converting device group commits to template stack commits. +- Add validation to the certificate alias length, ensuring that alias length does not exceed 31 characters for Panorama and does not exceed 63 characters for Firewall. + 2.5.0 Features: - Add support for multiple Device Groups. You can now specify a comma-delimited list of Device Groups for your Certificate Store. i.e. `Group 1;Group 2;Group 3`. diff --git a/PaloAlto.IntegrationTests/BaseIntegrationTest.cs b/PaloAlto.IntegrationTests/BaseIntegrationTest.cs index e0ff863..369661c 100644 --- a/PaloAlto.IntegrationTests/BaseIntegrationTest.cs +++ b/PaloAlto.IntegrationTests/BaseIntegrationTest.cs @@ -16,16 +16,32 @@ using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; using Moq; using Newtonsoft.Json; using PaloAlto.IntegrationTests.Models; +using PaloAlto.Tests.Common.TestUtilities; using Xunit; +using Xunit.Abstractions; namespace PaloAlto.IntegrationTests; public abstract class BaseIntegrationTest { protected readonly string MockCertificatePassword = "sldfklsdfsldjfk"; + protected readonly ILogger Logger; + + public BaseIntegrationTest(ITestOutputHelper output) + { + var loggerFactory = LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(LogLevel.Trace) + .AddProvider(new XunitLoggerProvider(output)); + }); + + Logger = loggerFactory.CreateLogger(); + } protected void AssertJobSuccess(JobResult result, string context) { @@ -75,7 +91,7 @@ private JobResult ProcessManagementJob(ManagementJobConfiguration config) mgmtSecretResolver .Setup(m => m.Resolve(It.Is(s => s == config.ServerPassword))) .Returns(() => config.ServerPassword); - var mgmt = new Management(mgmtSecretResolver.Object); + var mgmt = new Management(mgmtSecretResolver.Object, Logger); return mgmt.ProcessJob(config); } diff --git a/PaloAlto.IntegrationTests/InventoryIntegrationTests.cs b/PaloAlto.IntegrationTests/InventoryIntegrationTests.cs index 0e8a2c9..7c73874 100644 --- a/PaloAlto.IntegrationTests/InventoryIntegrationTests.cs +++ b/PaloAlto.IntegrationTests/InventoryIntegrationTests.cs @@ -14,11 +14,17 @@ using PaloAlto.IntegrationTests.Models; using Xunit; +using Xunit.Abstractions; namespace PaloAlto.IntegrationTests; public class InventoryIntegrationTests : BaseIntegrationTest { + public InventoryIntegrationTests(ITestOutputHelper output): base(output) + { + + } + #region Firewall Tests // Test Case 6 repeats across Management + Inventory. Keeping number in place for parity. diff --git a/PaloAlto.IntegrationTests/ManagementIntegrationTests.cs b/PaloAlto.IntegrationTests/ManagementIntegrationTests.cs index 8051b1d..3bab1bf 100644 --- a/PaloAlto.IntegrationTests/ManagementIntegrationTests.cs +++ b/PaloAlto.IntegrationTests/ManagementIntegrationTests.cs @@ -15,11 +15,16 @@ using PaloAlto.IntegrationTests.Generators; using PaloAlto.IntegrationTests.Models; using Xunit; +using Xunit.Abstractions; namespace PaloAlto.IntegrationTests; public class ManagementIntegrationTests : BaseIntegrationTest { + public ManagementIntegrationTests(ITestOutputHelper output) : base(output) + { + } + #region Firewall Tests [Fact(DisplayName = "TC01: Firewall Enroll No Bindings")] @@ -550,7 +555,57 @@ public void TestCase16c_PanoramaEnroll_NoOverwrite_MultipleDeviceGroups_AddsToPa { StorePath = "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificatesTemplate']/config/shared", - DeviceGroup = "Group1;Group1", // This will be treated as separate device groups in the app code. + DeviceGroup = "Group1;Group1;Group1", // This will be treated as separate device groups in the app code. + Alias = alias, + Overwrite = false, + + CertificateContents = certificateContent, + CertificatePassword = MockCertificatePassword, + TemplateStack = "" + }; + props.AddPanoramaCredentials(); + + var result = ProcessManagementAddJob(props); + + AssertJobSuccess(result, "Add"); + } + + [Fact(DisplayName = "TC16d: Panorama No Overwrite with No Device Group But TemplateStack Defined Adds to Panorama and Firewalls")] + public void TestCase16d_PanoramaEnroll_NoOverwrite_NoDeviceGroups_WithTemplateStack_AddsToPanoramaAndFirewalls() + { + var alias = AliasGenerator.Generate(); + var certificateContent = PfxGenerator.GetBlobWithChain(alias, MockCertificatePassword); + + var props = new TestManagementJobConfigurationProperties() + { + StorePath = + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificatesTemplate']/config/shared", + DeviceGroup = "", + Alias = alias, + Overwrite = false, + + CertificateContents = certificateContent, + CertificatePassword = MockCertificatePassword, + TemplateStack = "CertificatesStack" + }; + props.AddPanoramaCredentials(); + + var result = ProcessManagementAddJob(props); + + AssertJobSuccess(result, "Add"); + } + + [Fact(DisplayName = "TC16e: Panorama No Overwrite with No Device Group And No TemplateStack Defined Adds to Panorama and Firewalls")] + public void TestCase16e_PanoramaEnroll_NoOverwrite_NoDeviceGroups_NoTemplateStack_AddsToPanoramaAndFirewalls() + { + var alias = AliasGenerator.Generate(); + var certificateContent = PfxGenerator.GetBlobWithChain(alias, MockCertificatePassword); + + var props = new TestManagementJobConfigurationProperties() + { + StorePath = + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificatesTemplate']/config/shared", + DeviceGroup = "", Alias = alias, Overwrite = false, @@ -896,6 +951,56 @@ public void TestCase25_PanoramaAdd_Vsys_BoundCert_WithOverride_ShouldSucceed() var updateResult = ProcessManagementAddJob(updateProps); AssertJobSuccess(updateResult, "Update"); } - + #endregion + + [Fact(DisplayName = "TC26a: Panorama Enroll when alias name is too long, returns error")] + public void TestCase26a_PanoramaEnroll_WhenAliasNameIsTooLong_ReturnsFailure() + { + var alias = new string('a', 32); + var certificateContent = PfxGenerator.GetBlobWithChain(alias, MockCertificatePassword); + + var props = new TestManagementJobConfigurationProperties() + { + StorePath = + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificatesTemplate']/config/shared", + DeviceGroup = "Group1", + Alias = alias, + Overwrite = false, + + CertificateContents = certificateContent, + CertificatePassword = MockCertificatePassword, + TemplateStack = "" + }; + props.AddPanoramaCredentials(); + + var result = ProcessManagementAddJob(props); + + AssertJobFailure(result, "Alias name is too long, it must not be more than 31 characters. Current length: 32"); + } + + [Fact(DisplayName = "TC26b: Firewall Enroll when alias name is too long, returns error")] + public void TestCase26b_FirewallEnroll_WhenAliasNameIsTooLong_ReturnsFailure() + { + var alias = new string('a', 64); + var certificateContent = PfxGenerator.GetBlobWithChain(alias, MockCertificatePassword); + + var props = new TestManagementJobConfigurationProperties() + { + StorePath = + "/config/shared", + DeviceGroup = "", + Alias = alias, + Overwrite = false, + + CertificateContents = certificateContent, + CertificatePassword = MockCertificatePassword, + TemplateStack = "" + }; + props.AddPanoramaCredentials(); + + var result = ProcessManagementAddJob(props); + + AssertJobFailure(result, "Alias name is too long, it must not be more than 63 characters. Current length: 64"); + } } diff --git a/PaloAlto.IntegrationTests/PaloAlto.IntegrationTests.csproj b/PaloAlto.IntegrationTests/PaloAlto.IntegrationTests.csproj index 7fbaf2f..c59c8ad 100644 --- a/PaloAlto.IntegrationTests/PaloAlto.IntegrationTests.csproj +++ b/PaloAlto.IntegrationTests/PaloAlto.IntegrationTests.csproj @@ -23,6 +23,7 @@ + @@ -32,4 +33,8 @@ + + + + diff --git a/PaloAlto.Tests.Common/PaloAlto.Tests.Common.csproj b/PaloAlto.Tests.Common/PaloAlto.Tests.Common.csproj new file mode 100644 index 0000000..b5d1b5d --- /dev/null +++ b/PaloAlto.Tests.Common/PaloAlto.Tests.Common.csproj @@ -0,0 +1,14 @@ + + + + net6.0 + enable + enable + + + + + + + + diff --git a/PaloAlto.Tests.Common/TestUtilities/XunitLogger.cs b/PaloAlto.Tests.Common/TestUtilities/XunitLogger.cs new file mode 100644 index 0000000..af64d3d --- /dev/null +++ b/PaloAlto.Tests.Common/TestUtilities/XunitLogger.cs @@ -0,0 +1,46 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// 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 Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace PaloAlto.Tests.Common.TestUtilities; + +public class XunitLogger : ILogger +{ + private readonly ITestOutputHelper _output; + + public XunitLogger(string _, ITestOutputHelper output) + { + _output = output; + } + + public IDisposable BeginScope(TState state) => null!; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + _output.WriteLine($"[{logLevel}] {formatter(state, exception)}"); + + if (exception != null) + { + _output.WriteLine(exception.ToString()); + } + } +} \ No newline at end of file diff --git a/PaloAlto.Tests.Common/TestUtilities/XunitLoggerProvider.cs b/PaloAlto.Tests.Common/TestUtilities/XunitLoggerProvider.cs new file mode 100644 index 0000000..8ac9a87 --- /dev/null +++ b/PaloAlto.Tests.Common/TestUtilities/XunitLoggerProvider.cs @@ -0,0 +1,35 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// 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 Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace PaloAlto.Tests.Common.TestUtilities; + +public class XunitLoggerProvider : ILoggerProvider +{ + private readonly ITestOutputHelper _output; + + public XunitLoggerProvider(ITestOutputHelper output) + { + _output = output; + } + + public ILogger CreateLogger(string categoryName) + { + return new XunitLogger(categoryName, _output); + } + + public void Dispose() { } +} diff --git a/PaloAlto.UnitTests/BaseUnitTest.cs b/PaloAlto.UnitTests/BaseUnitTest.cs new file mode 100644 index 0000000..f453ae2 --- /dev/null +++ b/PaloAlto.UnitTests/BaseUnitTest.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; +using PaloAlto.Tests.Common.TestUtilities; +using Xunit.Abstractions; + +namespace PaloAlto.UnitTests; + +public abstract class BaseUnitTest +{ + protected readonly ILogger Logger; + + public BaseUnitTest(ITestOutputHelper output) + { + var loggerFactory = LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(LogLevel.Trace) + .AddProvider(new XunitLoggerProvider(output)); + }); + + Logger = loggerFactory.CreateLogger(); + } +} diff --git a/PaloAlto.UnitTests/Helpers/PanoramaTemplateStackFinderTests.cs b/PaloAlto.UnitTests/Helpers/PanoramaTemplateStackFinderTests.cs new file mode 100644 index 0000000..1c1a059 --- /dev/null +++ b/PaloAlto.UnitTests/Helpers/PanoramaTemplateStackFinderTests.cs @@ -0,0 +1,325 @@ +using Keyfactor.Extensions.Orchestrator.PaloAlto.Client; +using Keyfactor.Extensions.Orchestrator.PaloAlto.Helpers; +using Keyfactor.Extensions.Orchestrator.PaloAlto.Models.Responses; +using Microsoft.Extensions.Logging; +using Moq; +using PaloAlto.Tests.Common.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace PaloAlto.UnitTests.Helpers +{ + public class PanoramaTemplateStackFinderTests : BaseUnitTest + { + private readonly Mock _paloAltoClient; + private readonly PanoramaTemplateStackFinder _sut; + + public PanoramaTemplateStackFinderTests(ITestOutputHelper output) : base(output) + { + _paloAltoClient = new Mock(); + _sut = new PanoramaTemplateStackFinder(_paloAltoClient.Object, Logger); + } + + [Fact] + public async Task GetTemplateStacks_WhenDeviceGroupsEmpty_WhenTemplateStackEmpty_ReturnsEmptyList() + { + var deviceGroups = new List(); + var template = "template-1"; + var templateStack = ""; + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTemplateStacks_WhenDeviceGroupsEmpty_WhenTemplateStackNull_ReturnsEmptyList() + { + var deviceGroups = new List(); + var template = "template-1"; + string? templateStack = null; + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTemplateStacks_WhenDeviceGroupsEmpty_WhenTemplateStackProvided_ReturnsTemplateStack() + { + var deviceGroups = new List(); + var template = "template-1"; + string templateStack = "test-stack"; + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Single(result); + + Assert.Equal("test-stack", result.First()); + } + + [Fact] + public async Task + GetTemplateStacks_WhenDeviceGroupsNotEmpty_WhenTemplateStackEmpty_ReturnsTemplateStackAssociatedWithDeviceGroups() + { + var deviceGroups = new List { "dg-1" }; + var template = "template-1"; + string templateStack = ""; + + + var remoteDeviceGroups = new List + { + new() + { + Name = "dg-1", + ReferenceTemplates = new List() { "template-1" } + }, + new() + { + Name = "dg-2", + ReferenceTemplates = new List() { "template-2" } + }, + }; + + var remoteTemplateStacks = new List + { + new() + { + Name = "template-stack-1", + Templates = new List() { "template-1", "template-2" } + }, + new() + { + Name = "template-stack-2", + Templates = new List() { "template-1", "template-3" } + }, + }; + + SetupDeviceGroupResponse(remoteDeviceGroups); + SetupTemplateStackResponse(remoteTemplateStacks); + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + Assert.Equal("template-stack-1", result[0]); + Assert.Equal("template-stack-2", result[1]); + } + + [Fact] + public async Task GetTemplateStacks_WhenDeviceGroupsNotEmpty_WhenTemplateStackNotEmpty_ReturnsUniqueSet() + { + var deviceGroups = new List { "dg-1" }; + var template = "template-1"; + string templateStack = "template-stack-1"; + + var remoteDeviceGroups = new List + { + new() + { + Name = "dg-1", + ReferenceTemplates = new List() { "template-1" } + }, + new() + { + Name = "dg-2", + ReferenceTemplates = new List() { "template-2" } + }, + }; + + var remoteTemplateStacks = new List + { + new() + { + Name = "template-stack-1", + Templates = new List() { "template-1", "template-2" } + }, + new() + { + Name = "template-stack-2", + Templates = new List() { "template-1", "template-3" } + }, + }; + + SetupDeviceGroupResponse(remoteDeviceGroups); + SetupTemplateStackResponse(remoteTemplateStacks); + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + Assert.Equal("template-stack-1", result[0]); + Assert.Equal("template-stack-2", result[1]); + } + + [Fact] + public async Task GetTemplateStacks_WhenDeviceGroupsContainMultipleReferenceTemplates_ReturnsUniqueSet() + { + var deviceGroups = new List { "dg-1" }; + var template = "template-1"; + string templateStack = ""; + + var remoteDeviceGroups = new List + { + new() + { + Name = "dg-1", + ReferenceTemplates = new List() { "template-1", "template-2", "template-3" } + }, + }; + + var remoteTemplateStacks = new List + { + new() + { + Name = "template-stack-1", + Templates = new List() { "template-1"} + }, + new() + { + Name = "template-stack-2", + Templates = new List() { "template-1" } + }, + }; + + SetupDeviceGroupResponse(remoteDeviceGroups); + SetupTemplateStackResponse(remoteTemplateStacks); + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + Assert.Equal("template-stack-1", result[0]); + Assert.Equal("template-stack-2", result[1]); + } + + [Fact] + public async Task GetTemplateStacks_WhenDeviceGroupNotFoundInRemoteSystem_DoesNotReturnTemplateStacks() + { + var deviceGroups = new List { "dg-1" }; + var template = "template-1"; + string templateStack = ""; + + var remoteDeviceGroups = new List + { + }; + + var remoteTemplateStacks = new List + { + new() + { + Name = "template-stack-1", + Templates = new List() { "template-1" } + } + }; + + SetupDeviceGroupResponse(remoteDeviceGroups); + SetupTemplateStackResponse(remoteTemplateStacks); + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTemplateStacks_WhenTemplateStackNotFoundInRemoteSystem_DoesNotReturnTemplateStacks() + { + var deviceGroups = new List { "dg-1" }; + var template = "template-1"; + string templateStack = ""; + + var remoteDeviceGroups = new List + { + new() + { + Name = "dg-1", + ReferenceTemplates = new List() { "template-1", "template-2", "template-3" } + }, + }; + + var remoteTemplateStacks = new List + { + }; + + SetupDeviceGroupResponse(remoteDeviceGroups); + SetupTemplateStackResponse(remoteTemplateStacks); + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetTemplateStacks_WhenTemplateNameDoesNotMatchDeviceGroup_DoesNotReturnTemplateStack() + { + var deviceGroups = new List { "dg-1" }; + var template = "template-1"; + string templateStack = ""; + + var remoteDeviceGroups = new List + { + new() + { + Name = "dg-1", + ReferenceTemplates = new List() { "template-2" } + }, + }; + + var remoteTemplateStacks = new List + { + new() + { + Name = "template-stack-1", + Templates = new List() { "template-1", "template-2" } + }, + new() + { + Name = "template-stack-2", + Templates = new List() { "template-1", "template-3" } + }, + }; + + SetupDeviceGroupResponse(remoteDeviceGroups); + SetupTemplateStackResponse(remoteTemplateStacks); + + var result = await _sut.GetTemplateStacks(deviceGroups, template, templateStack); + + Assert.NotNull(result); + Assert.Empty(result); + } + + private void SetupDeviceGroupResponse(List deviceGroups) + { + _paloAltoClient + .Setup(p => p.GetDeviceGroups()) + .ReturnsAsync(new DeviceGroupsResponse + { + Result = new DeviceGroupsResult + { + DeviceGroups = deviceGroups, + } + }); + } + + private void SetupTemplateStackResponse(List templateStacks) + { + _paloAltoClient + .Setup(p => p.GetTemplateStacks()) + .ReturnsAsync(new TemplateStacksResponse + { + Result = new TemplateStackResult + { + TemplateStacks = templateStacks, + } + }); + } + } +} diff --git a/PaloAlto.UnitTests/PaloAlto.UnitTests.csproj b/PaloAlto.UnitTests/PaloAlto.UnitTests.csproj index 347588a..0009739 100644 --- a/PaloAlto.UnitTests/PaloAlto.UnitTests.csproj +++ b/PaloAlto.UnitTests/PaloAlto.UnitTests.csproj @@ -23,6 +23,7 @@ + diff --git a/PaloAlto.UnitTests/ValidatorsTests.cs b/PaloAlto.UnitTests/ValidatorsTests.cs index ef0b5c8..26b7344 100644 --- a/PaloAlto.UnitTests/ValidatorsTests.cs +++ b/PaloAlto.UnitTests/ValidatorsTests.cs @@ -19,23 +19,24 @@ using Keyfactor.Orchestrators.Common.Enums; using Moq; using Xunit; + namespace PaloAlto.UnitTests; public class ValidatorsTests { private readonly Mock _paloAltoClientMock; private readonly IPaloAltoClient _paloAltoClient; - + public ValidatorsTests() { _paloAltoClientMock = new Mock(); _paloAltoClient = _paloAltoClientMock.Object; } - + #region BuildPaloError - + [Fact] - public async Task BuildPaloError_WithNoLineMsg_ReturnsEmptyString() + public void BuildPaloError_WithNoLineMsg_ReturnsEmptyString() { var errorResponse = new ErrorSuccessResponse() { @@ -44,13 +45,13 @@ public async Task BuildPaloError_WithNoLineMsg_ReturnsEmptyString() Line = new List() } }; - + var result = Validators.BuildPaloError(errorResponse); Assert.Equal("", result); } - + [Fact] - public async Task BuildPaloError_WithSingleLineMsg_ReturnsMessageString() + public void BuildPaloError_WithSingleLineMsg_ReturnsMessageString() { var errorResponse = new ErrorSuccessResponse() { @@ -62,13 +63,13 @@ public async Task BuildPaloError_WithSingleLineMsg_ReturnsMessageString() } } }; - + var result = Validators.BuildPaloError(errorResponse); Assert.Equal("Hello World!", result); } - + [Fact] - public async Task BuildPaloError_WithMultipleLineMsg_ReturnsConcatenatedString() + public void BuildPaloError_WithMultipleLineMsg_ReturnsConcatenatedString() { var errorResponse = new ErrorSuccessResponse() { @@ -81,155 +82,159 @@ public async Task BuildPaloError_WithMultipleLineMsg_ReturnsConcatenatedString() } } }; - + var result = Validators.BuildPaloError(errorResponse); Assert.Equal("Hello World!, Fizz Buzz!", result); } - + #endregion #region IsValidPanoramaFormat [Fact] - public async Task IsValidPanoramaFormat_WithNoMatch_ReturnsFalse() + public void IsValidPanoramaFormat_WithNoMatch_ReturnsFalse() { var input = "/home/etc"; var result = Validators.IsValidPanoramaFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidPanoramaFormat_WithDeviceEntryAndNoCertificateTemplate_ReturnsFalse() + public void IsValidPanoramaFormat_WithDeviceEntryAndNoCertificateTemplate_ReturnsFalse() { var input = "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='']/config/shared"; var result = Validators.IsValidPanoramaFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidPanoramaFormat_WithNoDeviceEntryAndCertificateTemplate_ReturnsFalse() + public void IsValidPanoramaFormat_WithNoDeviceEntryAndCertificateTemplate_ReturnsFalse() { var input = "/config/devices/entry[@name='']/template/entry[@name='CertificatesTemplate']/config/shared"; var result = Validators.IsValidPanoramaFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidPanoramaFormat_WithNonLocalhostDeviceEntryAndCertificateTemplate_ReturnsTrue() + public void IsValidPanoramaFormat_WithNonLocalhostDeviceEntryAndCertificateTemplate_ReturnsTrue() { - var input = "/config/devices/entry[@name='somethingrandom']/template/entry[@name='CertificatesTemplate']/config/shared"; + var input = + "/config/devices/entry[@name='somethingrandom']/template/entry[@name='CertificatesTemplate']/config/shared"; var result = Validators.IsValidPanoramaFormat(input); Assert.True(result); } - + [Fact] - public async Task IsValidPanoramaFormat_WithDeviceEntryAndCertificateTemplate_ReturnsTrue() + public void IsValidPanoramaFormat_WithDeviceEntryAndCertificateTemplate_ReturnsTrue() { - var input = "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificatesTemplate']/config/shared"; + var input = + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificatesTemplate']/config/shared"; var result = Validators.IsValidPanoramaFormat(input); Assert.True(result); } - + #endregion - + #region IsValidFirewallVsysFormat [Fact] - public async Task IsValidFirewallVsysFormat_WithNoMatch_ReturnsFalse() + public void IsValidFirewallVsysFormat_WithNoMatch_ReturnsFalse() { var input = "/home/etc"; var result = Validators.IsValidFirewallVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidFirewallVsysFormat_WithDeviceEntryAndNoVsys_ReturnsFalse() + public void IsValidFirewallVsysFormat_WithDeviceEntryAndNoVsys_ReturnsFalse() { var input = "/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='']"; var result = Validators.IsValidFirewallVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidFirewallVsysFormat_WithNoDeviceEntryAndVsys_ReturnsFalse() + public void IsValidFirewallVsysFormat_WithNoDeviceEntryAndVsys_ReturnsFalse() { var input = "/config/devices/entry[@name='']/vsys/entry[@name='System']"; var result = Validators.IsValidFirewallVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidFirewallVsysFormat_WithNonLocalhostDeviceEntryAndCertificateTemplate_ReturnsFalse() + public void IsValidFirewallVsysFormat_WithNonLocalhostDeviceEntryAndCertificateTemplate_ReturnsFalse() { var input = "/config/devices/entry[@name='somethingrandom']/vsys/entry[@name='System']"; var result = Validators.IsValidFirewallVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidFirewallVsysFormat_WithDeviceEntryAndCertificateTemplate_ReturnsTrue() + public void IsValidFirewallVsysFormat_WithDeviceEntryAndCertificateTemplate_ReturnsTrue() { var input = "/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='System']"; var result = Validators.IsValidFirewallVsysFormat(input); Assert.True(result); } - + #endregion - + #region IsValidPanoramaVsysFormat [Fact] - public async Task IsValidPanoramaVsysFormat_WithNoMatch_ReturnsFalse() + public void IsValidPanoramaVsysFormat_WithNoMatch_ReturnsFalse() { var input = "/home/etc"; var result = Validators.IsValidPanoramaVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidPanoramaVsysFormat_WithTemplateEntryAndNoVsysEntry_ReturnsFalse() + public void IsValidPanoramaVsysFormat_WithTemplateEntryAndNoVsysEntry_ReturnsFalse() { - var input = "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='']"; + var input = + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='']"; var result = Validators.IsValidPanoramaVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidPanoramaVsysFormat_WithNoTemplateEntryAndVsysEntry_ReturnsFalse() + public void IsValidPanoramaVsysFormat_WithNoTemplateEntryAndVsysEntry_ReturnsFalse() { var input = "/config/devices/entry/template/entry[@name='']/config/devices/entry/vsys/entry[@name='System']"; var result = Validators.IsValidPanoramaVsysFormat(input); Assert.False(result); } - + [Fact] - public async Task IsValidPanoramaVsysFormat_WithTemplateEntryAndVsysEntry_ReturnsTrue() + public void IsValidPanoramaVsysFormat_WithTemplateEntryAndVsysEntry_ReturnsTrue() { - var input = "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']"; + var input = + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']"; var result = Validators.IsValidPanoramaVsysFormat(input); Assert.True(result); } - + #endregion #region ValidateStoreProperties [Fact] - public async Task + public void ValidateStoreProperties_WhenStorePathIsNotConfigPanoramaOrConfigShared_StorePathIsNotValidFormat_ReturnsError() { var properties = new JobProperties(); @@ -237,21 +242,23 @@ public async Task var jobHistoryId = (long)1234; var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); - + Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); Assert.Equal(1234, result.JobHistoryId); Assert.Equal("The store setup is not valid. Path is invalid " + "needs to be /config/panorama, /config/shared or in format of " + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='TemplateName']/config/shared " + - "or /config/devices/entry/template/entry[@name='TemplateName']/config/devices/entry/vsys/entry[@name='VsysName']", result.FailureMessage); + "or /config/devices/entry/template/entry[@name='TemplateName']/config/devices/entry/vsys/entry[@name='VsysName']", + result.FailureMessage); } - + [Theory] [InlineData("/config/panorama")] [InlineData("/config/shared")] - public async Task - ValidateStoreProperties_WhenStorePathDoesNotContainTemplate_StorePropertiesContainsDeviceGroup_ReturnsError(string storePath) + public void + ValidateStoreProperties_WhenStorePathDoesNotContainTemplate_StorePropertiesContainsDeviceGroup_ReturnsError( + string storePath) { var properties = new JobProperties() { @@ -260,19 +267,22 @@ public async Task var jobHistoryId = (long)1234; var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); - + Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); Assert.Equal(1234, result.JobHistoryId); - Assert.Equal("The store setup is not valid. You do not need a device group with a Palo Alto Firewall. It is only required for Panorama.", result.FailureMessage); + Assert.Equal( + "The store setup is not valid. You do not need a device group with a Palo Alto Firewall. It is only required for Panorama.", + result.FailureMessage); } - + [Theory] [InlineData("/config/panorama")] [InlineData("/config/shared")] [InlineData("/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='System']")] - public async Task - ValidateStoreProperties_WhenStorePathDoesNotContainTemplate_StorePropertiesContainsTemplateStack_ReturnsError(string storePath) + public void + ValidateStoreProperties_WhenStorePathDoesNotContainTemplate_StorePropertiesContainsTemplateStack_ReturnsError( + string storePath) { var properties = new JobProperties() { @@ -281,19 +291,22 @@ public async Task var jobHistoryId = (long)1234; var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); - + Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); Assert.Equal(1234, result.JobHistoryId); - Assert.Equal("The store setup is not valid. You do not need a Template Stack with a Palo Alto Firewall. It is only required for Panorama.", result.FailureMessage); + Assert.Equal( + "The store setup is not valid. You do not need a Template Stack with a Palo Alto Firewall. It is only required for Panorama.", + result.FailureMessage); } - + [Theory] [InlineData("/config/panorama")] [InlineData("/config/shared")] [InlineData("/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='System']")] - public async Task - ValidateStoreProperties_WhenStorePathDoesNotContainTemplate_StorePropertiesAreValid_ReturnsTrue(string storePath) + public void + ValidateStoreProperties_WhenStorePathDoesNotContainTemplate_StorePropertiesAreValid_ReturnsTrue( + string storePath) { var properties = new JobProperties(); var jobHistoryId = (long)1234; @@ -302,13 +315,15 @@ public async Task Assert.True(valid); Assert.Equal(OrchestratorJobStatusJobResult.Unknown, result.Result); // a new JobResult object is instantiated. } - + #region DeviceGroup Check - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void ValidateStoreProperties_WhenStorePathContainsTemplate_DeviceGroupIsNotFound_ReturnsError(string storePath) { var properties = new JobProperties() @@ -316,7 +331,7 @@ public async Task DeviceGroup = "Group1" }; var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetDeviceGroupList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -347,13 +362,17 @@ public async Task var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); - Assert.Equal("The store setup is not valid. Could not find Device Group(s) Group1 In Panorama. Valid Device Groups are: Group2", result.FailureMessage); + Assert.Equal( + "The store setup is not valid. Could not find Device Group(s) Group1 In Panorama. Valid Device Groups are: Group2", + result.FailureMessage); } - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void ValidateStoreProperties_WhenStorePathContainsTemplate_DeviceGroupIsFound_ReturnsTrue(string storePath) { var properties = new JobProperties() @@ -361,7 +380,7 @@ public async Task DeviceGroup = "Group1" }; var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetDeviceGroupList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -397,17 +416,20 @@ public async Task #region Multiple Device Groups [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task - ValidateStoreProperties_WhenStorePathContainsTemplate_MultipleDeviceGroups_OneDeviceGroupNotFound_ReturnsError(string storePath) + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void + ValidateStoreProperties_WhenStorePathContainsTemplate_MultipleDeviceGroups_OneDeviceGroupNotFound_ReturnsError( + string storePath) { var properties = new JobProperties() { DeviceGroup = "Group1;Group2;Group3;Group4" }; var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetDeviceGroupList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -442,21 +464,26 @@ public async Task var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); - Assert.Equal("The store setup is not valid. Could not find Device Group(s) Group3, Group4 In Panorama. Valid Device Groups are: Group1, Group2", result.FailureMessage); + Assert.Equal( + "The store setup is not valid. Could not find Device Group(s) Group3, Group4 In Panorama. Valid Device Groups are: Group1, Group2", + result.FailureMessage); } - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task - ValidateStoreProperties_WhenStorePathContainsTemplate_MultipleDeviceGroups_AllDeviceGroupFound_ReturnsTrue(string storePath) + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void + ValidateStoreProperties_WhenStorePathContainsTemplate_MultipleDeviceGroups_AllDeviceGroupFound_ReturnsTrue( + string storePath) { var properties = new JobProperties() { DeviceGroup = "Group1;Group2;Group3" }; var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetDeviceGroupList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -502,15 +529,17 @@ public async Task } #endregion - + #endregion - + #region TemplateStack Check - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void ValidateStoreProperties_WhenStorePathContainsTemplate_TemplateStackIsNotFound_ReturnsError(string storePath) { var properties = new JobProperties() @@ -518,7 +547,7 @@ public async Task TemplateStack = "Stack1" }; var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetTemplateStackList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -549,13 +578,17 @@ public async Task var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); - Assert.Equal("The store setup is not valid. Could not find your Template Stacks In Panorama. Valid Template Stacks are Stack2", result.FailureMessage); + Assert.Equal( + "The store setup is not valid. Could not find your Template Stacks In Panorama. Valid Template Stacks are Stack2", + result.FailureMessage); } - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void ValidateStoreProperties_WhenStorePathContainsTemplate_TemplateStackIsFound_ReturnsValid(string storePath) { var properties = new JobProperties() @@ -563,7 +596,7 @@ public async Task TemplateStack = "Stack1" }; var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetTemplateStackList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -595,20 +628,22 @@ public async Task Assert.True(valid); Assert.Equal(OrchestratorJobStatusJobResult.Unknown, result.Result); // Instantiates new JobResult object } - + #endregion - + #region TemplateList Check - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void ValidateStoreProperties_WhenStorePathContainsTemplate_TemplateListIsNotFound_ReturnsError(string storePath) { var properties = new JobProperties(); var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetTemplateStackList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -632,18 +667,22 @@ public async Task var (valid, result) = Validators.ValidateStoreProperties(properties, storePath, _paloAltoClient, jobHistoryId); Assert.False(valid); Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.Result); - Assert.Equal("The store setup is not valid. Could not find your Template In Panorama. Valid Templates are SomethingRandom", result.FailureMessage); + Assert.Equal( + "The store setup is not valid. Could not find your Template In Panorama. Valid Templates are SomethingRandom", + result.FailureMessage); } - + [Theory] - [InlineData("/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] - [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] - public async Task + [InlineData( + "/config/devices/entry[@name='localhost.localdomain']/template/entry[@name='CertificateStack']/config/shared")] + [InlineData( + "/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + public void ValidateStoreProperties_WhenStorePathContainsTemplate_TemplateListIsFound_ReturnsValid(string storePath) { var properties = new JobProperties(); var jobHistoryId = (long)1234; - + _paloAltoClientMock.Setup(p => p.GetTemplateStackList()).ReturnsAsync(new NamedListResponse() { Result = new NamedListResult() @@ -668,35 +707,104 @@ public async Task Assert.True(valid); Assert.Equal(OrchestratorJobStatusJobResult.Unknown, result.Result); // Instantiates new JobResult object } - + #endregion + + #endregion + + #region ValidateCertificateAlias + + [Theory] + [InlineData(null)] + [InlineData("")] + public void ValidateCertificateAlias_WhenAliasIsNull_ReturnsFailure(string alias) + { + string storePath = ""; // store path does not matter at this point + + var result = Validators.ValidateCertificateAlias(storePath, alias); + Assert.False(result.valid); + Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.result.Result); + Assert.Equal("Certificate alias must not be empty", result.result.FailureMessage); + } + + [Theory] + [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + [InlineData("/config/devices/entry[@name='somethingrandom']/template/entry[@name='CertificatesTemplate']/config/shared")] + [InlineData("/config/panorama")] + public void ValidateCertificateAlias_WhenPanorama_AliasTooLong_ReturnsFailure(string storePath) + { + string alias = new string('a', 32); + + var result = Validators.ValidateCertificateAlias(storePath, alias); + Assert.False(result.valid); + Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.result.Result); + Assert.Equal("Alias name is too long, it must not be more than 31 characters. Current length: 32", result.result.FailureMessage); + } + + [Theory] + [InlineData("/config/shared")] + [InlineData("/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='vsys1']")] + public void ValidateCertificateAlias_WhenFirewall_AliasTooLong_ReturnsFailure(string storePath) + { + string alias = new string('a', 64); + + var result = Validators.ValidateCertificateAlias(storePath, alias); + Assert.False(result.valid); + Assert.Equal(OrchestratorJobStatusJobResult.Failure, result.result.Result); + Assert.Equal("Alias name is too long, it must not be more than 63 characters. Current length: 64", result.result.FailureMessage); + } + + [Theory] + [InlineData("/config/devices/entry/template/entry[@name='CertificateStack']/config/devices/entry/vsys/entry[@name='System']")] + [InlineData("/config/devices/entry[@name='somethingrandom']/template/entry[@name='CertificatesTemplate']/config/shared")] + [InlineData("/config/panorama")] + public void ValidateCertificateAlias_WhenPanorama_AliasCorrectLength_ReturnsSuccess(string storePath) + { + string alias = new string('a', 31); + + var result = Validators.ValidateCertificateAlias(storePath, alias); + Assert.True(result.valid); + Assert.Null(result.result); + } + [Theory] + [InlineData("/config/shared")] + [InlineData("/config/devices/entry[@name='localhost.localdomain']/vsys/entry[@name='vsys1']")] + public void ValidateCertificateAlias_WhenFirewall_AliasCorrectLength_ReturnsSuccess(string storePath) + { + string alias = new string('a', 63); + + var result = Validators.ValidateCertificateAlias(storePath, alias); + Assert.True(result.valid); + Assert.Null(result.result); + } + #endregion #region GetDeviceGroups - + [Fact] - public async Task GetDeviceGroups_WhenDeviceGroupsInputIsNull_ReturnsEmptyList() + public void GetDeviceGroups_WhenDeviceGroupsInputIsNull_ReturnsEmptyList() { - string deviceGroupsProperty = null; + string? deviceGroupsProperty = null; var result = Validators.GetDeviceGroups(deviceGroupsProperty); - + Assert.Empty(result); } [Fact] - public async Task GetDeviceGroups_WhenDeviceGroupsInputIsEmpty_ReturnsEmptyList() + public void GetDeviceGroups_WhenDeviceGroupsInputIsEmpty_ReturnsEmptyList() { string deviceGroupsProperty = ""; var result = Validators.GetDeviceGroups(deviceGroupsProperty); - + Assert.Empty(result); } - + [Fact] - public async Task GetDeviceGroups_WhenDeviceGroupsInputHasSingleEntry_ReturnsListWithEntry() + public void GetDeviceGroups_WhenDeviceGroupsInputHasSingleEntry_ReturnsListWithEntry() { string deviceGroupsProperty = "Group 1"; @@ -705,9 +813,9 @@ public async Task GetDeviceGroups_WhenDeviceGroupsInputHasSingleEntry_ReturnsLis Assert.Equal(1, result.Count); Assert.Equal("Group 1", result.First()); } - + [Fact] - public async Task GetDeviceGroups_WhenDeviceGroupsInputHasMultipleSemicolonDelimitedEntries_ReturnsListWithEntries() + public void GetDeviceGroups_WhenDeviceGroupsInputHasMultipleSemicolonDelimitedEntries_ReturnsListWithEntries() { string deviceGroupsProperty = "Group 1;Group 2;Group3;Random_Group-123.456"; @@ -719,12 +827,13 @@ public async Task GetDeviceGroups_WhenDeviceGroupsInputHasMultipleSemicolonDelim Assert.Equal("Group3", result.ElementAt(2)); Assert.Equal("Random_Group-123.456", result.ElementAt(3)); } - + [Fact] - public async Task GetDeviceGroups_WhenDeviceGroupsInputHasMultipleSemicolonDelimitedEntries_WithSpaces_ReturnsListWithEntries() + public void + GetDeviceGroups_WhenDeviceGroupsInputHasMultipleSemicolonDelimitedEntries_WithSpaces_ReturnsListWithEntries() { string deviceGroupsProperty = "Group 1 ;Group 2; Group 3"; - + var result = Validators.GetDeviceGroups(deviceGroupsProperty); Assert.Equal(3, result.Count); diff --git a/PaloAlto.sln b/PaloAlto.sln index f2de4a9..7360811 100644 --- a/PaloAlto.sln +++ b/PaloAlto.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaloAlto.IntegrationTests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaloAlto.UnitTests", "PaloAlto.UnitTests\PaloAlto.UnitTests.csproj", "{30B1C65A-AFBF-42AC-BB6B-4C755D0B08F3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PaloAlto.Tests.Common", "PaloAlto.Tests.Common\PaloAlto.Tests.Common.csproj", "{86AA8249-1299-415D-BC42-A829EE4B8E4F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +48,10 @@ Global {30B1C65A-AFBF-42AC-BB6B-4C755D0B08F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {30B1C65A-AFBF-42AC-BB6B-4C755D0B08F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {30B1C65A-AFBF-42AC-BB6B-4C755D0B08F3}.Release|Any CPU.Build.0 = Release|Any CPU + {86AA8249-1299-415D-BC42-A829EE4B8E4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86AA8249-1299-415D-BC42-A829EE4B8E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86AA8249-1299-415D-BC42-A829EE4B8E4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86AA8249-1299-415D-BC42-A829EE4B8E4F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -54,6 +60,7 @@ Global {6302034E-DF8C-4B65-AC36-CED24C068999} = {1A6C93E7-24FD-47FD-883D-EDABF5CEE4C6} {94D8FD54-A92A-4453-991F-3EFFF1493E07} = {F618499B-AFF5-489D-AE01-6B7AA75CDBCD} {30B1C65A-AFBF-42AC-BB6B-4C755D0B08F3} = {F618499B-AFF5-489D-AE01-6B7AA75CDBCD} + {86AA8249-1299-415D-BC42-A829EE4B8E4F} = {F618499B-AFF5-489D-AE01-6B7AA75CDBCD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E0FA12DA-6B82-4E64-928A-BB9965E636C1} diff --git a/PaloAlto/Client/IPaloAltoClient.cs b/PaloAlto/Client/IPaloAltoClient.cs index 81a4abd..574923b 100644 --- a/PaloAlto/Client/IPaloAltoClient.cs +++ b/PaloAlto/Client/IPaloAltoClient.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Collections.Generic; using System.Threading.Tasks; using Keyfactor.Extensions.Orchestrator.PaloAlto.Models.Responses; @@ -24,7 +25,7 @@ public interface IPaloAltoClient Task GetDeviceGroupList(); Task GetTemplateStackList(); Task GetCommitResponse(); - Task GetCommitAllResponse(string deviceGroup,string storePath,string templateStack); + Task GetCommitAllResponse(IReadOnlyCollection deviceGroups,string storePath,string templateStack); Task GetTrustedRootList(); Task GetCertificateByName(string name); Task SubmitDeleteCertificate(string name, string storePath); @@ -32,6 +33,8 @@ public interface IPaloAltoClient Task SubmitSetTrustedRoot(string name, string storePath); Task SetPanoramaTarget(string storePath); Task GetJobStatus(string jobId); + Task GetDeviceGroups(); + Task GetTemplateStacks(); Task ImportCertificate(string name, string passPhrase, byte[] bytes, string includeKey, string category, string storePath); diff --git a/PaloAlto/Client/PaloAltoClient.cs b/PaloAlto/Client/PaloAltoClient.cs index 16ff222..8d1a733 100644 --- a/PaloAlto/Client/PaloAltoClient.cs +++ b/PaloAlto/Client/PaloAltoClient.cs @@ -13,12 +13,13 @@ // limitations under the License. using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Text.RegularExpressions; -using System.Threading; using System.Threading.Tasks; using System.Xml; using System.Xml.Serialization; @@ -128,7 +129,6 @@ public async Task GetCommitResponse() { var uri = $"/api/?&type=commit&action=partial&cmd={ServerUserName}&key={ApiKey}"; - var response = await GetXmlResponseAsync(await HttpClient.GetAsync(uri)); return response; } @@ -139,7 +139,7 @@ public async Task GetCommitResponse() } } - public async Task GetCommitAllResponse(string deviceGroup,string storePath,string templateStack) + public async Task GetCommitAllResponse(IReadOnlyCollection deviceGroups, string storePath,string templateStack) { try { @@ -147,21 +147,18 @@ public async Task GetCommitAllResponse(string deviceGroup,string //var uri = $"/api/?&type=commit&action=all&cmd={ServerUserName}&key={ApiKey}"; var uri = string.Empty; CommitResponse response = new (); + var jobPoller = new PanoramaJobPoller(this); - if (!string.IsNullOrEmpty(deviceGroup)) + var templateStackFinder = new PanoramaTemplateStackFinder(this, _logger); + + var template = GetTemplateName(storePath); + + // Committing to device groups directly is *very* slow. Instead, we need to find the template stacks associated with the device groups. + var templateStacks = await templateStackFinder.GetTemplateStacks(deviceGroups, template, templateStack); + + // If there are no device groups present, we need to commit to the template directly + if (!deviceGroups.Any()) { - foreach (var group in Validators.GetDeviceGroups(deviceGroup)) - { - _logger.LogTrace($"Committing changes to device group {group}"); - uri = $"/api/?&type=commit&action=all&cmd=&key={ApiKey}"; - response = await GetXmlResponseAsync(await HttpClient.GetAsync(uri)); - - await HandleCommitResponse(response, jobPoller); - } - } - else - { - var template = GetTemplateName(storePath); _logger.LogTrace($"Committing changes to template {template}"); uri =$"/api/?&type=commit&action=all&cmd=&key={ApiKey}"; @@ -170,14 +167,16 @@ public async Task GetCommitAllResponse(string deviceGroup,string await HandleCommitResponse(response, jobPoller); } - if (!string.IsNullOrEmpty(templateStack)) + // Loop through all template stacks (even those associated with device groups) and commit to those stacks + foreach (var stack in templateStacks) { - _logger.LogTrace($"Committing changes to template stack {templateStack}"); - uri = $"/api/?&type=commit&action=all&cmd={templateStack}&key={ApiKey}"; + _logger.LogTrace($"Committing changes to template stack {stack}"); + uri = $"/api/?&type=commit&action=all&cmd={stack}&key={ApiKey}"; response = await GetXmlResponseAsync(await HttpClient.GetAsync(uri)); await HandleCommitResponse(response, jobPoller); } + return response; } catch (Exception e) @@ -187,6 +186,35 @@ public async Task GetCommitAllResponse(string deviceGroup,string } } + public async Task GetDeviceGroups() + { + _logger.MethodEntry(); + + var url = + $"/api/?type=config&action=get&xpath=/config/devices/entry[@name='localhost.localdomain']/device-group&key={ApiKey}"; + var deviceGroups = await GetXmlResponseAsync(await HttpClient.GetAsync(url)); + + if (deviceGroups.Result.Count != deviceGroups.Result.TotalCount) + { + _logger.LogWarning($"Panorama API returned a different number of device groups than expected total. Retrieved {deviceGroups.Result.Count} but expected {deviceGroups.Result.TotalCount}. Results may be truncated."); + } + + _logger.MethodExit(); + return deviceGroups; + } + + public async Task GetTemplateStacks() + { + _logger.MethodEntry(); + + var url = + $"/api/?type=config&action=get&xpath=/config/devices/entry[@name='localhost.localdomain']/template-stack&key={ApiKey}"; + var stacks = await GetXmlResponseAsync(await HttpClient.GetAsync(url)); + + _logger.MethodExit(); + return stacks; + } + private async Task HandleCommitResponse(CommitResponse response, PanoramaJobPoller jobPoller) { if (response.Status != "success") @@ -200,7 +228,7 @@ private async Task HandleCommitResponse(CommitResponse response, PanoramaJobPoll if (response.Result?.HasJobId ?? false) { _logger.LogTrace($"Waiting to make sure commit was successful. Job ID: {response.Result?.JobId}..."); - var result = await jobPoller.WaitForJobCompletion(response.Result.JobId); + var result = await jobPoller.WaitForJobCompletion(response.Result!.JobId); if (result.Result == OrchestratorJobStatusJobResult.Failure) { throw new Exception(result.FailureMessage); @@ -216,7 +244,6 @@ public async Task GetJobStatus(string jobId) return await GetXmlResponseAsync(await HttpClient.GetAsync(url)); } - public string GetTemplateName(string storePath) { string pattern = @"\/template\/entry\[@name='([^']+)'\]"; diff --git a/PaloAlto/Helpers/PanoramaJobPoller.cs b/PaloAlto/Helpers/PanoramaJobPoller.cs index bc98867..adc0828 100644 --- a/PaloAlto/Helpers/PanoramaJobPoller.cs +++ b/PaloAlto/Helpers/PanoramaJobPoller.cs @@ -72,7 +72,7 @@ public async Task WaitForJobCompletion(string jobId, CancellationToke } throw new InvalidOperationException( - $"Job {jobId} completed but failed. Result {jobStatus.Result}"); + $"Job {jobId} completed but failed. Result {jobStatus.Result}. Details: {string.Join(", ", jobStatus.Details?.Line.Select(p => p) ?? new List())}"); case JobStatus.Failed: throw new InvalidOperationException( diff --git a/PaloAlto/Helpers/PanoramaTemplateStackFinder.cs b/PaloAlto/Helpers/PanoramaTemplateStackFinder.cs new file mode 100644 index 0000000..92faf5c --- /dev/null +++ b/PaloAlto/Helpers/PanoramaTemplateStackFinder.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Keyfactor.Extensions.Orchestrator.PaloAlto.Client; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.PaloAlto.Helpers; + +public class PanoramaTemplateStackFinder +{ + private readonly IPaloAltoClient _client; + private readonly ILogger _logger; + + public PanoramaTemplateStackFinder(IPaloAltoClient client, ILogger logger) + { + _client = client; + _logger = logger; + } + + /// + /// Returns a list of unique template stacks associated with device groups + /// and a template, including the provided template stack if specified. + /// + /// A collection of device groups to push certificates to. + /// The template to filter device group associations to. + /// A template stack to also push configuration updates to, can be independent of device group match. + /// + public async Task> GetTemplateStacks(IReadOnlyCollection deviceGroups, string template, string templateStack) + { + _logger.MethodEntry(); + _logger.LogDebug($"Finding template stacks for device groups: {string.Join(", ", deviceGroups)} with provided template stack: '{templateStack}'"); + + var result = new List(); + if (!string.IsNullOrWhiteSpace(templateStack)) + { + _logger.LogDebug($"Adding template stack '{templateStack}' to result as it was provided."); + result.Add(templateStack); + } + + if (!deviceGroups.Any()) + { + _logger.LogTrace($"No device groups found. Returning template stacks: {string.Join(", ", result)}"); + _logger.MethodExit(); + return result; + } + + var deviceGroupsList = await _client.GetDeviceGroups(); + var templates = new List(); // A lookup reference for templates associated with device groups + + foreach (var dg in deviceGroups.Distinct()) + { + var lookup = deviceGroupsList.Result.DeviceGroups.FirstOrDefault(p => p.Name == dg); + if (lookup == null) + { + _logger.LogWarning($"Device group '{dg}' not found in Panorama."); + continue; + } + + // Filter referenced templates to only include the specified template + // This reduces the chance of adding unrelated template stacks + var referencedTemplates = lookup.ReferenceTemplates.Where(p => p == template); + + templates.AddRange(referencedTemplates); + } + + if (!templates.Any()) + { + _logger.LogTrace($"No templates associated with device groups: {string.Join(", ", deviceGroups)}. Returning template stacks: {string.Join(", ", result)}"); + _logger.MethodExit(); + return result; + } + + var templatesStackList = await _client.GetTemplateStacks(); + + foreach (var stack in templatesStackList.Result.TemplateStacks) + { + // Add template stacks where the associated templates match the list of templates returned from device group query + if (templates.Any(t => stack.Templates.Contains(t))) + { + _logger.LogDebug($"Adding template stack '{stack.Name}' to result as it contains templates associated with device groups."); + result.Add(stack.Name); + } + } + + _logger.MethodExit(); + + return result.Distinct().ToList(); + } +} diff --git a/PaloAlto/Jobs/Management.cs b/PaloAlto/Jobs/Management.cs index f20e278..2233429 100644 --- a/PaloAlto/Jobs/Management.cs +++ b/PaloAlto/Jobs/Management.cs @@ -17,6 +17,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading.Tasks; using System.Xml.Serialization; using Keyfactor.Extensions.Orchestrator.PaloAlto.Client; using Keyfactor.Extensions.Orchestrator.PaloAlto.Helpers; @@ -48,6 +49,13 @@ public Management(IPAMSecretResolver resolver) _logger.LogTrace("Initialized Management with IPAMSecretResolver."); } + public Management(IPAMSecretResolver resolver, ILogger logger) + { + _resolver = resolver; + _logger = logger; + _logger.LogTrace("Initialized Management with IPAMSecretResolver."); + } + private string ServerPassword { get; set; } private JobProperties StoreProperties { get; set; } @@ -60,7 +68,6 @@ public Management(IPAMSecretResolver resolver) public JobResult ProcessJob(ManagementJobConfiguration jobConfiguration) { - _logger = LogHandler.GetClassLogger(); _logger.LogTrace($"Processing job with configuration: {JsonConvert.SerializeObject(jobConfiguration)}"); StoreProperties = JsonConvert.DeserializeObject( jobConfiguration.CertificateStoreDetails.Properties, @@ -83,11 +90,12 @@ private JobResult PerformManagement(ManagementJobConfiguration config) _logger.MethodEntry(); ServerPassword = ResolvePamField("ServerPassword", config.ServerPassword); ServerUserName = ResolvePamField("ServerUserName", config.ServerUsername); - + _logger.LogTrace("Creating PaloAlto Client for Management job"); - - _client = new PaloAltoClient(config.CertificateStoreDetails.ClientMachine, ServerUserName, ServerPassword); //Api base URL Plus Key - + + _client = new PaloAltoClient(config.CertificateStoreDetails.ClientMachine, ServerUserName, + ServerPassword); //Api base URL Plus Key + _logger.LogTrace("Validating Store Properties for Management Job"); var (valid, result) = Validators.ValidateStoreProperties(StoreProperties, @@ -99,6 +107,15 @@ private JobResult PerformManagement(ManagementJobConfiguration config) if (!valid) return result; _logger.LogTrace("Validated Store Properties for Management Job"); + var (aliasValid, aliasResult) = + Validators.ValidateCertificateAlias(config.CertificateStoreDetails.StorePath, + config.JobCertificate?.Alias); + + _logger.LogTrace($"Validated certificate alias. valid={aliasValid}"); + + if (!aliasValid) return aliasResult; + _logger.LogTrace("Validated alias for Management Job"); + var complete = new JobResult { Result = OrchestratorJobStatusJobResult.Failure, @@ -110,19 +127,19 @@ private JobResult PerformManagement(ManagementJobConfiguration config) if (config.OperationType.ToString() == "Add") { _logger.LogTrace("Adding..."); - if(config!=null) - _logger.LogTrace($"Add Config Json {_client.MaskSensitiveData(JsonConvert.SerializeObject(config))}"); + if (config != null) + _logger.LogTrace( + $"Add Config Json {_client.MaskSensitiveData(JsonConvert.SerializeObject(config))}"); complete = PerformAddition(config); _logger.LogTrace("Finished Perform Addition Function"); - } else if (config.OperationType.ToString() == "Remove") { _logger.LogTrace("Removing..."); - _logger.LogTrace($"Remove Config Json {_client.MaskSensitiveData(JsonConvert.SerializeObject(config))}"); + _logger.LogTrace( + $"Remove Config Json {_client.MaskSensitiveData(JsonConvert.SerializeObject(config))}"); complete = PerformRemoval(config); _logger.LogTrace("Finished Perform Removal Function"); - } return complete; @@ -158,11 +175,12 @@ private JobResult PerformRemoval(ManagementJobConfiguration config) } _logger.LogTrace( - - $"Alias to Remove From Palo Alto: {config.JobCertificate.Alias}"); + $"Alias to Remove From Palo Alto: {config.JobCertificate.Alias}"); if (!DeleteCertificate(config, _client, warnings, out var deleteResult)) return deleteResult; _logger.LogTrace("Attempting to Commit Changes for Removal Job..."); - warnings = CommitChanges(config, _client, warnings); + warnings = CommitChanges(config, _client, warnings) + .GetAwaiter() + .GetResult(); _logger.LogTrace("Finished Committing Changes....."); if (warnings?.Length > 0) @@ -171,7 +189,6 @@ private JobResult PerformRemoval(ManagementJobConfiguration config) _logger.LogTrace("Warnings Found"); deleteResult.FailureMessage = warnings; deleteResult.Result = OrchestratorJobStatusJobResult.Warning; - } return deleteResult; @@ -196,15 +213,19 @@ private bool SetPanoramaTarget(ManagementJobConfiguration config, PaloAltoClient _logger.LogTrace("Trying to Set Panorama Target for Template Vsys Configuration"); var targetResult = client.SetPanoramaTarget(config.CertificateStoreDetails.StorePath).Result; _logger.LogTrace("Completed Set Panorama Target for Template Vsys Configuration"); - if (targetResult != null && targetResult.Status.Equals("error", StringComparison.CurrentCultureIgnoreCase)) + if (targetResult != null && + targetResult.Status.Equals("error", StringComparison.CurrentCultureIgnoreCase)) { { - var error = targetResult.LineMsg != null ? Validators.BuildPaloError(targetResult) : "Could not retrieve error results"; + var error = targetResult.LineMsg != null + ? Validators.BuildPaloError(targetResult) + : "Could not retrieve error results"; _logger.LogTrace($"Could not set target for Panorama vsys {error}"); return false; } } } + _logger.MethodExit(); return true; } @@ -214,7 +235,6 @@ private bool CheckForDuplicate(ManagementJobConfiguration config, PaloAltoClient _logger.MethodEntry(); try { - _logger.MethodEntry(); _logger.LogTrace("Getting list to check for duplicates"); var rawCertificatesResult = client.GetCertificateList( @@ -228,7 +248,6 @@ private bool CheckForDuplicate(ManagementJobConfiguration config, PaloAltoClient _logger.MethodExit(); return certificatesResult.Count > 0; - } catch (Exception e) { @@ -270,7 +289,8 @@ private JobResult PerformAddition(ManagementJobConfiguration config) "Finished SetPanoramaTarget Function."); var duplicate = CheckForDuplicate(config, client, config.JobCertificate.Alias); - _logger.LogTrace($"Duplicate? = {duplicate.ToString()}. Config.Overwrite = {config.Overwrite.ToString()}"); + _logger.LogTrace( + $"Duplicate? = {duplicate.ToString()}. Config.Overwrite = {config.Overwrite.ToString()}"); //Check for Duplicate already in Palo Alto, if there, make sure the Overwrite flag is checked before replacing if (duplicate && config.Overwrite || !duplicate) @@ -291,7 +311,9 @@ private JobResult PerformAddition(ManagementJobConfiguration config) string errorMsg = string.Empty; _logger.LogTrace("Importing Certificate Chain"); - var type = string.IsNullOrWhiteSpace(config.JobCertificate.PrivateKeyPassword) ? "certificate" : "keypair"; + var type = string.IsNullOrWhiteSpace(config.JobCertificate.PrivateKeyPassword) + ? "certificate" + : "keypair"; _logger.LogTrace($"Certificate Type of {type}"); var importResult = client.ImportCertificate(alias, config.JobCertificate.PrivateKeyPassword, @@ -302,15 +324,22 @@ private JobResult PerformAddition(ManagementJobConfiguration config) LogResponse(content); _logger.LogTrace("Finished Logging Import Results..."); - //4. Try to commit to firewall or Palo Alto then Push to the devices - if (errorMsg.Length == 0) + if (content != null && content.Status.Equals("error", StringComparison.CurrentCultureIgnoreCase)) { - _logger.LogTrace("Attempting to Commit Changes, no errors were found"); - warnings = CommitChanges(config, client, warnings); + errorMsg = content.LineMsg != null + ? Validators.BuildPaloError(content) + : "Could not retrieve error results"; + + return ReturnJobResult(config, warnings, false, errorMsg); } - return ReturnJobResult(config, warnings, true, errorMsg); + //4. Try to commit to firewall or Palo Alto then Push to the devices + _logger.LogTrace("Attempting to Commit Changes, no errors were found"); + warnings = CommitChanges(config, client, warnings) + .GetAwaiter() + .GetResult(); + return ReturnJobResult(config, warnings, true, errorMsg); } return new JobResult @@ -371,11 +400,13 @@ private bool DeleteCertificate(ManagementJobConfiguration config, PaloAltoClient } var delRespTryTwo = client - .SubmitDeleteCertificate(config.JobCertificate.Alias, config.CertificateStoreDetails.StorePath).Result; + .SubmitDeleteCertificate(config.JobCertificate.Alias, config.CertificateStoreDetails.StorePath) + .Result; if (delRespTryTwo.Status.ToUpper() == "ERROR") { { - deleteResult = ReturnJobResult(config, warnings, false, Validators.BuildPaloError(delResponse)); + deleteResult = ReturnJobResult(config, warnings, false, + Validators.BuildPaloError(delResponse)); return false; } } @@ -482,12 +513,15 @@ private string GetPemFile(ManagementJobConfiguration config) return certPem; } - private string CommitChanges(ManagementJobConfiguration config, PaloAltoClient client, string warnings) + private async Task CommitChanges(ManagementJobConfiguration config, PaloAltoClient client, + string warnings) { _logger.MethodEntry(); - var commitResponse = client.GetCommitResponse().Result; + + var commitResponse = await client.GetCommitResponse(); _logger.LogTrace("Got client commit response, attempting to log it"); LogResponse(commitResponse); + if (commitResponse.Status == "success") { _logger.LogTrace("Commit response shows success"); @@ -499,14 +533,14 @@ private string CommitChanges(ManagementJobConfiguration config, PaloAltoClient c // (Panorama has a limit to the number of queued jobs it allows, so we want to make sure this one completes). _logger.LogTrace("Waiting for job to finish"); var jobPoller = new PanoramaJobPoller(client); - var completionResult = jobPoller.WaitForJobCompletion(commitResponse.Result.JobId).GetAwaiter().GetResult(); + var completionResult = await jobPoller.WaitForJobCompletion(commitResponse.Result.JobId); if (completionResult.Result == OrchestratorJobStatusJobResult.Failure) { return completionResult.FailureMessage; } } - + //Check to see if it is a Panorama instance (not "/" or empty store path) if Panorama, push to corresponding firewall devices var deviceGroup = StoreProperties?.DeviceGroup; _logger.LogTrace($"Device Group {deviceGroup}"); @@ -515,9 +549,14 @@ private string CommitChanges(ManagementJobConfiguration config, PaloAltoClient c _logger.LogTrace($"Template Stack {templateStack}"); //If there is a template and device group then push to all firewall devices because it is Panorama - if (Validators.IsValidPanoramaVsysFormat(config.CertificateStoreDetails.StorePath) || Validators.IsValidPanoramaFormat(config.CertificateStoreDetails.StorePath)) + if (Validators.IsValidPanoramaVsysFormat(config.CertificateStoreDetails.StorePath) || + Validators.IsValidPanoramaFormat(config.CertificateStoreDetails.StorePath)) { - var commitAllResponse = client.GetCommitAllResponse(deviceGroup, config.CertificateStoreDetails.StorePath, templateStack).Result; + // Split the device groups from the store properties + var deviceGroups = Validators.GetDeviceGroups(deviceGroup); + + var commitAllResponse = await client.GetCommitAllResponse(deviceGroups, + config.CertificateStoreDetails.StorePath, templateStack); _logger.LogTrace("Logging commit response from panorama."); LogResponse(commitAllResponse); if (commitAllResponse.Status != "success") @@ -582,4 +621,4 @@ public static string OrderCertificatesAndConvertToPem(X509CertificateEntry[] cer return pemString; } } -} \ No newline at end of file +} diff --git a/PaloAlto/Models/Responses/DeviceGroupsResponse.cs b/PaloAlto/Models/Responses/DeviceGroupsResponse.cs new file mode 100644 index 0000000..e18c773 --- /dev/null +++ b/PaloAlto/Models/Responses/DeviceGroupsResponse.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Xml.Serialization; + +namespace Keyfactor.Extensions.Orchestrator.PaloAlto.Models.Responses +{ + [XmlRoot(ElementName = "response")] + public class DeviceGroupsResponse + { + [XmlElement(ElementName = "result")] + public DeviceGroupsResult Result { get; set; } + + [XmlAttribute(AttributeName = "status")] + public string Status { get; set; } + + [XmlAttribute(AttributeName = "code")] + public int Code { get; set; } + } + + public class DeviceGroupsResult + { + [XmlArray("device-group")] + [XmlArrayItem("entry")] + public List DeviceGroups { get; set; } + + [XmlAttribute(AttributeName = "total-count")] + public int TotalCount { get; set; } + + [XmlAttribute(AttributeName = "count")] + public int Count { get; set; } + } + + public class DeviceGroup + { + [XmlAttribute(AttributeName = "name")] + public string Name { get; set; } + + [XmlArray("reference-templates")] + [XmlArrayItem("member")] + public List ReferenceTemplates { get; set; } + + [XmlArray("devices")] + [XmlArrayItem("entry")] + public List Devices { get; set; } + } + + public class DeviceEntry + { + [XmlAttribute(AttributeName = "name")] + public string Name { get; set; } + } +} + diff --git a/PaloAlto/Models/Responses/TemplateStacksResponse.cs b/PaloAlto/Models/Responses/TemplateStacksResponse.cs new file mode 100644 index 0000000..37fdae0 --- /dev/null +++ b/PaloAlto/Models/Responses/TemplateStacksResponse.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Xml.Serialization; + +namespace Keyfactor.Extensions.Orchestrator.PaloAlto.Models.Responses +{ + [XmlRoot(ElementName = "response")] + public class TemplateStacksResponse + { + [XmlElement(ElementName = "result")] + public TemplateStackResult Result { get; set; } + + [XmlAttribute(AttributeName = "status")] + public string Status { get; set; } + + [XmlAttribute(AttributeName = "code")] + public int Code { get; set; } + } + + public class TemplateStackResult + { + [XmlArray("template-stack")] + [XmlArrayItem("entry")] + public List TemplateStacks { get; set; } + } + + public class TemplateStack + { + [XmlAttribute("name")] + public string Name { get; set; } + + [XmlArray("templates")] + [XmlArrayItem("member")] + public List Templates { get; set; } + } +} + + diff --git a/PaloAlto/Validators.cs b/PaloAlto/Validators.cs index 1892061..4af0824 100644 --- a/PaloAlto/Validators.cs +++ b/PaloAlto/Validators.cs @@ -150,6 +150,51 @@ public static (bool valid, JobResult result) ValidateStoreProperties(JobProperti return (true, new JobResult()); } + /// + /// Panorama and Firewall have different constraints around certificate name. Panorama will not allow + /// certificate names longer than 31 characters, while Firewall has this limit at 63 characters. + /// Even if certificate is pushed to Firewall via Panorama, Panorama will reject certificate names longer than 31 characters. + /// + /// See: https://docs.paloaltonetworks.com/ngfw/administration/certificate-management/obtain-certificates/generate-certificate#generate-certificate-pan-os + /// + /// The store path the certificate will be pushed to + /// The alias (logical name) of the certificate in Panorama / Firewall + /// A bool indicating if validation succeeds. If false, the JobResult contains the failure object. + public static (bool valid, JobResult result) ValidateCertificateAlias(string storePath, string alias) + { + if (string.IsNullOrWhiteSpace(alias)) + { + var result = new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + FailureMessage = "Certificate alias must not be empty" + }; + + return (false, result); + } + + int maxLength = 63; + + if (storePath == "/config/panorama" || IsValidPanoramaFormat(storePath) || + IsValidPanoramaVsysFormat(storePath)) + { + maxLength = 31; + } + + if (alias.Length > maxLength) + { + var result = new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + FailureMessage = $"Alias name is too long, it must not be more than {maxLength} characters. Current length: {alias.Length}" + }; + + return (false, result); + } + + return (true, null); + } + public static bool IsValidPanoramaVsysFormat(string storePath) { string pattern = @"^/config/devices/entry/template/entry\[@name='[^']+'\]/config/devices/entry/vsys/entry\[@name='[^']+'\]$"; diff --git a/README.md b/README.md index 7f7dd53..3245068 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ The Palo Alto Orchestrator Extension is an integration that can replace and inve This integration is compatible with Keyfactor Universal Orchestrator version 10.4 and later. ## Support -The Palo Alto Universal Orchestrator extension If you have a support issue, please open a support ticket by either contacting your Keyfactor representative or via the Keyfactor Support Portal at https://support.keyfactor.com. +The Palo Alto Universal Orchestrator extension is supported by Keyfactor. If you require support for any issues or have feature request, please open a support ticket by either contacting your Keyfactor representative or via the Keyfactor Support Portal at https://support.keyfactor.com. -> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. +> If you want to contribute bug fixes or additional enhancements, use the **[Pull requests](../../pulls)** tab. ## Requirements & Prerequisites @@ -297,6 +297,15 @@ Please refer to the **Universal Orchestrator (remote)** usage section ([PAM prov +## Release 2.6 Update on Alias Constraints +**Important Note** For managmeent jobs, the alias provided for the job is validated to ensure the length of the alias is not longer than Panorama / Firewall allows. For Panorama, alias length MUST NOT be more than 31 characters. For Firewall, alias length MUST NOT be more than 63 characters. If your store path points to Panorama, even if you are pushing the certificate to Firewall, you must keep alias length to at most 31 characters. Please see the [Panorama documentation](https://docs.paloaltonetworks.com/ngfw/administration/certificate-management/obtain-certificates/generate-certificate#generate-certificate-pan-os) for more information on certificate name length. + +If the alias length exceeds the maximum length, you will receive a job failure with the following error message: +``` +Alias name is too long, it must not be more than 31 characters. Current length: 32. +Certificate thumbprint: . +``` + ## Release 2.2 Update on Entry Params **Important Note** Entry params are no longer used. This version of the extension will only update certs on existing bindings and not add a cert to a new binding location. This was done to simplify the process since there are so many binding locations and reference issues. diff --git a/docsource/content.md b/docsource/content.md index 60cd1ed..04f0fb0 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -6,6 +6,16 @@ The Palo Alto Orchestrator Extension is an integration that can replace and inve ## Requirements +## Release 2.6 Update on Alias Constraints +**Important Note** For managmeent jobs, the alias provided for the job is validated to ensure the length of the alias is not longer than Panorama / Firewall allows. For Panorama, alias length MUST NOT be more than 31 characters. For Firewall, alias length MUST NOT be more than 63 characters. If your store path points to Panorama, even if you are pushing the certificate to Firewall, you must keep alias length to at most 31 characters. Please see the [Panorama documentation](https://docs.paloaltonetworks.com/ngfw/administration/certificate-management/obtain-certificates/generate-certificate#generate-certificate-pan-os) for more information on certificate name length. + +If the alias length exceeds the maximum length, you will receive a job failure with the following error message: +``` +Alias name is too long, it must not be more than 31 characters. Current length: 32. +Certificate thumbprint: . +``` + + ## Release 2.2 Update on Entry Params **Important Note** Entry params are no longer used. This version of the extension will only update certs on existing bindings and not add a cert to a new binding location. This was done to simplify the process since there are so many binding locations and reference issues.