From d6fe27bc7c5d848fea83fa42ee15f12b4ea292d9 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 2 Apr 2026 10:07:09 -0400 Subject: [PATCH 01/21] Datapower Discovery Job --- .claude/settings.json | 24 +++ DataPower/Client/DataPowerClient.cs | 67 ++++++++ DataPower/Jobs/Discovery.cs | 146 ++++++++++++++++++ .../Models/Requests/ListDomainsRequest.cs | 31 ++++ .../Models/Requests/ListFileStoreRequest.cs | 32 ++++ .../Models/Responses/ListDomainsResponse.cs | 29 ++++ .../Models/Responses/ListFileStoreResponse.cs | 29 ++++ .../Models/SupportingObjects/DomainInfo.cs | 25 +++ .../SupportingObjects/FileStoreDirectory.cs | 25 +++ DataPower/manifest.json | 4 + integration-manifest.json | 8 +- 11 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 .claude/settings.json create mode 100644 DataPower/Jobs/Discovery.cs create mode 100644 DataPower/Models/Requests/ListDomainsRequest.cs create mode 100644 DataPower/Models/Requests/ListFileStoreRequest.cs create mode 100644 DataPower/Models/Responses/ListDomainsResponse.cs create mode 100644 DataPower/Models/Responses/ListFileStoreResponse.cs create mode 100644 DataPower/Models/SupportingObjects/DomainInfo.cs create mode 100644 DataPower/Models/SupportingObjects/FileStoreDirectory.cs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a0642f0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:andergrove.com)", + "WebFetch(domain:www.orangespecs.com)", + "WebFetch(domain:blog.andergrove.com)", + "WebFetch(domain:github.com)", + "Bash(git checkout:*)", + "Read(//c/Users/bhill/.nuget/packages/keyfactor.orchestrators.iorchestratorjobextensions/**)", + "Bash(dotnet build:*)", + "Bash(dotnet /c/Users/bhill/.nuget/packages/ilspycmd/9.0.0.7894/tools/net8.0/any/ilspycmd.dll /c/Users/bhill/.nuget/packages/keyfactor.orchestrators.iorchestratorjobextensions/0.7.0/lib/netstandard2.0/Keyfactor.Orchestrators.IOrchestratorJobExtensions.dll)", + "Bash(pip install:*)", + "Bash(python3 -c ':*)", + "Bash(gh search:*)", + "WebFetch(domain:keyfactor.github.io)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:www.nuget.org)", + "Bash(powershell -Command ':*)" + ], + "additionalDirectories": [ + "c:\\Users\\bhill\\source\\repos\\ibm-datapower-orchestrator\\.claude" + ] + } +} diff --git a/DataPower/Client/DataPowerClient.cs b/DataPower/Client/DataPowerClient.cs index 997681a..41ad0f9 100644 --- a/DataPower/Client/DataPowerClient.cs +++ b/DataPower/Client/DataPowerClient.cs @@ -17,6 +17,8 @@ using System.Net; using System.Net.Http; using System.Text; +using System.Collections.Generic; +using System.Linq; using Keyfactor.Extensions.Orchestrator.DataPower.Models.Requests; using Keyfactor.Extensions.Orchestrator.DataPower.Models.Responses; using Keyfactor.Extensions.Orchestrator.DataPower.Models.SupportingObjects; @@ -72,6 +74,71 @@ public DataPowerClient(string user, string pass, string baseUrl, string domain) #region Class Methods + public List ListDomains() + { + try + { + var request = new ListDomainsRequest(); + var strResponse = ApiRequestString("ListDomains", request.GetResource(), request.Method, + string.Empty, false, true); + + var containerName = "domain"; + + // DataPower returns a single object instead of array when only one domain exists + if (strResponse.Contains($"\"{containerName}\"") && + !strResponse.Contains($"\"{containerName}\" : [") && + !strResponse.Contains($"\"{containerName}\":[")) + { + // Wrap single domain object in array for proper deserialization + var singleResponse = JsonConvert.DeserializeObject(strResponse); + return singleResponse?.Domain != null + ? new List { singleResponse.Domain } + : new List(); + } + + var response = JsonConvert.DeserializeObject(strResponse); + return response?.Domains?.ToList() ?? new List(); + } + catch (Exception e) + { + _logger.LogError($"Error In DataPowerClient.ListDomains: {LogHandler.FlattenException(e)}"); + throw; + } + } + + public List ListFileStoreDirectories(string domain) + { + try + { + var request = new ListFileStoreRequest(domain); + var strResponse = ApiRequestString("ListFileStoreDirectories", request.GetResource(), request.Method, + string.Empty, false, true); + + var containerName = "directory"; + + // DataPower returns a single object instead of array when only one directory exists + if (strResponse.Contains($"\"{containerName}\"") && + !strResponse.Contains($"\"{containerName}\" : [") && + !strResponse.Contains($"\"{containerName}\":[")) + { + strResponse = FixDataPowerBadJson(strResponse, containerName); + } + + var response = JsonConvert.DeserializeObject(strResponse); + if (response?.FileStore?.Directories == null) + return new List(); + + return response.FileStore.Directories + .Select(d => d.Name) + .ToList(); + } + catch (Exception e) + { + _logger.LogError($"Error In DataPowerClient.ListFileStoreDirectories: {LogHandler.FlattenException(e)}"); + throw; + } + } + public bool SaveConfig() { try diff --git a/DataPower/Jobs/Discovery.cs b/DataPower/Jobs/Discovery.cs new file mode 100644 index 0000000..a562e4c --- /dev/null +++ b/DataPower/Jobs/Discovery.cs @@ -0,0 +1,146 @@ +// Copyright 2023 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 System; +using System.Collections.Generic; +using System.Linq; +using Keyfactor.Extensions.Orchestrator.DataPower.Client; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Jobs +{ + public class Discovery : IDiscoveryJobExtension + { + private readonly ILogger _logger; + private readonly IPAMSecretResolver _resolver; + + // Certificate-relevant filestore directories on DataPower + private static readonly HashSet CertStoreDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "cert", + "pubcert", + "sharedcert" + }; + + public Discovery(IPAMSecretResolver resolver) + { + _logger = LogHandler.GetClassLogger(); + _resolver = resolver; + } + + private string ResolvePamField(string name, string value) + { + _logger.LogTrace($"Attempting to resolved PAM eligible field {name}"); + return _resolver.Resolve(value); + } + + public string ExtensionName => "DataPower"; + + public JobResult ProcessJob(DiscoveryJobConfiguration jobConfiguration, + SubmitDiscoveryUpdate submitDiscoveryUpdate) + { + try + { + _logger.MethodEntry(LogLevel.Debug); + return PerformDiscovery(jobConfiguration, submitDiscoveryUpdate); + } + catch (Exception e) + { + _logger.LogError($"Error In Discovery.ProcessJob: {LogHandler.FlattenException(e)}"); + return new JobResult + { + FailureMessage = $"Unknown Exception Occured In ProcessJob: {LogHandler.FlattenException(e)}", + JobHistoryId = jobConfiguration.JobHistoryId, + Result = OrchestratorJobStatusJobResult.Failure + }; + } + } + + private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDiscoveryUpdate submitDiscovery) + { + try + { + var protocol = "https"; + if (config.JobProperties != null && config.JobProperties.ContainsKey("Protocol")) + { + protocol = config.JobProperties["Protocol"]?.ToString() ?? "https"; + } + + var baseUrl = $"{protocol}://" + config.ClientMachine.Trim(); + + _logger.LogTrace($"Entering IBM DataPower: Discovery for appliance {config.ClientMachine}"); + + var apiClient = new DataPowerClient( + ResolvePamField("ServerUserName", config.ServerUsername), + ResolvePamField("ServerPassword", config.ServerPassword), + baseUrl, + "default"); + + // Step 1: List all domains on the appliance + _logger.LogTrace("Discovering domains on DataPower appliance..."); + var domains = apiClient.ListDomains(); + _logger.LogTrace($"Found {domains.Count} domain(s)"); + + var discoveredLocations = new List(); + + // Step 2: For each domain, discover certificate store directories + foreach (var domain in domains) + { + _logger.LogTrace($"Discovering filestore directories for domain: {domain.Name}"); + try + { + var directories = apiClient.ListFileStoreDirectories(domain.Name); + _logger.LogTrace($"Found {directories.Count} directory(ies) in domain {domain.Name}"); + + var certDirectories = directories + .Where(d => CertStoreDirectories.Contains(d)) + .ToList(); + + foreach (var dir in certDirectories) + { + var storePath = $"{domain.Name}\\{dir}"; + _logger.LogTrace($"Discovered certificate store: {storePath}"); + discoveredLocations.Add(storePath); + } + } + catch (Exception ex) + { + _logger.LogWarning($"Unable to list filestore directories for domain {domain.Name}: {LogHandler.FlattenException(ex)}"); + } + } + + _logger.LogTrace($"Discovery complete. Found {discoveredLocations.Count} certificate store location(s)."); + + submitDiscovery.Invoke(discoveredLocations); + + _logger.MethodExit(LogLevel.Debug); + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Success, + JobHistoryId = config.JobHistoryId + }; + } + catch (Exception e) + { + _logger.LogError($"Error In Discovery.PerformDiscovery: {LogHandler.FlattenException(e)}"); + throw; + } + } + } +} diff --git a/DataPower/Models/Requests/ListDomainsRequest.cs b/DataPower/Models/Requests/ListDomainsRequest.cs new file mode 100644 index 0000000..c8f3367 --- /dev/null +++ b/DataPower/Models/Requests/ListDomainsRequest.cs @@ -0,0 +1,31 @@ +// Copyright 2023 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 Newtonsoft.Json; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.Requests +{ + public class ListDomainsRequest : Request + { + public ListDomainsRequest() + { + Method = "GET"; + } + + public new string GetResource() + { + return "/mgmt/domains/config/"; + } + } +} diff --git a/DataPower/Models/Requests/ListFileStoreRequest.cs b/DataPower/Models/Requests/ListFileStoreRequest.cs new file mode 100644 index 0000000..fb6860e --- /dev/null +++ b/DataPower/Models/Requests/ListFileStoreRequest.cs @@ -0,0 +1,32 @@ +// Copyright 2023 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 Newtonsoft.Json; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.Requests +{ + public class ListFileStoreRequest : Request + { + public ListFileStoreRequest(string domain) + { + Method = "GET"; + Domain = domain; + } + + public new string GetResource() + { + return "/mgmt/filestore/" + Domain; + } + } +} diff --git a/DataPower/Models/Responses/ListDomainsResponse.cs b/DataPower/Models/Responses/ListDomainsResponse.cs new file mode 100644 index 0000000..d367b0e --- /dev/null +++ b/DataPower/Models/Responses/ListDomainsResponse.cs @@ -0,0 +1,29 @@ +// Copyright 2023 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 Keyfactor.Extensions.Orchestrator.DataPower.Models.SupportingObjects; +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.Responses +{ + public class ListDomainsResponse + { + [JsonProperty("domain")] public DomainInfo[] Domains { get; set; } + } + + public class ListDomainsSingleResponse + { + [JsonProperty("domain")] public DomainInfo Domain { get; set; } + } +} diff --git a/DataPower/Models/Responses/ListFileStoreResponse.cs b/DataPower/Models/Responses/ListFileStoreResponse.cs new file mode 100644 index 0000000..e71e7ea --- /dev/null +++ b/DataPower/Models/Responses/ListFileStoreResponse.cs @@ -0,0 +1,29 @@ +// Copyright 2023 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 Keyfactor.Extensions.Orchestrator.DataPower.Models.SupportingObjects; +using Newtonsoft.Json; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.Responses +{ + public class ListFileStoreResponse + { + [JsonProperty("filestore")] public FileStoreContent FileStore { get; set; } + } + + public class FileStoreContent + { + [JsonProperty("directory")] public FileStoreDirectory[] Directories { get; set; } + } +} diff --git a/DataPower/Models/SupportingObjects/DomainInfo.cs b/DataPower/Models/SupportingObjects/DomainInfo.cs new file mode 100644 index 0000000..dabb311 --- /dev/null +++ b/DataPower/Models/SupportingObjects/DomainInfo.cs @@ -0,0 +1,25 @@ +// Copyright 2023 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 Newtonsoft.Json; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.SupportingObjects +{ + public class DomainInfo + { + [JsonProperty("name")] public string Name { get; set; } + + [JsonProperty("href")] public string Href { get; set; } + } +} diff --git a/DataPower/Models/SupportingObjects/FileStoreDirectory.cs b/DataPower/Models/SupportingObjects/FileStoreDirectory.cs new file mode 100644 index 0000000..b46f491 --- /dev/null +++ b/DataPower/Models/SupportingObjects/FileStoreDirectory.cs @@ -0,0 +1,25 @@ +// Copyright 2023 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 Newtonsoft.Json; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.SupportingObjects +{ + public class FileStoreDirectory + { + [JsonProperty("name")] public string Name { get; set; } + + [JsonProperty("href")] public string Href { get; set; } + } +} diff --git a/DataPower/manifest.json b/DataPower/manifest.json index ffec818..824f203 100644 --- a/DataPower/manifest.json +++ b/DataPower/manifest.json @@ -8,6 +8,10 @@ "CertStores.DataPower.Management": { "assemblypath": "DataPower.dll", "TypeFullName": "Keyfactor.Extensions.Orchestrator.DataPower.Jobs.Management" + }, + "CertStores.DataPower.Discovery": { + "assemblypath": "DataPower.dll", + "TypeFullName": "Keyfactor.Extensions.Orchestrator.DataPower.Jobs.Discovery" } } } diff --git a/integration-manifest.json b/integration-manifest.json index 8303933..42c6488 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -8,7 +8,7 @@ "support_level": "kf-supported", "release_project": "DataPower/DataPower.csproj", "release_dir": "DataPower/bin/Release", - "description": "The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\\directory combination. ", + "description": "The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Discovery, Inventory, Add and Remove functions are supported. Discovery automatically finds all domains and certificate store directories on a DataPower appliance. This integration can add/replace certificates in any domain\\directory combination. ", "link_github": true, "update_catalog": true, "about": { @@ -19,7 +19,7 @@ "keyfactor_platform_version": "10.4", "win": { "supportsCreateStore": false, - "supportsDiscovery": false, + "supportsDiscovery": true, "supportsManagementAdd": true, "supportsManagementRemove": true, "supportsReenrollment": false, @@ -28,7 +28,7 @@ }, "linux": { "supportsCreateStore": false, - "supportsDiscovery": false, + "supportsDiscovery": true, "supportsManagementAdd": true, "supportsManagementRemove": true, "supportsReenrollment": false, @@ -44,7 +44,7 @@ "SupportedOperations": { "Add": true, "Create": false, - "Discovery": false, + "Discovery": true, "Enrollment": false, "Remove": false }, From afb8861fe147e2b4bdad539b472905bd89d4b321 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Thu, 2 Apr 2026 14:09:00 +0000 Subject: [PATCH 02/21] Update generated docs --- README.md | 365 +++++++++++------- .../bash/curl_create_store_types.sh | 149 +++++++ .../bash/kfutil_create_store_types.sh | 28 ++ .../powershell/kfutil_create_store_types.ps1 | 29 ++ .../restmethod_create_store_types.ps1 | 143 +++++++ 5 files changed, 575 insertions(+), 139 deletions(-) create mode 100755 scripts/store_types/bash/curl_create_store_types.sh create mode 100755 scripts/store_types/bash/kfutil_create_store_types.sh create mode 100644 scripts/store_types/powershell/kfutil_create_store_types.ps1 create mode 100644 scripts/store_types/powershell/restmethod_create_store_types.ps1 diff --git a/README.md b/README.md index 87633b2..e5e382f 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ The IBM DataPower Orchestrator allows for the management of certificates in the This integration is compatible with Keyfactor Universal Orchestrator version 10.4 and later. ## Support -The DataPower Universal Orchestrator extension is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket 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. +The DataPower 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. + +> If you want to contribute bug fixes or additional enhancements, use the **[Pull requests](../../pulls)** tab. ## Requirements & Prerequisites @@ -54,89 +54,186 @@ Before installing the DataPower Universal Orchestrator extension, we recommend t The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. For example default\pubcert -## Create the DataPower Certificate Store Type +## DataPower Certificate Store Type To use the DataPower Universal Orchestrator extension, you **must** create the DataPower Certificate Store Type. This only needs to happen _once_ per Keyfactor Command instance. -* **Create DataPower using kfutil**: - ```shell - # IBM Data Power - kfutil store-types create DataPower - ``` -* **Create DataPower manually in the Command UI**: -
Create DataPower manually in the Command UI - Create a store type called `DataPower` with the attributes in the tables below: - #### Basic Tab - | Attribute | Value | Description | - | --------- | ----- | ----- | - | Name | IBM Data Power | Display name for the store type (may be customized) | - | Short Name | DataPower | Short display name for the store type | - | Capability | DataPower | Store type name orchestrator will register with. Check the box to allow entry of value | - | Supports Add | ✅ Checked | Check the box. Indicates that the Store Type supports Management Add | - | Supports Remove | 🔲 Unchecked | Indicates that the Store Type supports Management Remove | - | Supports Discovery | 🔲 Unchecked | Indicates that the Store Type supports Discovery | - | Supports Reenrollment | 🔲 Unchecked | Indicates that the Store Type supports Reenrollment | - | Supports Create | 🔲 Unchecked | Indicates that the Store Type supports store creation | - | Needs Server | ✅ Checked | Determines if a target server name is required when creating store | - | Blueprint Allowed | 🔲 Unchecked | Determines if store type may be included in an Orchestrator blueprint | - | Uses PowerShell | 🔲 Unchecked | Determines if underlying implementation is PowerShell | - | Requires Store Password | 🔲 Unchecked | Enables users to optionally specify a store password when defining a Certificate Store. | - | Supports Entry Password | 🔲 Unchecked | Determines if an individual entry within a store can have a password. | - The Basic tab should look like this: +#### Supported Operations + +| Operation | Is Supported | +|--------------|------------------------------------------------------------------------------------------------------------------------| +| Add | ✅ Checked | +| Remove | 🔲 Unchecked | +| Discovery | ✅ Checked | +| Reenrollment | 🔲 Unchecked | +| Create | 🔲 Unchecked | + +#### Store Type Creation + +##### Using kfutil: +`kfutil` is a custom CLI for the Keyfactor Command API and can be used to create certificate store types. +For more information on [kfutil](https://github.com/Keyfactor/kfutil) check out the [docs](https://github.com/Keyfactor/kfutil?tab=readme-ov-file#quickstart) +
Click to expand DataPower kfutil details + + ##### Using online definition from GitHub: + This will reach out to GitHub and pull the latest store-type definition + ```shell + # IBM Data Power + kfutil store-types create DataPower + ``` + + ##### Offline creation using integration-manifest file: + If required, it is possible to create store types from the [integration-manifest.json](./integration-manifest.json) included in this repo. + You would first download the [integration-manifest.json](./integration-manifest.json) and then run the following command + in your offline environment. + ```shell + kfutil store-types create --from-file integration-manifest.json + ``` +
+ + +#### Manual Creation +Below are instructions on how to create the DataPower store type manually in +the Keyfactor Command Portal +
Click to expand manual DataPower details + + Create a store type called `DataPower` with the attributes in the tables below: + + ##### Basic Tab + | Attribute | Value | Description | + | --------- | ----- | ----- | + | Name | IBM Data Power | Display name for the store type (may be customized) | + | Short Name | DataPower | Short display name for the store type | + | Capability | DataPower | Store type name orchestrator will register with. Check the box to allow entry of value | + | Supports Add | ✅ Checked | Check the box. Indicates that the Store Type supports Management Add | + | Supports Remove | 🔲 Unchecked | Indicates that the Store Type supports Management Remove | + | Supports Discovery | ✅ Checked | Check the box. Indicates that the Store Type supports Discovery | + | Supports Reenrollment | 🔲 Unchecked | Indicates that the Store Type supports Reenrollment | + | Supports Create | 🔲 Unchecked | Indicates that the Store Type supports store creation | + | Needs Server | ✅ Checked | Determines if a target server name is required when creating store | + | Blueprint Allowed | 🔲 Unchecked | Determines if store type may be included in an Orchestrator blueprint | + | Uses PowerShell | 🔲 Unchecked | Determines if underlying implementation is PowerShell | + | Requires Store Password | 🔲 Unchecked | Enables users to optionally specify a store password when defining a Certificate Store. | + | Supports Entry Password | 🔲 Unchecked | Determines if an individual entry within a store can have a password. | + + The Basic tab should look like this: + + ![DataPower Basic Tab](docsource/images/DataPower-basic-store-type-dialog.png) + + ##### Advanced Tab + | Attribute | Value | Description | + | --------- | ----- | ----- | + | Supports Custom Alias | Required | Determines if an individual entry within a store can have a custom Alias. | + | Private Key Handling | Optional | This determines if Keyfactor can send the private key associated with a certificate to the store. Required because IIS certificates without private keys would be invalid. | + | PFX Password Style | Default | 'Default' - PFX password is randomly generated, 'Custom' - PFX password may be specified when the enrollment job is created (Requires the Allow Custom Password application setting to be enabled.) | + + The Advanced tab should look like this: + + ![DataPower Advanced Tab](docsource/images/DataPower-advanced-store-type-dialog.png) + + > For Keyfactor **Command versions 24.4 and later**, a Certificate Format dropdown is available with PFX and PEM options. Ensure that **PFX** is selected, as this determines the format of new and renewed certificates sent to the Orchestrator during a Management job. Currently, all Keyfactor-supported Orchestrator extensions support only PFX. + + ##### Custom Fields Tab + Custom fields operate at the certificate store level and are used to control how the orchestrator connects to the remote target server containing the certificate store to be managed. The following custom fields should be added to the store type: + + | Name | Display Name | Description | Type | Default Value/Options | Required | + | ---- | ------------ | ---- | --------------------- | -------- | ----------- | + | ServerUsername | Server Username | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | Secret | | 🔲 Unchecked | + | ServerPassword | Server Password | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | Secret | | 🔲 Unchecked | + | ServerUseSsl | Use SSL | Should be true, http is not supported. | Bool | true | ✅ Checked | + | InventoryBlackList | Inventory Black List | Comma seperated list of alias values you do not want to inventory from DataPower. | String | | 🔲 Unchecked | + | Protocol | Protocol Name | Comma seperated list of alias values you do not want to inventory from DataPower. | String | https | ✅ Checked | + | PublicCertStoreName | Public Cert Store Name | This probably will remain pubcert unless someone changed the default name in DataPower. | String | pubcert | ✅ Checked | + | InventoryPageSize | Inventory Page Size | This determines the page size during the inventory calls. (100 should be fine). | String | 100 | ✅ Checked | + + The Custom Fields tab should look like this: + + ![DataPower Custom Fields Tab](docsource/images/DataPower-custom-fields-store-type-dialog.png) + + + ###### Server Username + Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). + + + > [!IMPORTANT] + > This field is created by the `Needs Server` on the Basic tab, do not create this field manually. + + - ![DataPower Basic Tab](docsource/images/DataPower-basic-store-type-dialog.png) - #### Advanced Tab - | Attribute | Value | Description | - | --------- | ----- | ----- | - | Supports Custom Alias | Required | Determines if an individual entry within a store can have a custom Alias. | - | Private Key Handling | Optional | This determines if Keyfactor can send the private key associated with a certificate to the store. Required because IIS certificates without private keys would be invalid. | - | PFX Password Style | Default | 'Default' - PFX password is randomly generated, 'Custom' - PFX password may be specified when the enrollment job is created (Requires the Allow Custom Password application setting to be enabled.) | + ###### Server Password + A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). - The Advanced tab should look like this: - ![DataPower Advanced Tab](docsource/images/DataPower-advanced-store-type-dialog.png) + > [!IMPORTANT] + > This field is created by the `Needs Server` on the Basic tab, do not create this field manually. - #### Custom Fields Tab - Custom fields operate at the certificate store level and are used to control how the orchestrator connects to the remote target server containing the certificate store to be managed. The following custom fields should be added to the store type: - | Name | Display Name | Description | Type | Default Value/Options | Required | - | ---- | ------------ | ---- | --------------------- | -------- | ----------- | - | ServerUsername | Server Username | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | Secret | | 🔲 Unchecked | - | ServerPassword | Server Password | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | Secret | | 🔲 Unchecked | - | ServerUseSsl | Use SSL | Should be true, http is not supported. | Bool | true | ✅ Checked | - | InventoryBlackList | Inventory Black List | Comma seperated list of alias values you do not want to inventory from DataPower. | String | | 🔲 Unchecked | - | Protocol | Protocol Name | Comma seperated list of alias values you do not want to inventory from DataPower. | String | https | ✅ Checked | - | PublicCertStoreName | Public Cert Store Name | This probably will remain pubcert unless someone changed the default name in DataPower. | String | pubcert | ✅ Checked | - | InventoryPageSize | Inventory Page Size | This determines the page size during the inventory calls. (100 should be fine). | String | 100 | ✅ Checked | - The Custom Fields tab should look like this: - ![DataPower Custom Fields Tab](docsource/images/DataPower-custom-fields-store-type-dialog.png) + ###### Use SSL + Should be true, http is not supported. + ![DataPower Custom Field - ServerUseSsl](docsource/images/DataPower-custom-field-ServerUseSsl-dialog.png) + ![DataPower Custom Field - ServerUseSsl](docsource/images/DataPower-custom-field-ServerUseSsl-validation-options-dialog.png) -
+ + ###### Inventory Black List + Comma seperated list of alias values you do not want to inventory from DataPower. + + ![DataPower Custom Field - InventoryBlackList](docsource/images/DataPower-custom-field-InventoryBlackList-dialog.png) + ![DataPower Custom Field - InventoryBlackList](docsource/images/DataPower-custom-field-InventoryBlackList-validation-options-dialog.png) + + + + ###### Protocol Name + Comma seperated list of alias values you do not want to inventory from DataPower. + + ![DataPower Custom Field - Protocol](docsource/images/DataPower-custom-field-Protocol-dialog.png) + ![DataPower Custom Field - Protocol](docsource/images/DataPower-custom-field-Protocol-validation-options-dialog.png) + + + + ###### Public Cert Store Name + This probably will remain pubcert unless someone changed the default name in DataPower. + + ![DataPower Custom Field - PublicCertStoreName](docsource/images/DataPower-custom-field-PublicCertStoreName-dialog.png) + ![DataPower Custom Field - PublicCertStoreName](docsource/images/DataPower-custom-field-PublicCertStoreName-validation-options-dialog.png) + + + + ###### Inventory Page Size + This determines the page size during the inventory calls. (100 should be fine). + + ![DataPower Custom Field - InventoryPageSize](docsource/images/DataPower-custom-field-InventoryPageSize-dialog.png) + ![DataPower Custom Field - InventoryPageSize](docsource/images/DataPower-custom-field-InventoryPageSize-validation-options-dialog.png) + + + + + +
## Installation -1. **Download the latest DataPower Universal Orchestrator extension from GitHub.** +1. **Download the latest DataPower Universal Orchestrator extension from GitHub.** + + Navigate to the [DataPower Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/ibm-datapower-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive. - Navigate to the [DataPower Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/ibm-datapower-orchestrator/releases/latest). Refer to the compatibility matrix below to determine whether the `net6.0` or `net8.0` asset should be downloaded. Then, click the corresponding asset to download the zip archive. - | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `ibm-datapower-orchestrator` .NET version to download | - | --------- | ----------- | ----------- | ----------- | - | Older than `11.0.0` | | | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` | - | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | - | `11.6` _and_ newer | `net8.0` | | `net8.0` | + | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `ibm-datapower-orchestrator` .NET version to download | + | --------- | ----------- | ----------- | ----------- | + | Older than `11.0.0` | | | `net6.0` | + | Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` | + | Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` | + | `11.6` _and_ newer | `net8.0` | | `net8.0` | Unzip the archive containing extension assemblies to a known location. @@ -146,9 +243,9 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D * **Default on Windows** - `C:\Program Files\Keyfactor\Keyfactor Orchestrator\extensions` * **Default on Linux** - `/opt/keyfactor/orchestrator/extensions` - + 3. **Create a new directory for the DataPower Universal Orchestrator extension inside the extensions directory.** - + Create a new directory called `ibm-datapower-orchestrator`. > The directory name does not need to match any names used elsewhere; it just has to be unique within the extensions directory. @@ -159,14 +256,14 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D Refer to [Starting/Restarting the Universal Orchestrator service](https://software.keyfactor.com/Core-OnPrem/Current/Content/InstallingAgents/NetCoreOrchestrator/StarttheService.htm). -6. **(optional) PAM Integration** +6. **(optional) PAM Integration** The DataPower Universal Orchestrator extension is compatible with all supported Keyfactor PAM extensions to resolve PAM-eligible secrets. PAM extensions running on Universal Orchestrators enable secure retrieval of secrets from a connected PAM provider. - To configure a PAM provider, [reference the Keyfactor Integration Catalog](https://keyfactor.github.io/integrations-catalog/content/pam) to select an extension, and follow the associated instructions to install it on the Universal Orchestrator (remote). + To configure a PAM provider, [reference the Keyfactor Integration Catalog](https://keyfactor.github.io/integrations-catalog/content/pam) to select an extension and follow the associated instructions to install it on the Universal Orchestrator (remote). -> The above installation steps can be supplimented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/InstallingAgents/NetCoreOrchestrator/CustomExtensions.htm?Highlight=extensions). +> The above installation steps can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/InstallingAgents/NetCoreOrchestrator/CustomExtensions.htm?Highlight=extensions). @@ -174,103 +271,93 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D -* **Manually with the Command UI** - -
Create Certificate Stores manually in the UI +### Store Creation - 1. **Navigate to the _Certificate Stores_ page in Keyfactor Command.** +#### Manually with the Command UI - Log into Keyfactor Command, toggle the _Locations_ dropdown, and click _Certificate Stores_. +
Click to expand details - 2. **Add a Certificate Store.** +1. **Navigate to the _Certificate Stores_ page in Keyfactor Command.** - Click the Add button to add a new Certificate Store. Use the table below to populate the **Attributes** in the **Add** form. - | Attribute | Description | - | --------- | ----------- | - | Category | Select "IBM Data Power" or the customized certificate store name from the previous step. | - | Container | Optional container to associate certificate store with. | - | Client Machine | The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. | - | Store Path | The Store Path field should always be / unless we later determine there are alternate locations needed. | - | Orchestrator | Select an approved orchestrator capable of managing `DataPower` certificates. Specifically, one with the `DataPower` capability. | - | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | - | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | - | ServerUseSsl | Should be true, http is not supported. | - | InventoryBlackList | Comma seperated list of alias values you do not want to inventory from DataPower. | - | Protocol | Comma seperated list of alias values you do not want to inventory from DataPower. | - | PublicCertStoreName | This probably will remain pubcert unless someone changed the default name in DataPower. | - | InventoryPageSize | This determines the page size during the inventory calls. (100 should be fine). | + Log into Keyfactor Command, toggle the _Locations_ dropdown, and click _Certificate Stores_. +2. **Add a Certificate Store.** - + Click the Add button to add a new Certificate Store. Use the table below to populate the **Attributes** in the **Add** form. -
Attributes eligible for retrieval by a PAM Provider on the Universal Orchestrator + | Attribute | Description | + | --------- |---------------------------------------------------------| + | Category | Select "IBM Data Power" or the customized certificate store name from the previous step. | + | Container | Optional container to associate certificate store with. | + | Client Machine | The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. | + | Store Path | The Store Path field should always be / unless we later determine there are alternate locations needed. | + | Orchestrator | Select an approved orchestrator capable of managing `DataPower` certificates. Specifically, one with the `DataPower` capability. | + | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | + | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | + | ServerUseSsl | Should be true, http is not supported. | + | InventoryBlackList | Comma seperated list of alias values you do not want to inventory from DataPower. | + | Protocol | Comma seperated list of alias values you do not want to inventory from DataPower. | + | PublicCertStoreName | This probably will remain pubcert unless someone changed the default name in DataPower. | + | InventoryPageSize | This determines the page size during the inventory calls. (100 should be fine). | - If a PAM provider was installed _on the Universal Orchestrator_ in the [Installation](#Installation) section, the following parameters can be configured for retrieval _on the Universal Orchestrator_. - | Attribute | Description | - | --------- | ----------- | - | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | - | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | +
- Please refer to the **Universal Orchestrator (remote)** usage section ([PAM providers on the Keyfactor Integration Catalog](https://keyfactor.github.io/integrations-catalog/content/pam)) for your selected PAM provider for instructions on how to load attributes orchestrator-side. - > Any secret can be rendered by a PAM provider _installed on the Keyfactor Command server_. The above parameters are specific to attributes that can be fetched by an installed PAM provider running on the Universal Orchestrator server itself. -
- +#### Using kfutil CLI -
+
Click to expand details -* **Using kfutil** - -
Create Certificate Stores with kfutil - - 1. **Generate a CSV template for the DataPower certificate store** +1. **Generate a CSV template for the DataPower certificate store** - ```shell - kfutil stores import generate-template --store-type-name DataPower --outpath DataPower.csv - ``` - 2. **Populate the generated CSV file** + ```shell + kfutil stores import generate-template --store-type-name DataPower --outpath DataPower.csv + ``` +2. **Populate the generated CSV file** + + Open the CSV file, and reference the table below to populate parameters for each **Attribute**. + + | Attribute | Description | + | --------- | ----------- | + | Category | Select "IBM Data Power" or the customized certificate store name from the previous step. | + | Container | Optional container to associate certificate store with. | + | Client Machine | The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. | + | Store Path | The Store Path field should always be / unless we later determine there are alternate locations needed. | + | Orchestrator | Select an approved orchestrator capable of managing `DataPower` certificates. Specifically, one with the `DataPower` capability. | + | Properties.ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | + | Properties.ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | + | Properties.ServerUseSsl | Should be true, http is not supported. | + | Properties.InventoryBlackList | Comma seperated list of alias values you do not want to inventory from DataPower. | + | Properties.Protocol | Comma seperated list of alias values you do not want to inventory from DataPower. | + | Properties.PublicCertStoreName | This probably will remain pubcert unless someone changed the default name in DataPower. | + | Properties.InventoryPageSize | This determines the page size during the inventory calls. (100 should be fine). | + +3. **Import the CSV file to create the certificate stores** - Open the CSV file, and reference the table below to populate parameters for each **Attribute**. - | Attribute | Description | - | --------- | ----------- | - | Category | Select "IBM Data Power" or the customized certificate store name from the previous step. | - | Container | Optional container to associate certificate store with. | - | Client Machine | The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. | - | Store Path | The Store Path field should always be / unless we later determine there are alternate locations needed. | - | Orchestrator | Select an approved orchestrator capable of managing `DataPower` certificates. Specifically, one with the `DataPower` capability. | - | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | - | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | - | ServerUseSsl | Should be true, http is not supported. | - | InventoryBlackList | Comma seperated list of alias values you do not want to inventory from DataPower. | - | Protocol | Comma seperated list of alias values you do not want to inventory from DataPower. | - | PublicCertStoreName | This probably will remain pubcert unless someone changed the default name in DataPower. | - | InventoryPageSize | This determines the page size during the inventory calls. (100 should be fine). | + ```shell + kfutil stores import csv --store-type-name DataPower --file DataPower.csv + ``` +
- -
Attributes eligible for retrieval by a PAM Provider on the Universal Orchestrator +#### PAM Provider Eligible Fields +
Attributes eligible for retrieval by a PAM Provider on the Universal Orchestrator - If a PAM provider was installed _on the Universal Orchestrator_ in the [Installation](#Installation) section, the following parameters can be configured for retrieval _on the Universal Orchestrator_. - | Attribute | Description | - | --------- | ----------- | - | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | - | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | +If a PAM provider was installed _on the Universal Orchestrator_ in the [Installation](#Installation) section, the following parameters can be configured for retrieval _on the Universal Orchestrator_. + | Attribute | Description | + | --------- | ----------- | + | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | + | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | - > Any secret can be rendered by a PAM provider _installed on the Keyfactor Command server_. The above parameters are specific to attributes that can be fetched by an installed PAM provider running on the Universal Orchestrator server itself. -
- +Please refer to the **Universal Orchestrator (remote)** usage section ([PAM providers on the Keyfactor Integration Catalog](https://keyfactor.github.io/integrations-catalog/content/pam)) for your selected PAM provider for instructions on how to load attributes orchestrator-side. +> Any secret can be rendered by a PAM provider _installed on the Keyfactor Command server_. The above parameters are specific to attributes that can be fetched by an installed PAM provider running on the Universal Orchestrator server itself. - 3. **Import the CSV file to create the certificate stores** +
- ```shell - kfutil stores import csv --store-type-name DataPower --file DataPower.csv - ``` -
-> The content in this section can be supplimented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). +> The content in this section can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh new file mode 100755 index 0000000..3e531d8 --- /dev/null +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +# Creates all 1 store types via the Keyfactor Command REST API using curl. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if [ -z "${KEYFACTOR_HOSTNAME}" ]; then + echo "ERROR: KEYFACTOR_HOSTNAME is required" + exit 1 +fi + +BASE_URL="https://${KEYFACTOR_HOSTNAME}/keyfactorapi" + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if [ -n "${KEYFACTOR_AUTH_ACCESS_TOKEN}" ]; then + BEARER_TOKEN="${KEYFACTOR_AUTH_ACCESS_TOKEN}" +elif [ -n "${KEYFACTOR_AUTH_CLIENT_ID}" ] && [ -n "${KEYFACTOR_AUTH_CLIENT_SECRET}" ] && [ -n "${KEYFACTOR_AUTH_TOKEN_URL}" ]; then + echo "Fetching OAuth token..." + BEARER_TOKEN=$(curl -s -X POST "${KEYFACTOR_AUTH_TOKEN_URL}" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=client_credentials" \ + --data-urlencode "client_id=${KEYFACTOR_AUTH_CLIENT_ID}" \ + --data-urlencode "client_secret=${KEYFACTOR_AUTH_CLIENT_SECRET}" | jq -r '.access_token') + if [ -z "${BEARER_TOKEN}" ] || [ "${BEARER_TOKEN}" = "null" ]; then + echo "ERROR: Failed to fetch OAuth token from ${KEYFACTOR_AUTH_TOKEN_URL}" + exit 1 + fi +elif [ -n "${KEYFACTOR_USERNAME}" ] && [ -n "${KEYFACTOR_PASSWORD}" ] && [ -n "${KEYFACTOR_DOMAIN}" ]; then + BEARER_TOKEN="" +else + echo "ERROR: Authentication required. Set one of:" + echo " KEYFACTOR_AUTH_ACCESS_TOKEN" + echo " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL" + echo " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN" + exit 1 +fi + +if [ -n "${BEARER_TOKEN}" ]; then + CURL_AUTH=("-H" "Authorization: Bearer ${BEARER_TOKEN}") +else + CURL_AUTH=("-u" "${KEYFACTOR_USERNAME}@${KEYFACTOR_DOMAIN}:${KEYFACTOR_PASSWORD}") +fi + +create_store_type() { + local name="$1" + local body="$2" + echo "Creating ${name} store type..." + response=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${BASE_URL}/certificatestoretypes" \ + -H "Content-Type: application/json" \ + -H "x-keyfactor-requested-with: APIClient" \ + "${CURL_AUTH[@]}" \ + -d "${body}") + if [ "$response" = "200" ] || [ "$response" = "201" ]; then + echo " OK (HTTP ${response})" + else + echo " FAILED (HTTP ${response})" + fi +} + +# --------------------------------------------------------------------------- +# DataPower — The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. +# --------------------------------------------------------------------------- +create_store_type "DataPower" '{ + "Name": "IBM Data Power", + "ShortName": "DataPower", + "Capability": "DataPower", + "LocalStore": false, + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": true, + "Enrollment": false, + "Remove": false + }, + "Properties": [ + { + "Name": "ServerUseSsl", + "DisplayName": "Use SSL", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "true", + "Required": true + }, + { + "Name": "InventoryBlackList", + "DisplayName": "Inventory Black List", + "Type": "String", + "DependsOn": "", + "DefaultValue": "", + "Required": false, + "IsPAMEligible": false + }, + { + "Name": "Protocol", + "DisplayName": "Protocol Name", + "Type": "String", + "DependsOn": "", + "DefaultValue": "https", + "Required": true, + "IsPAMEligible": false + }, + { + "Name": "PublicCertStoreName", + "DisplayName": "Public Cert Store Name", + "Type": "String", + "DependsOn": "", + "DefaultValue": "pubcert", + "Required": true, + "IsPAMEligible": false + }, + { + "Name": "InventoryPageSize", + "DisplayName": "Inventory Page Size", + "Type": "String", + "DependsOn": "", + "DefaultValue": "100", + "Required": true, + "IsPAMEligible": false + } + ], + "EntryParameters": [], + "StorePathDescription": "The Store Path field should always be / unless we later determine there are alternate locations needed.", + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "PrivateKeyAllowed": "Optional", + "JobProperties": [], + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": false, + "CustomAliasAllowed": "Required" +}' + + +echo "Completed." diff --git a/scripts/store_types/bash/kfutil_create_store_types.sh b/scripts/store_types/bash/kfutil_create_store_types.sh new file mode 100755 index 0000000..768e08d --- /dev/null +++ b/scripts/store_types/bash/kfutil_create_store_types.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if ! command -v kfutil &> /dev/null; then + echo "kfutil could not be found. Please install kfutil" + echo "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +fi + +if [ -z "$KEYFACTOR_HOSTNAME" ]; then + echo "KEYFACTOR_HOSTNAME not set — launching kfutil login" + kfutil login +fi + +kfutil store-types create --name "DataPower" + +echo "Done. All store types created." diff --git a/scripts/store_types/powershell/kfutil_create_store_types.ps1 b/scripts/store_types/powershell/kfutil_create_store_types.ps1 new file mode 100644 index 0000000..7660aff --- /dev/null +++ b/scripts/store_types/powershell/kfutil_create_store_types.ps1 @@ -0,0 +1,29 @@ +# Creates all 1 store types using kfutil. +# kfutil reads definitions from the Keyfactor integration catalog. +# +# Auth environment variables (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD +# + KEYFACTOR_DOMAIN +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +# Uncomment if kfutil is not in your PATH +# Set-Alias -Name kfutil -Value 'C:\Program Files\Keyfactor\kfutil\kfutil.exe' + +if ($null -eq (Get-Command "kfutil" -ErrorAction SilentlyContinue)) { + Write-Host "kfutil could not be found. Please install kfutil" + Write-Host "See https://github.com/Keyfactor/kfutil#quickstart" + exit 1 +} + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Host "KEYFACTOR_HOSTNAME not set — launching kfutil login" + & kfutil login +} + +& kfutil store-types create --name "DataPower" + +Write-Host "Done. All store types created." diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 new file mode 100644 index 0000000..44330f8 --- /dev/null +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -0,0 +1,143 @@ +# Creates all 1 store types via the Keyfactor Command REST API +# using PowerShell Invoke-RestMethod. +# +# Authentication (first matching method is used): +# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN +# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET +# + KEYFACTOR_AUTH_TOKEN_URL +# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN +# +# Always required: +# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) +# +# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. + +if (-not $env:KEYFACTOR_HOSTNAME) { + Write-Error "KEYFACTOR_HOSTNAME is required" + exit 1 +} + +$uri = "https://$($env:KEYFACTOR_HOSTNAME)/keyfactorapi/certificatestoretypes" +$headers = @{ + 'Content-Type' = "application/json" + 'x-keyfactor-requested-with' = "APIClient" +} + +# --------------------------------------------------------------------------- +# Resolve auth +# --------------------------------------------------------------------------- +if ($env:KEYFACTOR_AUTH_ACCESS_TOKEN) { + $headers['Authorization'] = "Bearer $($env:KEYFACTOR_AUTH_ACCESS_TOKEN)" +} elseif ($env:KEYFACTOR_AUTH_CLIENT_ID -and $env:KEYFACTOR_AUTH_CLIENT_SECRET -and $env:KEYFACTOR_AUTH_TOKEN_URL) { + Write-Host "Fetching OAuth token..." + $tokenBody = @{ + grant_type = 'client_credentials' + client_id = $env:KEYFACTOR_AUTH_CLIENT_ID + client_secret = $env:KEYFACTOR_AUTH_CLIENT_SECRET + } + $tokenResp = Invoke-RestMethod -Method Post -Uri $env:KEYFACTOR_AUTH_TOKEN_URL -Body $tokenBody + $headers['Authorization'] = "Bearer $($tokenResp.access_token)" +} elseif ($env:KEYFACTOR_USERNAME -and $env:KEYFACTOR_PASSWORD -and $env:KEYFACTOR_DOMAIN) { + $cred = [System.Convert]::ToBase64String( + [System.Text.Encoding]::ASCII.GetBytes( + "$($env:KEYFACTOR_USERNAME)@$($env:KEYFACTOR_DOMAIN):$($env:KEYFACTOR_PASSWORD)")) + $headers['Authorization'] = "Basic $cred" +} else { + Write-Error ("Authentication required. Set one of:`n" + + " KEYFACTOR_AUTH_ACCESS_TOKEN`n" + + " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL`n" + + " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN") + exit 1 +} + +function New-StoreType { + param([string]$Name, [string]$Body) + Write-Host "Creating $Name store type..." + try { + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $Body -ContentType "application/json" | Out-Null + Write-Host " OK" + } catch { + Write-Warning " FAILED: $($_.Exception.Message)" + } +} + +# --------------------------------------------------------------------------- +# DataPower — The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. +# --------------------------------------------------------------------------- +New-StoreType "DataPower" @' +{ + "Name": "IBM Data Power", + "ShortName": "DataPower", + "Capability": "DataPower", + "LocalStore": false, + "SupportedOperations": { + "Add": true, + "Create": false, + "Discovery": true, + "Enrollment": false, + "Remove": false + }, + "Properties": [ + { + "Name": "ServerUseSsl", + "DisplayName": "Use SSL", + "Type": "Bool", + "DependsOn": "", + "DefaultValue": "true", + "Required": true + }, + { + "Name": "InventoryBlackList", + "DisplayName": "Inventory Black List", + "Type": "String", + "DependsOn": "", + "DefaultValue": "", + "Required": false, + "IsPAMEligible": false + }, + { + "Name": "Protocol", + "DisplayName": "Protocol Name", + "Type": "String", + "DependsOn": "", + "DefaultValue": "https", + "Required": true, + "IsPAMEligible": false + }, + { + "Name": "PublicCertStoreName", + "DisplayName": "Public Cert Store Name", + "Type": "String", + "DependsOn": "", + "DefaultValue": "pubcert", + "Required": true, + "IsPAMEligible": false + }, + { + "Name": "InventoryPageSize", + "DisplayName": "Inventory Page Size", + "Type": "String", + "DependsOn": "", + "DefaultValue": "100", + "Required": true, + "IsPAMEligible": false + } + ], + "EntryParameters": [], + "StorePathDescription": "The Store Path field should always be / unless we later determine there are alternate locations needed.", + "PasswordOptions": { + "EntrySupported": false, + "StoreRequired": false, + "Style": "Default" + }, + "PrivateKeyAllowed": "Optional", + "JobProperties": [], + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": false, + "CustomAliasAllowed": "Required" +} +'@ + + +Write-Host "Completed." From 062b156ffe7a515758f1ca0b456d83c0d24a00a4 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 6 Apr 2026 09:54:35 -0400 Subject: [PATCH 03/21] fixed path documentation --- docs/discovery-overview.html | 813 +++++++++++++++++++++++++++++++++++ docsource/content.md | 79 +++- docsource/datapower.md | 2 + integration-manifest.json | 2 +- readme_source.md | 57 ++- 5 files changed, 946 insertions(+), 7 deletions(-) create mode 100644 docs/discovery-overview.html diff --git a/docs/discovery-overview.html b/docs/discovery-overview.html new file mode 100644 index 0000000..cd2dedb --- /dev/null +++ b/docs/discovery-overview.html @@ -0,0 +1,813 @@ + + + + + + IBM DataPower Orchestrator - Discovery Feature + + + + +
+
NEW FEATURE
+

IBM DataPower Orchestrator — Discovery

+

Automatically discover all domains and certificate stores across your DataPower appliance. No more manually creating hundreds of cert store definitions.

+
+ +
+ + +
+

Before vs. After Discovery

+ +
+
+
Before — Manual Setup
+

Without Discovery

+
    +
  • Admin must know every domain name on the appliance
  • +
  • Each domain + store combination created by hand in Keyfactor Command
  • +
  • 50 domains × 2 stores = 100 cert store definitions manually configured
  • +
  • New domains added to DataPower require manual store creation
  • +
  • Environments (prod, test, dev, sandbox) multiply the effort
  • +
  • Human error risk — missed domains mean unmanaged certificates
  • +
+
+
+
After — Automated Discovery
+

With Discovery

+
    +
  • Point Discovery at the appliance — it finds all domains automatically
  • +
  • All certificate store directories (cert, pubcert, sharedcert) detected per domain
  • +
  • Store paths returned in ready-to-use format: domain\store
  • +
  • New domains picked up on next scheduled Discovery run
  • +
  • Run once per environment to discover everything
  • +
  • Complete coverage — no domains or stores overlooked
  • +
+
+
+
+ + +
+

Customer Scenario

+
+

A typical enterprise DataPower deployment with multiple environments, each containing dozens of domains:

+
+
Environment
+
Impact
+
Production
+
50 domains × 2 cert stores = 100 store definitions auto-discovered in one job
+
Test
+
40 domains × 2 cert stores = 80 store definitions auto-discovered in one job
+
Dev
+
30 domains × 2 cert stores = 60 store definitions auto-discovered in one job
+
Sandbox
+
20 domains × 2 cert stores = 40 store definitions auto-discovered in one job
+
Total
+
280 cert store definitions — discovered automatically with 4 Discovery jobs
+
+
+
+ + +
+

How Discovery Works

+
+
+ +
+
1
+
+
Keyfactor Triggers Discovery Job
+

Keyfactor Command schedules a Discovery job targeting the DataPower appliance. Only the appliance hostname/IP and credentials are needed — no domain or store path required.

+
+
+ +
+
+ +
+
+
+ +
+
2
+
+
Enumerate All Domains
+

The orchestrator calls GET /mgmt/domains/config/ on the DataPower REST Management Interface. This returns every application domain configured on the appliance.

+
+
+ +
+
+ +
+
+
+ +
+
3
+
+
Discover Certificate Stores Per Domain
+

For each domain found, the orchestrator calls GET /mgmt/filestore/{domain} to list the top-level filestore directories. It filters for certificate-relevant directories: cert, pubcert, and sharedcert.

+
+
+ +
+
+ +
+
+
+ +
+
4
+
+
Build Store Paths
+

Each domain + directory combination is formatted as a store path using the existing convention: domain\directory (e.g., production-api\cert).

+
+
+ +
+
+ +
+
+
+ +
+
5
+
+
Submit to Keyfactor Command
+

All discovered store paths are submitted back to Keyfactor via the SubmitDiscoveryUpdate callback. Keyfactor Command can then auto-create the corresponding certificate store definitions.

+
+
+ +
+
+ +
+

Resilient by design: If the orchestrator cannot access a specific domain's filestore (e.g., due to permissions), it logs a warning and continues discovering the remaining domains. One inaccessible domain does not block the entire job.

+
+
+ + +
+

Store Path Format

+

All operations in the DataPower Orchestrator (Discovery, Inventory, Add, Remove) use a consistent store path format to identify which domain and certificate directory to target. For full documentation, see content.md — Store Path Format.

+ +
+
+ <domain>\<directory> +
+ +
+
+
Directory
+
Scope
+
+
+
+ +
+ cert +
Per-domain — private keys & certs
+
+
+ pubcert +
Appliance-wide — public/trusted certs
+
+
+ sharedcert +
Appliance-wide — persists across upgrades
+
+
+ +
+ default\pubcert + production-api\cert + staging\pubcert + testdomain\sharedcert +
+
+
+ + +
+

Example Discovery Output

+

For a DataPower appliance with 4 domains, Discovery returns store paths like:

+
+// Discovered 8 certificate store locations: +default\cert +default\pubcert +production-api\cert +production-api\pubcert +staging-api\cert +staging-api\pubcert +internal-services\cert +internal-services\pubcert +
+

Each of these becomes a certificate store in Keyfactor Command, ready for Inventory and Management operations.

+
+ + +
+

DataPower API Calls Used

+ +
+
+ GET + /mgmt/domains/config/ +
+
+

Returns all application domains configured on the DataPower appliance.

+
+{ + "domain": [ + { "name": "default" }, + { "name": "production-api" }, + { "name": "staging-api" }, + { "name": "internal-services" } + ] +} +
+
+
+ +
+
+ GET + /mgmt/filestore/{domain} +
+
+

Returns the top-level filestore directories for a specific domain. The orchestrator filters for cert, pubcert, and sharedcert.

+
+{ + "filestore": { + "directory": [ + { "name": "cert" }, + { "name": "chkpoints" }, // ignored + { "name": "config" }, // ignored + { "name": "local" }, // ignored + { "name": "pubcert" }, + { "name": "sharedcert" } + ] + } +} +
+
+
+ +
+

DataPower JSON quirk: When only a single item exists (e.g., one domain or one directory), DataPower returns a plain object instead of a single-element array. The orchestrator handles this automatically, consistent with how the existing Inventory job handles the same behavior.

+
+
+ + +
+

Implementation Architecture

+ +

New Files

+
+
+
Jobs/Discovery.cs
+

Main Discovery job class. Implements IDiscoveryJobExtension. Orchestrates domain enumeration and store path collection.

+
+
+
Models/Requests/ListDomainsRequest.cs
+

HTTP request model for GET /mgmt/domains/config/

+
+
+
Models/Requests/ListFileStoreRequest.cs
+

HTTP request model for GET /mgmt/filestore/{domain}

+
+
+
Models/Responses/ListDomainsResponse.cs
+

Response deserialization for domain listing. Includes single-item variant for DataPower JSON quirk.

+
+
+
Models/Responses/ListFileStoreResponse.cs
+

Response deserialization for filestore directory listing.

+
+
+
Models/SupportingObjects/DomainInfo.cs
+

Domain name and href properties from the DataPower domains API.

+
+
+
Models/SupportingObjects/FileStoreDirectory.cs
+

Directory name and href properties from the filestore API.

+
+
+ +

Modified Files

+
+
+
Client/DataPowerClient.cs
+

Added ListDomains() and ListFileStoreDirectories() methods with single-item JSON quirk handling.

+
+
+
manifest.json
+

Registered CertStores.DataPower.Discovery job extension type.

+
+
+
integration-manifest.json
+

Set supportsDiscovery: true and Discovery: true in supported operations.

+
+
+
+ +
+ +
+ IBM DataPower Orchestrator — Keyfactor Integration — Discovery Feature Documentation +
+ + + diff --git a/docsource/content.md b/docsource/content.md index dd02147..6f8f212 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -1,12 +1,87 @@ ## Overview -The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. +The IBM DataPower Orchestrator allows for the management of certificates in the IBM DataPower platform. Discovery, Inventory, Add and Remove functions are supported. This integration can manage certificates in any domain and certificate store directory on a DataPower appliance. * DataPower ## Requirements -The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. For example default\pubcert +The IBM DataPower Orchestrator requires: +- A DataPower appliance with the REST Management Interface enabled (typically port 5554) +- API credentials with access to certificate management operations +- HTTPS connectivity between the Keyfactor Orchestrator and the DataPower appliance + +## Store Path Format + +The Store Path identifies which domain and certificate store directory to target on the DataPower appliance. All Inventory, Management (Add/Remove), and Discovery operations use this format. + +### Format + +``` +\ +``` + +The path is composed of two parts separated by a backslash (`\`) or forward slash (`/`): + +| Part | Description | Examples | +|------|-------------|----------| +| **Domain** | The DataPower application domain. Every DataPower appliance has at least a `default` domain. Additional domains are created for environment or application isolation. | `default`, `production-api`, `staging`, `internal-services` | +| **Directory** | The certificate store directory within that domain. DataPower has several standard directories for certificate storage. | `cert`, `pubcert`, `sharedcert` | + +### Certificate Store Directories + +| Directory | Scope | Contents | +|-----------|-------|----------| +| `cert` | Per-domain | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | +| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | +| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | + +### Examples + +| Store Path | Description | +|------------|-------------| +| `default\pubcert` | Public certificate store in the default domain | +| `default\cert` | Private key certificate store in the default domain | +| `production-api\cert` | Private key certificates in the production-api domain | +| `testdomain\pubcert` | Public certificates in the testdomain domain | + +> **Tip:** The Discovery job can automatically find all valid domain and directory combinations on an appliance, eliminating the need to manually determine store paths. See [Discovery](#discovery) below. + +## Discovery + +The Discovery job automatically enumerates all domains and certificate store directories on a DataPower appliance. This is especially useful for environments with many domains, as it eliminates the need to manually create certificate store definitions. + +### How It Works + +1. **Enumerate domains** — calls `GET /mgmt/domains/config/` to list every application domain on the appliance +2. **Discover stores per domain** — for each domain, calls `GET /mgmt/filestore/{domain}` to list the filestore directories +3. **Filter to certificate directories** — keeps only certificate-relevant directories (`cert`, `pubcert`, `sharedcert`) +4. **Return store paths** — submits the discovered paths (e.g., `production-api\cert`) to Keyfactor Command + +### Configuration + +Discovery requires only the appliance connection details — no store path is needed: + +| Field | Description | +|-------|-------------| +| Client Machine | The DataPower appliance hostname/IP and REST API port (e.g., `datapower.example.com:5554`) | +| Server Username | API username for DataPower (PAM eligible) | +| Server Password | API password for DataPower (PAM eligible) | + +### Example + +Running Discovery against an appliance with 3 domains returns paths like: + +``` +default\cert +default\pubcert +production-api\cert +production-api\pubcert +staging\cert +staging\pubcert +``` + +Each discovered path can become a certificate store definition in Keyfactor Command, ready for Inventory and Management operations. ## Test Cases diff --git a/docsource/datapower.md b/docsource/datapower.md index 002304d..1253823 100644 --- a/docsource/datapower.md +++ b/docsource/datapower.md @@ -2,3 +2,5 @@ ### Overview +The IBM DataPower Orchestrator supports Discovery, Inventory, Add, and Remove operations for certificates on DataPower appliances. For details on how store paths work across all operations, see the [Store Path Format](content.md#store-path-format) section in the main content documentation. + diff --git a/integration-manifest.json b/integration-manifest.json index 42c6488..c4a9665 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -121,7 +121,7 @@ ], "EntryParameters": [], "ClientMachineDescription": "The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used.", - "StorePathDescription": "The Store Path field should always be / unless we later determine there are alternate locations needed.", + "StorePathDescription": "The store path uses the format domain\\directory (e.g., default\\pubcert, production-api\\cert). The Discovery job can automatically find all valid store paths on an appliance.", "PasswordOptions": { "EntrySupported": false, "StoreRequired": false, diff --git a/readme_source.md b/readme_source.md index b8ceafa..631236f 100644 --- a/readme_source.md +++ b/readme_source.md @@ -1,8 +1,57 @@ -**IBM Datapower** +**IBM DataPower** **Overview** -The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. For example default\pubcert +The IBM DataPower Orchestrator allows for the management of certificates in the IBM DataPower platform. Discovery, Inventory, Add and Remove functions are supported. This integration can manage certificates in any domain and certificate store directory on a DataPower appliance. + +For details on how store paths work, see the [Store Path Format](#store-path-format) section below. + +--- + +**Store Path Format** + +The Store Path identifies which domain and certificate store directory to target on the DataPower appliance. The format is: + +``` +\ +``` + +| Part | Description | Examples | +|------|-------------|----------| +| **Domain** | The DataPower application domain | `default`, `production-api`, `staging` | +| **Directory** | The certificate store directory | `cert`, `pubcert`, `sharedcert` | + +**Certificate Store Directories:** + +| Directory | Scope | Contents | +|-----------|-------|----------| +| `cert` | Per-domain | Domain-specific certificates and private keys | +| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | +| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | + +**Examples:** `default\pubcert`, `production-api\cert`, `testdomain\pubcert` + +> **Tip:** Use the Discovery job to automatically find all valid store paths on an appliance. + +--- + +**Discovery** + +The Discovery job automatically enumerates all domains and certificate store directories on a DataPower appliance. This eliminates the need to manually create certificate store definitions, especially useful for environments with many domains. + +**How it works:** +1. Calls `GET /mgmt/domains/config/` to list all application domains +2. For each domain, calls `GET /mgmt/filestore/{domain}` to find certificate directories +3. Filters for `cert`, `pubcert`, and `sharedcert` directories +4. Returns store paths (e.g., `production-api\cert`) to Keyfactor Command + +**Discovery Configuration:** + +CONFIG ELEMENT|DESCRIPTION +--------------|----------- +Client Machine|The DataPower appliance hostname/IP and REST API port (e.g., `datapower.example.com:5554`) +Server Username|API username for DataPower (PAM eligible) +Server Password|API password for DataPower (PAM eligible) --- @@ -14,7 +63,7 @@ SETTING TAB | CONFIG ELEMENT | DESCRIPTION Basic |Name |Descriptive name for the Store Type. IBM Data Power Universal can be used. Basic |Short Name |The short name that identifies the registered functionality of the orchestrator. Must be DataPower. Basic |Custom Capability|Unchecked -Basic |Job Types |Inventory, Add, and Remove are the supported job types. +Basic |Job Types |Discovery, Inventory, Add, and Remove are the supported job types. Basic |Needs Server |Must be checked Basic |Blueprint Allowed |checked Basic |Requires Store Password |Determines if a store password is required when configuring an individual store. This must be unchecked. @@ -45,7 +94,7 @@ CONFIG ELEMENT |DESCRIPTION Category |The type of certificate store to be configured. Select category based on the display name configured above "IBM Data Power Universal". Container |This is a logical grouping of like stores. This configuration is optional and does not impact the functionality of the store. Client Machine | The server and port the DataPower API runs on. This is typically port 5554 for the API. -Store Path |This will the domain\path combination to enroll and inventory to. If it is the default domain just put the path. +Store Path |The `domain\directory` store path targeting a specific domain and certificate store. See [Store Path Format](#store-path-format) above. Examples: `default\pubcert`, `production-api\cert`. The Discovery job can find these automatically. Inventory Page Size|This determines the page size during the inventory calls. (100 should be fine). Public Cert Store Name| This probably will remain pubcert unless someone changed the default name in DataPower. Protocol| This should always be https in production, may need to change in test to http. From bd053b62dfa72bdbc0e36fc1431d54221b2b9e5e Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Mon, 6 Apr 2026 13:55:56 +0000 Subject: [PATCH 04/21] Update generated docs --- README.md | 85 +++++++++++++++++-- .../bash/curl_create_store_types.sh | 2 +- .../restmethod_create_store_types.ps1 | 2 +- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e5e382f..057bd7e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ ## Overview -The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. +The IBM DataPower Orchestrator allows for the management of certificates in the IBM DataPower platform. Discovery, Inventory, Add and Remove functions are supported. This integration can manage certificates in any domain and certificate store directory on a DataPower appliance. * DataPower @@ -51,7 +51,10 @@ The DataPower Universal Orchestrator extension is supported by Keyfactor. If you Before installing the DataPower Universal Orchestrator extension, we recommend that you install [kfutil](https://github.com/Keyfactor/kfutil). Kfutil is a command-line tool that simplifies the process of creating store types, installing extensions, and instantiating certificate stores in Keyfactor Command. -The IBM DataPower Orchestrator allows for the management of certificates in the IBM Datapower platform. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. For example default\pubcert +The IBM DataPower Orchestrator requires: +- A DataPower appliance with the REST Management Interface enabled (typically port 5554) +- API credentials with access to certificate management operations +- HTTPS connectivity between the Keyfactor Orchestrator and the DataPower appliance ## DataPower Certificate Store Type @@ -60,7 +63,7 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D - +The IBM DataPower Orchestrator supports Discovery, Inventory, Add, and Remove operations for certificates on DataPower appliances. For details on how store paths work across all operations, see the [Store Path Format](content.md#store-path-format) section in the main content documentation. @@ -290,7 +293,7 @@ the Keyfactor Command Portal | Category | Select "IBM Data Power" or the customized certificate store name from the previous step. | | Container | Optional container to associate certificate store with. | | Client Machine | The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. | - | Store Path | The Store Path field should always be / unless we later determine there are alternate locations needed. | + | Store Path | The store path uses the format domain\directory (e.g., default\pubcert, production-api\cert). The Discovery job can automatically find all valid store paths on an appliance. | | Orchestrator | Select an approved orchestrator capable of managing `DataPower` certificates. Specifically, one with the `DataPower` capability. | | ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | | ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | @@ -322,7 +325,7 @@ the Keyfactor Command Portal | Category | Select "IBM Data Power" or the customized certificate store name from the previous step. | | Container | Optional container to associate certificate store with. | | Client Machine | The Client Machine field should contain the IP or Domain name and Port Needed for REST API Access. For SSH Access, Port 22 will be used. | - | Store Path | The Store Path field should always be / unless we later determine there are alternate locations needed. | + | Store Path | The store path uses the format domain\directory (e.g., default\pubcert, production-api\cert). The Discovery job can automatically find all valid store paths on an appliance. | | Orchestrator | Select an approved orchestrator capable of managing `DataPower` certificates. Specifically, one with the `DataPower` capability. | | Properties.ServerUsername | Api UserName for DataPower. (or valid PAM key if the username is stored in a KF Command configured PAM integration). | | Properties.ServerPassword | A password for DataPower API access. Used for inventory.(or valid PAM key if the password is stored in a KF Command configured PAM integration). | @@ -360,7 +363,79 @@ Please refer to the **Universal Orchestrator (remote)** usage section ([PAM prov > The content in this section can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). +## Discovering Certificate Stores with the Discovery Job +The Discovery job automatically enumerates all domains and certificate store directories on a DataPower appliance. This is especially useful for environments with many domains, as it eliminates the need to manually create certificate store definitions. + +### How It Works + +1. **Enumerate domains** — calls `GET /mgmt/domains/config/` to list every application domain on the appliance +2. **Discover stores per domain** — for each domain, calls `GET /mgmt/filestore/{domain}` to list the filestore directories +3. **Filter to certificate directories** — keeps only certificate-relevant directories (`cert`, `pubcert`, `sharedcert`) +4. **Return store paths** — submits the discovered paths (e.g., `production-api\cert`) to Keyfactor Command + +### Configuration + +Discovery requires only the appliance connection details — no store path is needed: + +| Field | Description | +|-------|-------------| +| Client Machine | The DataPower appliance hostname/IP and REST API port (e.g., `datapower.example.com:5554`) | +| Server Username | API username for DataPower (PAM eligible) | +| Server Password | API password for DataPower (PAM eligible) | + +### Example + +Running Discovery against an appliance with 3 domains returns paths like: + +``` +default\cert +default\pubcert +production-api\cert +production-api\pubcert +staging\cert +staging\pubcert +``` + +Each discovered path can become a certificate store definition in Keyfactor Command, ready for Inventory and Management operations. + + + + +## Store Path Format + +The Store Path identifies which domain and certificate store directory to target on the DataPower appliance. All Inventory, Management (Add/Remove), and Discovery operations use this format. + +### Format + +``` +\ +``` + +The path is composed of two parts separated by a backslash (`\`) or forward slash (`/`): + +| Part | Description | Examples | +|------|-------------|----------| +| **Domain** | The DataPower application domain. Every DataPower appliance has at least a `default` domain. Additional domains are created for environment or application isolation. | `default`, `production-api`, `staging`, `internal-services` | +| **Directory** | The certificate store directory within that domain. DataPower has several standard directories for certificate storage. | `cert`, `pubcert`, `sharedcert` | + +### Certificate Store Directories + +| Directory | Scope | Contents | +|-----------|-------|----------| +| `cert` | Per-domain | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | +| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | +| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | + +### Examples + +| Store Path | Description | +|------------|-------------| +| `default\pubcert` | Public certificate store in the default domain | +| `default\cert` | Private key certificate store in the default domain | +| `production-api\cert` | Private key certificates in the production-api domain | +| `testdomain\pubcert` | Public certificates in the testdomain domain | +> **Tip:** The Discovery job can automatically find all valid domain and directory combinations on an appliance, eliminating the need to manually determine store paths. See [Discovery](#discovery) below. ## Test Cases diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 3e531d8..afbab29 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -131,7 +131,7 @@ create_store_type "DataPower" '{ } ], "EntryParameters": [], - "StorePathDescription": "The Store Path field should always be / unless we later determine there are alternate locations needed.", + "StorePathDescription": "The store path uses the format domain\\directory (e.g., default\\pubcert, production-api\\cert). The Discovery job can automatically find all valid store paths on an appliance.", "PasswordOptions": { "EntrySupported": false, "StoreRequired": false, diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index 44330f8..58318bb 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -124,7 +124,7 @@ New-StoreType "DataPower" @' } ], "EntryParameters": [], - "StorePathDescription": "The Store Path field should always be / unless we later determine there are alternate locations needed.", + "StorePathDescription": "The store path uses the format domain\\directory (e.g., default\\pubcert, production-api\\cert). The Discovery job can automatically find all valid store paths on an appliance.", "PasswordOptions": { "EntrySupported": false, "StoreRequired": false, From 44d62074de51dcc3683ff77e1c38e63095ee7c18 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 28 Apr 2026 10:24:39 -0400 Subject: [PATCH 05/21] Add Discovery feature spec documentation Markdown source and generated PDF describing the Discovery job: before/after comparison, step-by-step flow, store path format, DataPower API endpoints used, and implementation architecture. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/discovery-overview.md | 175 ++++++++++++++++++++++++++++++++++++ docs/discovery-overview.pdf | Bin 0 -> 49672 bytes 2 files changed, 175 insertions(+) create mode 100644 docs/discovery-overview.md create mode 100644 docs/discovery-overview.pdf diff --git a/docs/discovery-overview.md b/docs/discovery-overview.md new file mode 100644 index 0000000..ddc22ed --- /dev/null +++ b/docs/discovery-overview.md @@ -0,0 +1,175 @@ +# IBM DataPower Orchestrator - Discovery Feature + +> **NEW FEATURE** | Automatically discover all domains and certificate stores across your DataPower appliance. No more manually creating hundreds of cert store definitions. + +--- + +## Before vs. After Discovery + +### Without Discovery (Manual Setup) + +- Admin must know every domain name on the appliance +- Each domain + store combination created by hand in Keyfactor Command +- 50 domains x 2 stores = 100 cert store definitions manually configured +- New domains added to DataPower require manual store creation +- Environments (prod, test, dev, sandbox) multiply the effort +- Human error risk - missed domains mean unmanaged certificates + +### With Discovery (Automated) + +- Point Discovery at the appliance - it finds all domains automatically +- All certificate store directories (`cert`, `pubcert`, `sharedcert`) detected per domain +- Store paths returned in ready-to-use format: `domain\store` +- New domains picked up on next scheduled Discovery run +- Run once per environment to discover everything +- Complete coverage - no domains or stores overlooked + +--- + +## Customer Scenario + +A typical enterprise DataPower deployment with multiple environments, each containing dozens of domains: + +| Environment | Impact | +|-------------|--------| +| Production | 50 domains x 2 cert stores = **100 store definitions** auto-discovered in one job | +| Test | 40 domains x 2 cert stores = **80 store definitions** auto-discovered in one job | +| Dev | 30 domains x 2 cert stores = **60 store definitions** auto-discovered in one job | +| Sandbox | 20 domains x 2 cert stores = **40 store definitions** auto-discovered in one job | +| **Total** | **280 cert store definitions** - discovered automatically with 4 Discovery jobs | + +--- + +## How Discovery Works + +### Step 1: Keyfactor Triggers Discovery Job + +Keyfactor Command schedules a Discovery job targeting the DataPower appliance. Only the appliance hostname/IP and credentials are needed - no domain or store path required. + +### Step 2: Enumerate All Domains + +The orchestrator calls `GET /mgmt/domains/config/` on the DataPower REST Management Interface. This returns every application domain configured on the appliance. + +### Step 3: Discover Certificate Stores Per Domain + +For each domain found, the orchestrator calls `GET /mgmt/filestore/{domain}` to list the top-level filestore directories. It filters for certificate-relevant directories: `cert`, `pubcert`, and `sharedcert`. + +### Step 4: Build Store Paths + +Each domain + directory combination is formatted as a store path using the existing convention: `domain\directory` (e.g., `production-api\cert`). + +### Step 5: Submit to Keyfactor Command + +All discovered store paths are submitted back to Keyfactor via the `SubmitDiscoveryUpdate` callback. Keyfactor Command can then auto-create the corresponding certificate store definitions. + +> **Resilient by design:** If the orchestrator cannot access a specific domain's filestore (e.g., due to permissions), it logs a warning and continues discovering the remaining domains. One inaccessible domain does not block the entire job. + +--- + +## Store Path Format + +All operations in the DataPower Orchestrator (Discovery, Inventory, Add, Remove) use a consistent store path format: + +``` +\ +``` + +### Certificate Store Directories + +| Directory | Scope | Contents | +|---------------|-----------------|----------| +| `cert` | Per-domain | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | +| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | +| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | + +### Examples + +| Store Path | Description | +|------------|-------------| +| `default\pubcert` | Public certificate store in the default domain | +| `production-api\cert` | Private key certificates in the production-api domain | +| `staging\pubcert` | Public certificates in the staging domain | +| `testdomain\sharedcert` | Shared certificates in the testdomain domain | + +--- + +## Example Discovery Output + +For a DataPower appliance with 4 domains, Discovery returns store paths like: + +``` +default\cert +default\pubcert +production-api\cert +production-api\pubcert +staging-api\cert +staging-api\pubcert +internal-services\cert +internal-services\pubcert +``` + +Each of these becomes a certificate store in Keyfactor Command, ready for Inventory and Management operations. + +--- + +## DataPower API Calls Used + +### `GET /mgmt/domains/config/` + +Returns all application domains configured on the DataPower appliance. + +```json +{ + "domain": [ + { "name": "default" }, + { "name": "production-api" }, + { "name": "staging-api" }, + { "name": "internal-services" } + ] +} +``` + +### `GET /mgmt/filestore/{domain}` + +Returns the top-level filestore directories for a specific domain. The orchestrator filters for `cert`, `pubcert`, and `sharedcert`. + +```json +{ + "filestore": { + "directory": [ + { "name": "cert" }, + { "name": "chkpoints" }, + { "name": "config" }, + { "name": "local" }, + { "name": "pubcert" }, + { "name": "sharedcert" } + ] + } +} +``` + +> **DataPower JSON quirk:** When only a single item exists (e.g., one domain or one directory), DataPower returns a plain object instead of a single-element array. The orchestrator handles this automatically, consistent with how the existing Inventory job handles the same behavior. + +--- + +## Implementation Architecture + +### New Files + +| File | Description | +|------|-------------| +| `Jobs/Discovery.cs` | Main Discovery job class. Implements `IDiscoveryJobExtension`. Orchestrates domain enumeration and store path collection. | +| `Models/Requests/ListDomainsRequest.cs` | HTTP request model for `GET /mgmt/domains/config/` | +| `Models/Requests/ListFileStoreRequest.cs` | HTTP request model for `GET /mgmt/filestore/{domain}` | +| `Models/Responses/ListDomainsResponse.cs` | Response deserialization for domain listing. Includes single-item variant for DataPower JSON quirk. | +| `Models/Responses/ListFileStoreResponse.cs` | Response deserialization for filestore directory listing. | +| `Models/SupportingObjects/DomainInfo.cs` | Domain name and href properties from the DataPower domains API. | +| `Models/SupportingObjects/FileStoreDirectory.cs` | Directory name and href properties from the filestore API. | + +### Modified Files + +| File | Description | +|------|-------------| +| `Client/DataPowerClient.cs` | Added `ListDomains()` and `ListFileStoreDirectories()` methods with single-item JSON quirk handling. | +| `manifest.json` | Registered `CertStores.DataPower.Discovery` job extension type. | +| `integration-manifest.json` | Set `supportsDiscovery: true` and `Discovery: true` in supported operations. | diff --git a/docs/discovery-overview.pdf b/docs/discovery-overview.pdf new file mode 100644 index 0000000000000000000000000000000000000000..579526825ff71a4450963ddcf970e38243f59a59 GIT binary patch literal 49672 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

0P(HnB9DhjKG8%>!Ny@f(hxQjt# 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°qNqf376wd_Qg~?ROfKPR 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 Date: Tue, 28 Apr 2026 10:37:26 -0400 Subject: [PATCH 06/21] Add FlowLogger and hardening across all jobs Ports the canonical FlowLogger pattern from barracuda-waf-orchestrator's split-store-types branch and applies the orchestrator hardening checklist. New files: - DataPower/FlowLogger.cs - step-oriented breadcrumb logger appended to JobResult.FailureMessage on both success and failure - DataPower/Client/DataPowerApiException.cs - typed API error carrying HTTP status + response body, with Find() walker for AggregateException - DataPower/Jobs/JobBase.cs - shared plumbing for PAM resolution, JobResult helpers, and exception unwrapping Hardening: - Null/empty argument validation at every public job boundary - Streams disposed via using blocks - DataPowerClient.ApiRequestString throws DataPowerApiException on WebException with status code and trimmed response body - Trace logs mask sensitive payload fields (content, Password, PasswordAlias) before serializing the request body - PAM fields resolved with warn-on-empty fallback - Inventory, Management, and Discovery now derive from JobBase and wrap ProcessJob bodies in using FlowLogger blocks .gitignore additions: .claude/ (per-machine IDE state), .secrets/, *.env Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 8 + CHANGELOG.md | 52 ++++- DataPower/Client/DataPowerApiException.cs | 72 +++++++ DataPower/Client/DataPowerClient.cs | 101 +++++++-- DataPower/FlowLogger.cs | 243 ++++++++++++++++++++++ DataPower/Jobs/Discovery.cs | 174 ++++++++++------ DataPower/Jobs/Inventory.cs | 131 +++++++----- DataPower/Jobs/JobBase.cs | 126 +++++++++++ DataPower/Jobs/Management.cs | 146 ++++++++----- 9 files changed, 870 insertions(+), 183 deletions(-) create mode 100644 DataPower/Client/DataPowerApiException.cs create mode 100644 DataPower/FlowLogger.cs create mode 100644 DataPower/Jobs/JobBase.cs diff --git a/.gitignore b/.gitignore index dfcfd56..312edd2 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,11 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# Claude Code per-machine IDE state +.claude/ + +# Secrets - never commit credentials, PATs, or environment files +.secrets/ +*.env + diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3bc6b..7190db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,58 @@ -1.1.2 +## 1.2.0 - unreleased + +### Added +* Discovery job: automatically enumerates all application domains on a DataPower + appliance via `GET /mgmt/domains/config/`, then queries each domain's filestore + via `GET /mgmt/filestore/{domain}` to surface certificate store directories + (`cert`, `pubcert`, `sharedcert`). Eliminates manual creation of certificate + stores per domain in environments with many domains. +* `FlowLogger` step-oriented breadcrumb logger; every job (Inventory, Management, + Discovery) wraps its `ProcessJob` body in a `using (var flow = new FlowLogger(...))` + block. The step summary is appended to `JobResult.FailureMessage` on both success + and failure so operators can scan job-history without enabling trace logs. +* `DataPowerApiException`: typed API error carrying HTTP status code and trimmed + response body. `Find()` walker unwraps `AggregateException` chains so + `JobBase.DescribeException` can surface the underlying HTTP detail to operators. +* `JobBase`: shared plumbing for PAM resolution (warn-on-empty fallback), JobResult + helpers that auto-append flow summaries, and exception unwrapping. +* Spec documentation for the Discovery feature: `docs/discovery-overview.md`, + `docs/discovery-overview.pdf`, and the original HTML version. + +### Changed +* `DataPowerClient.ApiRequestString` now throws the typed `DataPowerApiException` + on `WebException`, capturing the HTTP status and response body from the failed + response. Previously errors were re-thrown as raw `WebException`. +* Trace logging masks sensitive payload fields (`content`, `Password`, + `PasswordAlias`) before serializing the request body to the log. +* Request and response streams are now wrapped in `using` blocks for deterministic + disposal. +* `Inventory`, `Management`, and `Discovery` jobs all derive from `JobBase` and + perform null-argument validation at the public boundary before any work begins. + +### Documentation +* `docsource/content.md` and `readme_source.md` now have a dedicated "Store Path + Format" section explaining `\`, the three certificate + directory types (`cert`, `pubcert`, `sharedcert`), and their scoping. +* Discovery section added to both source docs. +* `.gitignore` updated to exclude `.claude/` (per-machine IDE state), + `.secrets/`, and `*.env` files. + +## 1.1.2 + * Added Support for new version of Data Power and Backwards for Old Versions After Data Power API Breaking Changes -1.1.1 +## 1.1.1 + * Dual Build .Net 6 and .Net 8 support * Test Tool Modifications * Readme Updates -1.1.0 +## 1.1.0 + * Convert to Universal Orchestrator Framework * Added Support for .cer files during inventory * Added PAM Support -1.0.0 -* Windows Orchestrator with Add, Remove and Inventory Capabilities +## 1.0.0 +* Windows Orchestrator with Add, Remove and Inventory Capabilities diff --git a/DataPower/Client/DataPowerApiException.cs b/DataPower/Client/DataPowerApiException.cs new file mode 100644 index 0000000..b8ec581 --- /dev/null +++ b/DataPower/Client/DataPowerApiException.cs @@ -0,0 +1,72 @@ +// Copyright 2024 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 System; +using System.Net; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Client +{ + ///

+ /// Thrown by when the DataPower REST Management Interface + /// returns a non-success status. Carries the HTTP status code and trimmed response body + /// so callers can branch on specific conditions and operators can see what the appliance + /// actually said. + /// + public class DataPowerApiException : Exception + { + public HttpStatusCode StatusCode { get; } + public string Operation { get; } + public string ResponseBody { get; } + + public DataPowerApiException(string message, HttpStatusCode statusCode, string operation, string responseBody) + : base(message) + { + StatusCode = statusCode; + Operation = operation; + ResponseBody = responseBody; + } + + public DataPowerApiException(string message, HttpStatusCode statusCode, string operation, string responseBody, Exception inner) + : base(message, inner) + { + StatusCode = statusCode; + Operation = operation; + ResponseBody = responseBody; + } + + /// + /// Walks an exception chain (including ) and returns the + /// first found, or null if none is present. + /// + public static DataPowerApiException Find(Exception ex) + { + while (ex != null) + { + if (ex is DataPowerApiException api) + return api; + if (ex is AggregateException agg) + { + foreach (var inner in agg.InnerExceptions) + { + var found = Find(inner); + if (found != null) return found; + } + return null; + } + ex = ex.InnerException; + } + return null; + } + } +} diff --git a/DataPower/Client/DataPowerClient.cs b/DataPower/Client/DataPowerClient.cs index 41ad0f9..758ccde 100644 --- a/DataPower/Client/DataPowerClient.cs +++ b/DataPower/Client/DataPowerClient.cs @@ -472,8 +472,10 @@ public string ApiRequestString(string strCall, string strPostUrl, string strMeth _logger.LogTrace($"BaseUrl: {BaseUrl}"); _logger.LogTrace($"url: {strPostUrl}"); _logger.LogTrace($"strMethod: {strMethod}"); - _logger.LogTrace($"strQueryString: {strQueryString}"); + // Mask sensitive payload fields (cert/key material, passwords) before logging + _logger.LogTrace($"strQueryString: {MaskSensitivePayload(strQueryString)}"); + HttpWebResponse objResponse = null; try { ServicePointManager.ServerCertificateValidationCallback = delegate { return true; }; @@ -491,25 +493,100 @@ public string ApiRequestString(string strCall, string strPostUrl, string strMeth var postBytes = Encoding.UTF8.GetBytes(strQueryString); objRequest.ContentLength = postBytes.Length; - var requestStream = objRequest.GetRequestStream(); - requestStream.Write(postBytes, 0, postBytes.Length); - requestStream.Close(); + using (var requestStream = objRequest.GetRequestStream()) + { + requestStream.Write(postBytes, 0, postBytes.Length); + } } - var objResponse = (HttpWebResponse) objRequest.GetResponse(); - var strResponse = - new StreamReader(objResponse.GetResponseStream() ?? throw new InvalidOperationException()) - .ReadToEnd(); - _logger.LogTrace($"strResponse: {strResponse}"); - _logger.LogTrace("END APIRequestString"); + objResponse = (HttpWebResponse) objRequest.GetResponse(); + using (var stream = objResponse.GetResponseStream() ?? throw new InvalidOperationException("Response stream is null.")) + using (var reader = new StreamReader(stream)) + { + var strResponse = reader.ReadToEnd(); + _logger.LogTrace($"strResponse: {strResponse}"); + _logger.LogTrace("END APIRequestString"); + return strResponse; + } + } + catch (WebException webEx) + { + // Extract status code and response body from the failed response so the + // typed DataPowerApiException can carry both back to the operator. + var statusCode = HttpStatusCode.InternalServerError; + var responseBody = string.Empty; + + if (webEx.Response is HttpWebResponse errorResponse) + { + statusCode = errorResponse.StatusCode; + try + { + using (var stream = errorResponse.GetResponseStream()) + { + if (stream != null) + { + using (var reader = new StreamReader(stream)) + { + responseBody = reader.ReadToEnd(); + } + } + } + } + catch (Exception readEx) + { + _logger.LogWarning(readEx, "Failed to read error response body for {Operation}.", strCall); + } + finally + { + errorResponse.Dispose(); + } + } - return strResponse; + _logger.LogError(webEx, "END APIRequestString error: {ErrorMessage}", LogHandler.FlattenException(webEx)); + throw new DataPowerApiException( + $"DataPower API call '{strCall}' failed with HTTP {(int)statusCode} {statusCode}.", + statusCode, strCall, responseBody, webEx); + } + catch (DataPowerApiException) + { + // Already typed - just rethrow + throw; } catch (Exception ex) { - _logger.LogError($"END APIRequestString error: {LogHandler.FlattenException(ex)}"); + _logger.LogError(ex, "END APIRequestString error: {ErrorMessage}", LogHandler.FlattenException(ex)); throw; } + finally + { + objResponse?.Dispose(); + } + } + + /// + /// Masks sensitive fields (cert content, private keys, passwords) in a JSON payload + /// before it goes into trace logs. Only the value portion is replaced - keys remain + /// readable so operators can see the request shape. + /// + private static string MaskSensitivePayload(string payload) + { + if (string.IsNullOrEmpty(payload)) return payload; + + // Quick check: if no sensitive markers exist, skip the regex work + if (payload.IndexOf("content", StringComparison.OrdinalIgnoreCase) < 0 + && payload.IndexOf("Password", StringComparison.OrdinalIgnoreCase) < 0 + && payload.IndexOf("BEGIN", StringComparison.Ordinal) < 0) + { + return payload; + } + + // Replace string values for known-sensitive keys: "content", "Password", "PasswordAlias" + var masked = System.Text.RegularExpressions.Regex.Replace( + payload, + "\"(content|Password|PasswordAlias)\"\\s*:\\s*\"[^\"]*\"", + "\"$1\":\"***MASKED***\"", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return masked; } #endregion diff --git a/DataPower/FlowLogger.cs b/DataPower/FlowLogger.cs new file mode 100644 index 0000000..917f45c --- /dev/null +++ b/DataPower/FlowLogger.cs @@ -0,0 +1,243 @@ +// Copyright 2024 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 System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.DataPower +{ + public class FlowLogger : IDisposable + { + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _overallStopwatch; + private readonly List _steps = new List(); + private readonly Stack _branchStack = new Stack(); + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _flowName = flowName ?? throw new ArgumentNullException(nameof(flowName)); + _overallStopwatch = Stopwatch.StartNew(); + _logger.LogTrace("[FLOW:{FlowName}] === BEGIN ===", _flowName); + } + + public void Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Detail = detail, Status = StepStatus.Success }; + _steps.Add(step); + var prefix = GetPrefix(); + if (detail != null) + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} - {Detail}", _flowName, prefix, name, detail); + else + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName}", _flowName, prefix, name); + } + + public void Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + await action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public T Step(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + var result = action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + return result; + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public void Fail(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Failed, ErrorMessage = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Skip(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Skipped, Detail = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[SKIP] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Branch(string name) + { + _branchStack.Push(name); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}>> {BranchName}", _flowName, prefix, name); + } + + public void EndBranch() + { + if (_branchStack.Count > 0) + { + var name = _branchStack.Pop(); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}<< {BranchName}", _flowName, prefix, name); + } + } + + public bool HasFailures => _steps.Any(s => s.Status == StepStatus.Failed); + + public string GetSummary() + { + var hasFailures = HasFailures; + var overallStatus = hasFailures ? "FAILED" : "OK"; + var total = _steps.Count; + var succeeded = _steps.Count(s => s.Status == StepStatus.Success); + var failed = _steps.Count(s => s.Status == StepStatus.Failed); + var skipped = _steps.Count(s => s.Status == StepStatus.Skipped); + var elapsed = _overallStopwatch.ElapsedMilliseconds; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {elapsed}ms"); + sb.AppendLine($"Steps: {total} total, {succeeded} ok, {failed} failed, {skipped} skipped"); + sb.AppendLine("----------------------------------------"); + foreach (var step in _steps) + { + var icon = step.Status == StepStatus.Success ? "[OK] " + : step.Status == StepStatus.Failed ? "[FAIL]" + : step.Status == StepStatus.Skipped ? "[SKIP]" + : "[...]"; + var time = step.ElapsedMs.HasValue ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.ErrorMessage) + ? $" - {step.ErrorMessage}" + : !string.IsNullOrEmpty(step.Detail) + ? $" - {step.Detail}" + : ""; + sb.AppendLine($" {icon} {step.Name}{time}{detail}"); + } + sb.Append("----------------------------------------"); + + return sb.ToString(); + } + + public void Dispose() + { + _overallStopwatch.Stop(); + var summary = GetSummary(); + _logger.LogTrace("[FLOW:{FlowName}] === END ===\n{Summary}", _flowName, summary); + } + + private string GetPrefix() + { + if (_branchStack.Count == 0) return ""; + return new string(' ', _branchStack.Count * 2) + "| "; + } + + private static string FormatDetail(string detail) + { + return string.IsNullOrEmpty(detail) ? "" : $" - {detail}"; + } + + private enum StepStatus + { + Success, + Failed, + Skipped, + InProgress + } + + private class FlowStep + { + public string Name { get; set; } + public string Detail { get; set; } + public StepStatus Status { get; set; } = StepStatus.InProgress; + public long? ElapsedMs { get; set; } + public string ErrorMessage { get; set; } + } + } +} diff --git a/DataPower/Jobs/Discovery.cs b/DataPower/Jobs/Discovery.cs index a562e4c..f19b7dc 100644 --- a/DataPower/Jobs/Discovery.cs +++ b/DataPower/Jobs/Discovery.cs @@ -24,11 +24,8 @@ namespace Keyfactor.Extensions.Orchestrator.DataPower.Jobs { - public class Discovery : IDiscoveryJobExtension + public class Discovery : JobBase, IDiscoveryJobExtension { - private readonly ILogger _logger; - private readonly IPAMSecretResolver _resolver; - // Certificate-relevant filestore directories on DataPower private static readonly HashSet CertStoreDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -37,16 +34,9 @@ public class Discovery : IDiscoveryJobExtension "sharedcert" }; - public Discovery(IPAMSecretResolver resolver) - { - _logger = LogHandler.GetClassLogger(); - _resolver = resolver; - } - - private string ResolvePamField(string name, string value) + public Discovery(IPAMSecretResolver resolver) : base(resolver) { - _logger.LogTrace($"Attempting to resolved PAM eligible field {name}"); - return _resolver.Resolve(value); + Logger = LogHandler.GetClassLogger(); } public string ExtensionName => "DataPower"; @@ -54,92 +44,146 @@ private string ResolvePamField(string name, string value) public JobResult ProcessJob(DiscoveryJobConfiguration jobConfiguration, SubmitDiscoveryUpdate submitDiscoveryUpdate) { - try + if (jobConfiguration == null) { - _logger.MethodEntry(LogLevel.Debug); - return PerformDiscovery(jobConfiguration, submitDiscoveryUpdate); + Logger.LogError("ProcessJob called with null jobConfiguration."); + return FailureResult(0, "DiscoveryJobConfiguration is null."); } - catch (Exception e) + + if (submitDiscoveryUpdate == null) { - _logger.LogError($"Error In Discovery.ProcessJob: {LogHandler.FlattenException(e)}"); - return new JobResult + Logger.LogError("ProcessJob called with null submitDiscoveryUpdate."); + return FailureResult(jobConfiguration.JobHistoryId, "SubmitDiscoveryUpdate delegate is null."); + } + + using (var flow = new FlowLogger(Logger, "Discovery-ProcessJob")) + { + try + { + Logger.MethodEntry(LogLevel.Debug); + + flow.Step("ValidateConfig", () => + { + if (string.IsNullOrWhiteSpace(jobConfiguration.ClientMachine)) + throw new ArgumentException("ClientMachine is null or empty."); + }); + + return PerformDiscovery(jobConfiguration, submitDiscoveryUpdate, flow); + } + catch (Exception e) { - FailureMessage = $"Unknown Exception Occured In ProcessJob: {LogHandler.FlattenException(e)}", - JobHistoryId = jobConfiguration.JobHistoryId, - Result = OrchestratorJobStatusJobResult.Failure - }; + var msg = DescribeException(e); + flow.Fail("ProcessJob", msg); + Logger.LogError(e, "Error In Discovery.ProcessJob: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(jobConfiguration.JobHistoryId, + $"Unknown Exception Occured In ProcessJob: {msg}", flow); + } } } - private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDiscoveryUpdate submitDiscovery) + private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDiscoveryUpdate submitDiscovery, FlowLogger flow) { try { var protocol = "https"; - if (config.JobProperties != null && config.JobProperties.ContainsKey("Protocol")) + flow.Step("ParseProtocol", () => { - protocol = config.JobProperties["Protocol"]?.ToString() ?? "https"; - } + if (config.JobProperties != null && config.JobProperties.ContainsKey("Protocol")) + { + protocol = config.JobProperties["Protocol"]?.ToString() ?? "https"; + } + }, $"protocol={protocol}"); var baseUrl = $"{protocol}://" + config.ClientMachine.Trim(); + Logger.LogTrace($"Entering IBM DataPower: Discovery for appliance {config.ClientMachine}"); - _logger.LogTrace($"Entering IBM DataPower: Discovery for appliance {config.ClientMachine}"); - - var apiClient = new DataPowerClient( - ResolvePamField("ServerUserName", config.ServerUsername), - ResolvePamField("ServerPassword", config.ServerPassword), - baseUrl, - "default"); + DataPowerClient apiClient = null; + flow.Step("CreateApiClient", () => + { + apiClient = new DataPowerClient( + ResolvePamField("ServerUserName", config.ServerUsername), + ResolvePamField("ServerPassword", config.ServerPassword), + baseUrl, + "default"); + }, $"host={config.ClientMachine}"); + + List domains = null; + flow.Step("ListDomains", () => + { + domains = apiClient.ListDomains(); + }, $"will populate domains"); - // Step 1: List all domains on the appliance - _logger.LogTrace("Discovering domains on DataPower appliance..."); - var domains = apiClient.ListDomains(); - _logger.LogTrace($"Found {domains.Count} domain(s)"); + var domainCount = domains?.Count ?? 0; + Logger.LogTrace($"Found {domainCount} domain(s)"); var discoveredLocations = new List(); - // Step 2: For each domain, discover certificate store directories - foreach (var domain in domains) + if (domainCount == 0) + { + flow.Skip("DiscoverDirectories", "no domains returned"); + } + else { - _logger.LogTrace($"Discovering filestore directories for domain: {domain.Name}"); + flow.Branch($"PerDomain (count={domainCount})"); try { - var directories = apiClient.ListFileStoreDirectories(domain.Name); - _logger.LogTrace($"Found {directories.Count} directory(ies) in domain {domain.Name}"); - - var certDirectories = directories - .Where(d => CertStoreDirectories.Contains(d)) - .ToList(); - - foreach (var dir in certDirectories) + foreach (var domain in domains) { - var storePath = $"{domain.Name}\\{dir}"; - _logger.LogTrace($"Discovered certificate store: {storePath}"); - discoveredLocations.Add(storePath); + if (string.IsNullOrWhiteSpace(domain?.Name)) + { + flow.Skip("Domain", "empty domain name"); + continue; + } + + try + { + List directories = null; + flow.Step($"ListFileStore-{domain.Name}", () => + { + directories = apiClient.ListFileStoreDirectories(domain.Name); + }); + + var certDirectories = directories + .Where(d => CertStoreDirectories.Contains(d)) + .ToList(); + + foreach (var dir in certDirectories) + { + var storePath = $"{domain.Name}\\{dir}"; + discoveredLocations.Add(storePath); + flow.Step($"Discovered-{storePath}"); + } + } + catch (Exception ex) + { + // Resilient by design: one inaccessible domain should not abort discovery + var inner = DescribeException(ex); + flow.Skip($"Domain-{domain.Name}", $"unable to list directories: {inner}"); + Logger.LogWarning(ex, "Unable to list filestore directories for domain {DomainName}: {ErrorMessage}", + domain.Name, inner); + } } } - catch (Exception ex) + finally { - _logger.LogWarning($"Unable to list filestore directories for domain {domain.Name}: {LogHandler.FlattenException(ex)}"); + flow.EndBranch(); } } - _logger.LogTrace($"Discovery complete. Found {discoveredLocations.Count} certificate store location(s)."); - - submitDiscovery.Invoke(discoveredLocations); + flow.Step("SubmitDiscovery", () => submitDiscovery.Invoke(discoveredLocations), + $"locationCount={discoveredLocations.Count}"); - _logger.MethodExit(LogLevel.Debug); + Logger.MethodExit(LogLevel.Debug); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Success, - JobHistoryId = config.JobHistoryId - }; + flow.Step("Result", $"SUCCESS - {discoveredLocations.Count} locations discovered"); + return SuccessResult(config.JobHistoryId, flow.GetSummary()); } catch (Exception e) { - _logger.LogError($"Error In Discovery.PerformDiscovery: {LogHandler.FlattenException(e)}"); - throw; + var msg = DescribeException(e); + flow.Fail("PerformDiscovery", msg); + Logger.LogError(e, "Error In Discovery.PerformDiscovery: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(config.JobHistoryId, $"Discovery failed: {msg}", flow); } } } diff --git a/DataPower/Jobs/Inventory.cs b/DataPower/Jobs/Inventory.cs index 9a06d61..dd8021e 100644 --- a/DataPower/Jobs/Inventory.cs +++ b/DataPower/Jobs/Inventory.cs @@ -1,11 +1,11 @@ // Copyright 2023 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. @@ -19,28 +19,18 @@ using Keyfactor.Orchestrators.Extensions; using Keyfactor.Orchestrators.Extensions.Interfaces; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace Keyfactor.Extensions.Orchestrator.DataPower.Jobs { - public class Inventory : IInventoryJobExtension + public class Inventory : JobBase, IInventoryJobExtension { - private readonly ILogger _logger; private readonly RequestManager _reqManager; private string _protocol; - private readonly IPAMSecretResolver _resolver; - public Inventory(IPAMSecretResolver resolver) + public Inventory(IPAMSecretResolver resolver) : base(resolver) { - _logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); _reqManager = new RequestManager(resolver); - _resolver = resolver; - } - - private string ResolvePamField(string name, string value) - { - _logger.LogTrace($"Attempting to resolved PAM eligible field {name}"); - return _resolver.Resolve(value); } public string ExtensionName => "DataPower"; @@ -48,55 +38,102 @@ private string ResolvePamField(string name, string value) public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitInventoryUpdate submitInventoryUpdate) { - try + if (jobConfiguration == null) { - _logger.MethodEntry(LogLevel.Debug); - return PerformInventory(jobConfiguration, submitInventoryUpdate); + Logger.LogError("ProcessJob called with null jobConfiguration."); + return FailureResult(0, "InventoryJobConfiguration is null."); } - catch (Exception e) + + if (submitInventoryUpdate == null) { - _logger.LogError($"Error In Inventory.ProcessJob: {LogHandler.FlattenException(e)}"); - return new JobResult + Logger.LogError("ProcessJob called with null submitInventoryUpdate."); + return FailureResult(jobConfiguration.JobHistoryId, "SubmitInventoryUpdate delegate is null."); + } + + using (var flow = new FlowLogger(Logger, "Inventory-ProcessJob")) + { + try { - FailureMessage = $"Unknown Exception Occured In ProcessJob: {LogHandler.FlattenException(e)}", - JobHistoryId = jobConfiguration.JobHistoryId, - Result = OrchestratorJobStatusJobResult.Failure - }; + Logger.MethodEntry(LogLevel.Debug); + + flow.Step("ValidateConfig", () => + { + if (jobConfiguration.CertificateStoreDetails == null) + throw new ArgumentNullException(nameof(jobConfiguration), + "CertificateStoreDetails is null."); + if (string.IsNullOrWhiteSpace(jobConfiguration.CertificateStoreDetails.ClientMachine)) + throw new ArgumentException("ClientMachine is null or empty."); + if (string.IsNullOrWhiteSpace(jobConfiguration.CertificateStoreDetails.StorePath)) + throw new ArgumentException("StorePath is null or empty."); + }); + + return PerformInventory(jobConfiguration, submitInventoryUpdate, flow); + } + catch (Exception e) + { + var msg = DescribeException(e); + flow.Fail("ProcessJob", msg); + Logger.LogError(e, "Error In Inventory.ProcessJob: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(jobConfiguration.JobHistoryId, + $"Unknown Exception Occured In ProcessJob: {msg}", flow); + } } } - private JobResult PerformInventory(InventoryJobConfiguration config, SubmitInventoryUpdate submitInventory) + private JobResult PerformInventory(InventoryJobConfiguration config, SubmitInventoryUpdate submitInventory, FlowLogger flow) { try { - _logger.LogTrace("Parse: Certificate Inventory: " + config.CertificateStoreDetails.StorePath); - var ci = Utility.ParseCertificateConfig(config); - _protocol = ci.Protocol; - _logger.LogTrace( - $"Certificate Config Domain: {ci.Domain} and Certificate Store: {ci.CertificateStore}"); - _logger.LogTrace("Entering IBM DataPower: Certificate Inventory"); - _logger.LogTrace( - $"Entering processJob for Domain: {ci.Domain} and Certificate Store: {ci.CertificateStore}"); - - var apiClient = new DataPowerClient(ResolvePamField("ServerUserName",config.ServerUsername), ResolvePamField("ServerPassword",config.ServerPassword), - $"{_protocol}://" + config.CertificateStoreDetails.ClientMachine.Trim(), ci.Domain); + CertStoreInfo ci = null; + flow.Step("ParseCertificateConfig", () => + { + Logger.LogTrace("Parse: Certificate Inventory: " + config.CertificateStoreDetails.StorePath); + ci = Utility.ParseCertificateConfig(config); + if (ci == null) + throw new InvalidOperationException("Failed to parse certificate store configuration."); + _protocol = ci.Protocol; + }, $"storePath={config.CertificateStoreDetails.StorePath}"); + + Logger.LogTrace($"Certificate Config Domain: {ci.Domain} and Certificate Store: {ci.CertificateStore}"); + + DataPowerClient apiClient = null; + flow.Step("CreateApiClient", () => + { + apiClient = new DataPowerClient( + ResolvePamField("ServerUserName", config.ServerUsername), + ResolvePamField("ServerPassword", config.ServerPassword), + $"{_protocol}://" + config.CertificateStoreDetails.ClientMachine.Trim(), + ci.Domain); + }, $"domain={ci.Domain}, host={config.CertificateStoreDetails.ClientMachine}"); var publicCertStoreName = ci.PublicCertStoreName; - _logger.LogTrace($"$Public Store name is {publicCertStoreName}"); + Logger.LogTrace($"$Public Store name is {publicCertStoreName}"); + + var storePath = config.CertificateStoreDetails.StorePath; + var inventoryResult = flow.Step( + storePath.Contains(publicCertStoreName) ? "GetPublicCerts" : "GetCerts", + () => storePath.Contains(publicCertStoreName) + ? _reqManager.GetPublicCerts(config, apiClient, submitInventory, ci) + : _reqManager.GetCerts(config, apiClient, submitInventory, ci)); - var storePath = config.CertificateStoreDetails.StorePath; - - var inventoryResult = storePath.Contains(publicCertStoreName) - ? _reqManager.GetPublicCerts(config,apiClient,submitInventory,ci) - : _reqManager.GetCerts(config,apiClient,submitInventory,ci); + flow.Step("Result", $"{inventoryResult.Result}"); + + // Append flow summary to result message so operators see the breadcrumb + if (inventoryResult.Result == OrchestratorJobStatusJobResult.Success) + { + return SuccessResult(config.JobHistoryId, flow.GetSummary()); + } + inventoryResult.FailureMessage = $"{inventoryResult.FailureMessage}\n\n{flow.GetSummary()}"; return inventoryResult; } catch (Exception e) { - _logger.LogError($"Error In Inventory.PerformInventory: {LogHandler.FlattenException(e)}"); - throw; + var msg = DescribeException(e); + flow.Fail("PerformInventory", msg); + Logger.LogError(e, "Error In Inventory.PerformInventory: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(config.JobHistoryId, $"Inventory failed: {msg}", flow); } } } -} \ No newline at end of file +} diff --git a/DataPower/Jobs/JobBase.cs b/DataPower/Jobs/JobBase.cs new file mode 100644 index 0000000..5bce092 --- /dev/null +++ b/DataPower/Jobs/JobBase.cs @@ -0,0 +1,126 @@ +// Copyright 2024 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 System; +using Keyfactor.Extensions.Orchestrator.DataPower.Client; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.DataPower.Jobs +{ + /// + /// Shared plumbing for all IBM DataPower orchestrator job extensions. Provides PAM + /// resolution with warn-on-empty fallback, JobResult helpers that append the + /// summary, and exception unwrapping that surfaces + /// details (HTTP status + response body). + /// + public abstract class JobBase + { + protected ILogger Logger; + protected readonly IPAMSecretResolver Resolver; + + protected JobBase(IPAMSecretResolver resolver) + { + Resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + } + + /// + /// Resolves a PAM-eligible field. Returns the value as-is when it is null/empty + /// (with a warning), otherwise hands it to the PAM resolver. This avoids passing + /// empty strings into PAM providers which often misinterpret them as keys. + /// + protected string ResolvePamField(string name, string value) + { + Logger.LogTrace("Attempting to resolve PAM eligible field {FieldName}", name); + if (string.IsNullOrWhiteSpace(value)) + { + Logger.LogWarning("PAM field {FieldName} has a null/empty value, returning as-is.", name); + return value; + } + return Resolver.Resolve(value); + } + + protected static JobResult SuccessResult(long jobHistoryId, string message = "") + { + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Success, + JobHistoryId = jobHistoryId, + FailureMessage = message ?? "" + }; + } + + protected static JobResult WarningResult(long jobHistoryId, string message) + { + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Warning, + JobHistoryId = jobHistoryId, + FailureMessage = message ?? "" + }; + } + + protected static JobResult FailureResult(long jobHistoryId, string message, FlowLogger flow = null) + { + var combined = message ?? "Unknown error"; + if (flow != null) + { + combined = $"{combined}\n\n{flow.GetSummary()}"; + } + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobHistoryId, + FailureMessage = combined + }; + } + + /// + /// Unwraps an exception chain and produces a human-readable description. When a + /// is anywhere in the chain (including inside + /// an ), prefer its HTTP status + response body + /// over the generic .Message - operators need to see what the appliance returned. + /// + protected static string DescribeException(Exception ex) + { + if (ex == null) return "Unknown error"; + + var apiEx = DataPowerApiException.Find(ex); + if (apiEx != null) + { + var body = string.IsNullOrWhiteSpace(apiEx.ResponseBody) + ? string.Empty + : $" - body: {Trim(apiEx.ResponseBody, 500)}"; + return $"DataPower API error during {apiEx.Operation}: HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}{body}"; + } + + if (ex is AggregateException agg && agg.InnerExceptions.Count > 0) + { + return agg.InnerExceptions[0].Message; + } + + return ex.InnerException?.Message ?? ex.Message; + } + + private static string Trim(string s, int maxLen) + { + if (string.IsNullOrEmpty(s)) return s; + if (s.Length <= maxLen) return s; + return s.Substring(0, maxLen) + "..."; + } + } +} diff --git a/DataPower/Jobs/Management.cs b/DataPower/Jobs/Management.cs index a535f00..f242485 100644 --- a/DataPower/Jobs/Management.cs +++ b/DataPower/Jobs/Management.cs @@ -1,11 +1,11 @@ -// Copyright 2023 Keyfactor -// +// Copyright 2023 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. @@ -22,74 +22,112 @@ namespace Keyfactor.Extensions.Orchestrator.DataPower.Jobs { - public class Management : IManagementJobExtension + public class Management : JobBase, IManagementJobExtension { - private RequestManager _certManager; - private readonly ILogger _logger; - private readonly IPAMSecretResolver _resolver; + private readonly RequestManager _certManager; - - public Management(IPAMSecretResolver resolver) + public Management(IPAMSecretResolver resolver) : base(resolver) { - _certManager=new RequestManager(resolver); - _logger = LogHandler.GetClassLogger(); - _resolver = resolver; + Logger = LogHandler.GetClassLogger(); + _certManager = new RequestManager(resolver); } public string ExtensionName => "DataPower"; public JobResult ProcessJob(ManagementJobConfiguration config) { - try + if (config == null) + { + Logger.LogError("ProcessJob called with null config."); + return FailureResult(0, "ManagementJobConfiguration is null."); + } + + using (var flow = new FlowLogger(Logger, "Management-ProcessJob")) { - _logger.MethodEntry(LogLevel.Debug); + try + { + Logger.MethodEntry(LogLevel.Debug); - var ci = Utility.ParseCertificateConfig(config); - var np = Utility.ParseStoreProperties(config); + flow.Step("ValidateConfig", () => + { + if (config.CertificateStoreDetails == null) + throw new ArgumentNullException(nameof(config), "CertificateStoreDetails is null."); + if (string.IsNullOrWhiteSpace(config.CertificateStoreDetails.ClientMachine)) + throw new ArgumentException("ClientMachine is null or empty."); + if (string.IsNullOrWhiteSpace(config.CertificateStoreDetails.StorePath)) + throw new ArgumentException("StorePath is null or empty."); + }); - _logger.LogTrace($"ci {JsonConvert.SerializeObject(ci)}"); - _logger.LogTrace($"np {JsonConvert.SerializeObject(np)}"); + CertStoreInfo ci = null; + flow.Step("ParseCertificateConfig", () => + { + ci = Utility.ParseCertificateConfig(config); + if (ci == null) + throw new InvalidOperationException("Failed to parse certificate store configuration."); + }, $"storePath={config.CertificateStoreDetails.StorePath}"); - JobResult result; + Models.SupportingObjects.NamePrefix np = null; + flow.Step("ParseStoreProperties", () => + { + np = Utility.ParseStoreProperties(config); + }); - _logger.LogTrace("Entering IBM DataPower: Inventory Management for DOMAIN: " + ci.Domain); - switch (config.OperationType.ToString()) - { - case "Add": - _logger.LogTrace("Entering Add Job.."); - result = _certManager.Add(config, ci, np); - _logger.LogTrace("Finished Add Job.."); - _logger.LogTrace($"result {JsonConvert.SerializeObject(result)}"); - break; - case "Remove": - _logger.LogTrace("Entering Remove Job.."); - result = _certManager.Remove(config, ci, np); - _logger.LogTrace("Finished Remove Job.."); - _logger.LogTrace($"result {JsonConvert.SerializeObject(result)}"); - break; - default: - return new JobResult + Logger.LogTrace($"ci {JsonConvert.SerializeObject(ci)}"); + Logger.LogTrace($"np {JsonConvert.SerializeObject(np)}"); + Logger.LogTrace("Entering IBM DataPower: Inventory Management for DOMAIN: " + ci.Domain); + + JobResult result; + var operation = config.OperationType.ToString(); + + flow.Branch(operation); + try + { + switch (operation) { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = "Unrecognized Operation" - }; - } + case "Add": + result = flow.Step("Add", () => _certManager.Add(config, ci, np)); + break; + case "Remove": + result = flow.Step("Remove", () => _certManager.Remove(config, ci, np)); + break; + default: + flow.Fail("OperationType", $"Unrecognized operation '{operation}'"); + return FailureResult(config.JobHistoryId, + $"Unrecognized Operation: {operation}", flow); + } + } + finally + { + flow.EndBranch(); + } - _logger.MethodExit(LogLevel.Debug); + Logger.LogTrace($"result {JsonConvert.SerializeObject(result)}"); + Logger.MethodExit(LogLevel.Debug); - return result; - } - catch (Exception e) - { - _logger.LogError($"Error In ProcessJob.ProcessJob: {LogHandler.FlattenException(e)}"); - return new JobResult + if (result?.Result == OrchestratorJobStatusJobResult.Success) + { + flow.Step("Result", "SUCCESS"); + return SuccessResult(config.JobHistoryId, flow.GetSummary()); + } + + if (result != null) + { + flow.Fail("Result", result.FailureMessage ?? "Operation reported non-success."); + result.FailureMessage = $"{result.FailureMessage}\n\n{flow.GetSummary()}"; + return result; + } + + return FailureResult(config.JobHistoryId, "Operation returned a null result.", flow); + } + catch (Exception e) { - FailureMessage = $"Unknown Exception Occured In ProcessJob: {LogHandler.FlattenException(e)}", - JobHistoryId = config.JobHistoryId, - Result = OrchestratorJobStatusJobResult.Failure - }; + var msg = DescribeException(e); + flow.Fail("ProcessJob", msg); + Logger.LogError(e, "Error In Management.ProcessJob: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(config.JobHistoryId, + $"Unknown Exception Occured In ProcessJob: {msg}", flow); + } } } } -} \ No newline at end of file +} From 4c03e0fb3333e0f193801c71feb8083ae600df6f Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:19:48 -0400 Subject: [PATCH 07/21] Update keyfactor-starter-workflow.yml --- .github/workflows/keyfactor-starter-workflow.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml index a4649f2..aebc4ae 100644 --- a/.github/workflows/keyfactor-starter-workflow.yml +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -11,10 +11,17 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@3.1.2 + uses: keyfactor/actions/.github/workflows/starter.yml@v4 + with: + command_token_url: ${{ vars.COMMAND_TOKEN_URL }} + command_hostname: ${{ vars.COMMAND_HOSTNAME }} + command_base_api_path: ${{ vars.COMMAND_API_PATH }} secrets: token: ${{ secrets.V2BUILDTOKEN}} - APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} scan_token: ${{ secrets.SAST_TOKEN }} + entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} + entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} + command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} + command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} From 8b0fe8370f22fad5a7df26feac5a6ab863ba0097 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Thu, 30 Apr 2026 19:46:20 -0400 Subject: [PATCH 08/21] Fix Discovery: parse filestore.location[] and strip trailing colon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /mgmt/filestore/{domain} returns filestore.location[] with names like "cert:" / "pubcert:" / "sharedcert:" — not filestore.directory[]. The response model deserialized the wrong key, so ListFileStoreDirectories() always returned an empty list and Discovery submitted 0 store paths against a populated appliance. - Rename FileStoreDirectory -> FileStoreLocation (matches JSON shape). - ListFileStoreResponse: JsonProperty("directory") -> "location", Directories -> Locations. - DataPowerClient: single-item-quirk container name "directory" -> "location". - Discovery: trim trailing ':' before matching against the cert-store filter so "cert:" matches "cert" and the resulting storePath stays colon-free. - Update docs/discovery-overview.{md,html} references. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/Client/DataPowerClient.cs | 8 ++++---- DataPower/Jobs/Discovery.cs | 6 +++++- DataPower/Models/Responses/ListFileStoreResponse.cs | 2 +- .../{FileStoreDirectory.cs => FileStoreLocation.cs} | 4 +++- docs/discovery-overview.html | 4 ++-- docs/discovery-overview.md | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) rename DataPower/Models/SupportingObjects/{FileStoreDirectory.cs => FileStoreLocation.cs} (79%) diff --git a/DataPower/Client/DataPowerClient.cs b/DataPower/Client/DataPowerClient.cs index 758ccde..a232833 100644 --- a/DataPower/Client/DataPowerClient.cs +++ b/DataPower/Client/DataPowerClient.cs @@ -114,9 +114,9 @@ public List ListFileStoreDirectories(string domain) var strResponse = ApiRequestString("ListFileStoreDirectories", request.GetResource(), request.Method, string.Empty, false, true); - var containerName = "directory"; + var containerName = "location"; - // DataPower returns a single object instead of array when only one directory exists + // DataPower returns a single object instead of an array when only one location exists if (strResponse.Contains($"\"{containerName}\"") && !strResponse.Contains($"\"{containerName}\" : [") && !strResponse.Contains($"\"{containerName}\":[")) @@ -125,10 +125,10 @@ public List ListFileStoreDirectories(string domain) } var response = JsonConvert.DeserializeObject(strResponse); - if (response?.FileStore?.Directories == null) + if (response?.FileStore?.Locations == null) return new List(); - return response.FileStore.Directories + return response.FileStore.Locations .Select(d => d.Name) .ToList(); } diff --git a/DataPower/Jobs/Discovery.cs b/DataPower/Jobs/Discovery.cs index f19b7dc..1a93167 100644 --- a/DataPower/Jobs/Discovery.cs +++ b/DataPower/Jobs/Discovery.cs @@ -143,8 +143,12 @@ private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDisco directories = apiClient.ListFileStoreDirectories(domain.Name); }); + // DataPower's filestore location names carry a trailing colon + // (e.g. "cert:" / "pubcert:" / "sharedcert:"). Strip it before + // matching and before composing the store path. var certDirectories = directories - .Where(d => CertStoreDirectories.Contains(d)) + .Select(d => d?.TrimEnd(':')) + .Where(d => !string.IsNullOrEmpty(d) && CertStoreDirectories.Contains(d)) .ToList(); foreach (var dir in certDirectories) diff --git a/DataPower/Models/Responses/ListFileStoreResponse.cs b/DataPower/Models/Responses/ListFileStoreResponse.cs index e71e7ea..648b8d4 100644 --- a/DataPower/Models/Responses/ListFileStoreResponse.cs +++ b/DataPower/Models/Responses/ListFileStoreResponse.cs @@ -24,6 +24,6 @@ public class ListFileStoreResponse public class FileStoreContent { - [JsonProperty("directory")] public FileStoreDirectory[] Directories { get; set; } + [JsonProperty("location")] public FileStoreLocation[] Locations { get; set; } } } diff --git a/DataPower/Models/SupportingObjects/FileStoreDirectory.cs b/DataPower/Models/SupportingObjects/FileStoreLocation.cs similarity index 79% rename from DataPower/Models/SupportingObjects/FileStoreDirectory.cs rename to DataPower/Models/SupportingObjects/FileStoreLocation.cs index b46f491..7a95678 100644 --- a/DataPower/Models/SupportingObjects/FileStoreDirectory.cs +++ b/DataPower/Models/SupportingObjects/FileStoreLocation.cs @@ -16,7 +16,9 @@ namespace Keyfactor.Extensions.Orchestrator.DataPower.Models.SupportingObjects { - public class FileStoreDirectory + // One entry in the GET /mgmt/filestore/{domain} response. DataPower returns + // these under filestore.location[] with names like "cert:" / "pubcert:" / "sharedcert:". + public class FileStoreLocation { [JsonProperty("name")] public string Name { get; set; } diff --git a/docs/discovery-overview.html b/docs/discovery-overview.html index cd2dedb..cb0010a 100644 --- a/docs/discovery-overview.html +++ b/docs/discovery-overview.html @@ -781,8 +781,8 @@

New Files

Domain name and href properties from the DataPower domains API.

-
Models/SupportingObjects/FileStoreDirectory.cs
-

Directory name and href properties from the filestore API.

+
Models/SupportingObjects/FileStoreLocation.cs
+

Name and href of one entry in filestore.location[] (e.g. cert:, pubcert:, sharedcert:).

diff --git a/docs/discovery-overview.md b/docs/discovery-overview.md index ddc22ed..0b27197 100644 --- a/docs/discovery-overview.md +++ b/docs/discovery-overview.md @@ -164,7 +164,7 @@ Returns the top-level filestore directories for a specific domain. The orchestra | `Models/Responses/ListDomainsResponse.cs` | Response deserialization for domain listing. Includes single-item variant for DataPower JSON quirk. | | `Models/Responses/ListFileStoreResponse.cs` | Response deserialization for filestore directory listing. | | `Models/SupportingObjects/DomainInfo.cs` | Domain name and href properties from the DataPower domains API. | -| `Models/SupportingObjects/FileStoreDirectory.cs` | Directory name and href properties from the filestore API. | +| `Models/SupportingObjects/FileStoreLocation.cs` | Name and href of one entry in `filestore.location[]` (e.g. `cert:`, `pubcert:`, `sharedcert:`). | ### Modified Files From d2d4fe4d9419b10fc2f6971d96134095a1de3cce Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 09:38:59 -0400 Subject: [PATCH 09/21] Add test setup for populating a DataPower lab appliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Postman collection + PowerShell generator that produce a populated test appliance for exercising Discovery and Inventory: - generate-test-certs.ps1: emits 3 Collection Runner data files under test/data/ with 120 unique self-signed cert/key pairs (PKCS#8 keys, since DataPower's filestore validator rejects PKCS#1). - DataPower-Test-Setup.postman_collection.json: creates 10 application domains, populates default/pubcert (10 certs), default/sharedcert (10 cert+key pairs + matching CryptoCertificate/CryptoKey config objects in default), and per-domain cert/ (10 cert+key pairs per domain plus a CryptoCertificate and CryptoKey object per pair). Verify folder mirrors what Discovery and Inventory query. Cleanup folder tears it all down. - DataPower-Test.postman_environment.json: BASE_URL / USERNAME / PASSWORD placeholders only — no secrets committed. - README.md: run order, why config objects are required for the per-domain and sharedcert paths (Inventory reads CryptoCertificate objects, not the filestore), and Newman invocation for environments without GUI Runner data-file support. - test/.gitignore excludes data/ since the generated PEMs are throwaway. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/.gitignore | 1 + ...taPower-Test-Setup.postman_collection.json | 766 ++++++++++++++++++ test/DataPower-Test.postman_environment.json | 25 + test/README.md | 117 +++ test/generate-test-certs.ps1 | 94 +++ 5 files changed, 1003 insertions(+) create mode 100644 test/.gitignore create mode 100644 test/DataPower-Test-Setup.postman_collection.json create mode 100644 test/DataPower-Test.postman_environment.json create mode 100644 test/README.md create mode 100644 test/generate-test-certs.ps1 diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..8fce603 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +data/ diff --git a/test/DataPower-Test-Setup.postman_collection.json b/test/DataPower-Test-Setup.postman_collection.json new file mode 100644 index 0000000..a1a19bb --- /dev/null +++ b/test/DataPower-Test-Setup.postman_collection.json @@ -0,0 +1,766 @@ +{ + "info": { + "_postman_id": "e0fb252e-0b74-4414-bb1a-0da87ced4294", + "name": "DataPower Test Setup", + "description": "Populates a DataPower test appliance with 10 application domains and certificates in cert/pubcert/sharedcert directories. Designed for testing the Discovery and Inventory jobs in the Keyfactor IBM DataPower Orchestrator.\n\n## Prerequisites\n1. Run `generate-test-certs.ps1` to produce unique cert + key pairs in `test/data/`.\n2. Set `BASE_URL`, `USERNAME`, `PASSWORD` in the environment.\n3. Run the folders below in order via Collection Runner. For folders that consume cert content, attach the matching data file (iteration count is taken from the file).\n\n## Run order\n1. **Create Domains** (10 iterations) - creates test-domain-01 through test-domain-10\n2. **Save Default Domain** (1 iteration) - persists domain config\n3. **Populate Pubcert** - data: `data/pubcert-data.json` (10 unique certs in default/pubcert)\n4. **Populate Sharedcert** - data: `data/sharedcert-data.json` (10 unique certs in default/sharedcert)\n5. **Populate Per-Domain Cert Directory** - data: `data/perdomain-data.json` (100 unique cert+key pairs across domains)\n6. **Save All Domains** (10 iterations) - persists each domain's filestore changes\n7. **Verify - List Domains** (1 iteration) - confirms what Discovery would find", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "4197833" + }, + "item": [ + { + "name": "1. Create Domains", + "item": [ + { + "name": "Create Domain (test-domain-{{paddedIter}})", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"Domain\": {\n \"name\": \"test-domain-{{paddedIter}}\",\n \"mAdminState\": \"enabled\",\n \"FileMap\": {\n \"Display\": \"on\",\n \"Exec\": \"on\",\n \"CopyFrom\": \"on\",\n \"CopyTo\": \"on\",\n \"Delete\": \"on\",\n \"Subdir\": \"on\"\n },\n \"ConfigMode\": \"local\",\n \"ConfigPermissionsMode\": \"scope-domain\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/config/default/Domain/test-domain-{{paddedIter}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "default", + "Domain", + "test-domain-{{paddedIter}}" + ] + } + }, + "response": [] + } + ], + "description": "Run with 10 iterations. Creates test-domain-01 through test-domain-10." + }, + { + "name": "2. Save Default Domain Config", + "item": [ + { + "name": "Save Config - default", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"SaveConfig\": 0\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/actionqueue/default", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "actionqueue", + "default" + ] + } + }, + "response": [] + } + ], + "description": "Run with 1 iteration after creating domains. Persists domain definitions to the appliance startup config." + }, + { + "name": "3. Populate Pubcert (default domain)", + "item": [ + { + "name": "Add cert to default/pubcert (test-pubcert-{{paddedIter}}.pem)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"file\": {\n \"name\": \"test-pubcert-{{paddedIter}}.pem\",\n \"content\": \"{{certPemB64}}\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/pubcert/test-pubcert-{{paddedIter}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "pubcert", + "test-pubcert-{{paddedIter}}.pem" + ] + } + }, + "response": [] + } + ], + "description": "Run with 10 iterations. Adds 10 cert files to default/pubcert. This is appliance-wide so visible from every domain." + }, + { + "name": "4. Populate Sharedcert (default domain)", + "item": [ + { + "name": "Add cert to default/sharedcert (test-shared-{{paddedIter}}.pem)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"file\": {\n \"name\": \"test-shared-{{paddedIter}}.pem\",\n \"content\": \"{{certPemB64}}\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/sharedcert/test-shared-{{paddedIter}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "sharedcert", + "test-shared-{{paddedIter}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Add key to default/sharedcert (test-shared-key-{{paddedIter}}.pem)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"file\": {\n \"name\": \"test-shared-key-{{paddedIter}}.pem\",\n \"content\": \"{{keyPemB64}}\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/sharedcert/test-shared-key-{{paddedIter}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "sharedcert", + "test-shared-key-{{paddedIter}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Create CryptoCertificate object (default/test-shared-{{paddedIter}})", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"CryptoCertificate\": {\n \"name\": \"test-shared-{{paddedIter}}\",\n \"mAdminState\": \"enabled\",\n \"Filename\": \"sharedcert:///test-shared-{{paddedIter}}.pem\",\n \"PasswordAlias\": \"off\",\n \"IgnoreExpiration\": \"off\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/config/default/CryptoCertificate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "default", + "CryptoCertificate" + ] + } + }, + "response": [] + }, + { + "name": "Create CryptoKey object (default/test-shared-key-{{paddedIter}})", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"CryptoKey\": {\n \"name\": \"test-shared-key-{{paddedIter}}\",\n \"mAdminState\": \"enabled\",\n \"Filename\": \"sharedcert:///test-shared-key-{{paddedIter}}.pem\",\n \"PasswordAlias\": \"off\",\n \"IgnoreExpiration\": \"off\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/config/default/CryptoKey", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "default", + "CryptoKey" + ] + } + }, + "response": [] + } + ], + "description": "Run with 10 iterations using `data/sharedcert-data.json`. Per iteration: PUT cert PEM, PUT key PEM, POST CryptoCertificate config object pointing at the cert (sharedcert:///), POST CryptoKey config object pointing at the key. The crypto objects live in the `default` domain since sharedcert is appliance-wide." + }, + { + "name": "5. Populate Per-Domain Cert Directory", + "item": [ + { + "name": "Add cert + key to test-domain-{{domainIdxPadded}}/cert (cert-{{certIdxPadded}})", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "// Override the collection-level pre-request to compute domain/cert indices", + "const domainIdx = Math.floor(pm.info.iteration / 10) + 1;", + "const certIdx = (pm.info.iteration % 10) + 1;", + "pm.variables.set('domainIdxPadded', domainIdx.toString().padStart(2, '0'));", + "pm.variables.set('certIdxPadded', certIdx.toString().padStart(2, '0'));" + ] + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"file\": {\n \"name\": \"test-cert-{{certIdxPadded}}.pem\",\n \"content\": \"{{certPemB64}}\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/test-domain-{{domainIdxPadded}}/cert/test-cert-{{certIdxPadded}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "test-domain-{{domainIdxPadded}}", + "cert", + "test-cert-{{certIdxPadded}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Add key to test-domain-{{domainIdxPadded}}/cert (key-{{certIdxPadded}})", + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "const domainIdx = Math.floor(pm.info.iteration / 10) + 1;", + "const certIdx = (pm.info.iteration % 10) + 1;", + "pm.variables.set('domainIdxPadded', domainIdx.toString().padStart(2, '0'));", + "pm.variables.set('certIdxPadded', certIdx.toString().padStart(2, '0'));" + ] + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"file\": {\n \"name\": \"test-key-{{certIdxPadded}}.pem\",\n \"content\": \"{{keyPemB64}}\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/test-domain-{{domainIdxPadded}}/cert/test-key-{{certIdxPadded}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "test-domain-{{domainIdxPadded}}", + "cert", + "test-key-{{certIdxPadded}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Create CryptoCertificate object (test-domain-{{domainIdxPadded}}/test-cert-{{certIdxPadded}})", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"CryptoCertificate\": {\n \"name\": \"test-cert-{{certIdxPadded}}\",\n \"mAdminState\": \"enabled\",\n \"Filename\": \"cert:///test-cert-{{certIdxPadded}}.pem\",\n \"PasswordAlias\": \"off\",\n \"IgnoreExpiration\": \"off\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/config/test-domain-{{domainIdxPadded}}/CryptoCertificate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "test-domain-{{domainIdxPadded}}", + "CryptoCertificate" + ] + } + }, + "response": [] + }, + { + "name": "Create CryptoKey object (test-domain-{{domainIdxPadded}}/test-key-{{certIdxPadded}})", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"CryptoKey\": {\n \"name\": \"test-key-{{certIdxPadded}}\",\n \"mAdminState\": \"enabled\",\n \"Filename\": \"cert:///test-key-{{certIdxPadded}}.pem\",\n \"PasswordAlias\": \"off\",\n \"IgnoreExpiration\": \"off\"\n }\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/config/test-domain-{{domainIdxPadded}}/CryptoKey", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "test-domain-{{domainIdxPadded}}", + "CryptoKey" + ] + } + }, + "response": [] + } + ], + "description": "Run with 100 iterations. Per iteration: PUT cert PEM to filestore, PUT key PEM to filestore, POST CryptoCertificate config object pointing at the cert file, POST CryptoKey config object pointing at the key file. Inventory reads CryptoCertificate / CryptoKey config objects for per-domain cert directories (not the filestore), so all four steps are required for the certs to show up. Domain delete in Cleanup cascades to remove these config objects, so no extra cleanup is needed." + }, + { + "name": "6. Save All Domains", + "item": [ + { + "name": "Save Config - test-domain-{{paddedIter}}", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"SaveConfig\": 0\n}" + }, + "url": { + "raw": "{{BASE_URL}}/mgmt/actionqueue/test-domain-{{paddedIter}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "actionqueue", + "test-domain-{{paddedIter}}" + ] + } + }, + "response": [] + } + ], + "description": "Run with 10 iterations. Saves the filestore additions to each domain's persistent config." + }, + { + "name": "7. Verify", + "item": [ + { + "name": "List all domains (Discovery probe)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/domains/config/", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "domains", + "config", + "" + ] + } + }, + "response": [] + }, + { + "name": "List filestore for test-domain-01", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/test-domain-01", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "test-domain-01" + ] + } + }, + "response": [] + }, + { + "name": "List files in test-domain-01/cert", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/test-domain-01/cert", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "test-domain-01", + "cert" + ] + } + }, + "response": [] + }, + { + "name": "List CryptoCertificate objects in test-domain-01 (Inventory probe)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/config/test-domain-01/CryptoCertificate", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "test-domain-01", + "CryptoCertificate" + ] + } + }, + "response": [] + }, + { + "name": "List CryptoKey objects in test-domain-01", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/config/test-domain-01/CryptoKey", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "test-domain-01", + "CryptoKey" + ] + } + }, + "response": [] + }, + { + "name": "List files in default/pubcert", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/pubcert", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "pubcert" + ] + } + }, + "response": [] + }, + { + "name": "List files in default/sharedcert", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/sharedcert", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "sharedcert" + ] + } + }, + "response": [] + } + ], + "description": "Sanity-check the test data. These mirror the calls our Discovery job makes." + }, + { + "name": "Cleanup (optional)", + "item": [ + { + "name": "Delete test-domain-{{paddedIter}}", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/config/default/Domain/test-domain-{{paddedIter}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "default", + "Domain", + "test-domain-{{paddedIter}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete pubcert file (test-pubcert-{{paddedIter}}.pem)", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/pubcert/test-pubcert-{{paddedIter}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "pubcert", + "test-pubcert-{{paddedIter}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Delete sharedcert file (test-shared-{{paddedIter}}.pem)", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/sharedcert/test-shared-{{paddedIter}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "sharedcert", + "test-shared-{{paddedIter}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Delete sharedcert key file (test-shared-key-{{paddedIter}}.pem)", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/filestore/default/sharedcert/test-shared-key-{{paddedIter}}.pem", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "filestore", + "default", + "sharedcert", + "test-shared-key-{{paddedIter}}.pem" + ] + } + }, + "response": [] + }, + { + "name": "Delete default CryptoCertificate (test-shared-{{paddedIter}})", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/config/default/CryptoCertificate/test-shared-{{paddedIter}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "default", + "CryptoCertificate", + "test-shared-{{paddedIter}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete default CryptoKey (test-shared-key-{{paddedIter}})", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{BASE_URL}}/mgmt/config/default/CryptoKey/test-shared-key-{{paddedIter}}", + "host": [ + "{{BASE_URL}}" + ], + "path": [ + "mgmt", + "config", + "default", + "CryptoKey", + "test-shared-key-{{paddedIter}}" + ] + } + }, + "response": [] + } + ], + "description": "Tear down the test data when you're done. Run with 10 iterations. Test domains delete cascades to their per-domain cert files + crypto objects; appliance-wide pubcert/sharedcert files and the default-domain crypto objects (created by folder 4) need explicit deletes — included here." + } + ], + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{USERNAME}}", + "type": "string" + }, + { + "key": "password", + "value": "{{PASSWORD}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "requests": {}, + "exec": [ + "// Pad iteration number to 2 digits (1 -> '01', 10 -> '10')", + "const iter = pm.info.iteration + 1;", + "pm.variables.set('paddedIter', iter.toString().padStart(2, '0'));", + "", + "// For per-domain cert population: iterations 0-99 map to (domain 1-10) x (cert 1-10)", + "const domainIdx = Math.floor(pm.info.iteration / 10) + 1;", + "const certIdx = (pm.info.iteration % 10) + 1;", + "pm.variables.set('domainIdxPadded', domainIdx.toString().padStart(2, '0'));", + "pm.variables.set('certIdxPadded', certIdx.toString().padStart(2, '0'));" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "requests": {}, + "exec": [ + "pm.test('Status is 200, 201, or 202', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 201, 202]);", + "});" + ] + } + } + ], + "variable": [ + { + "key": "BASE_URL", + "value": "https://datapower.example.com:5554" + }, + { + "key": "USERNAME", + "value": "admin" + }, + { + "key": "PASSWORD", + "value": "" + } + ] +} \ No newline at end of file diff --git a/test/DataPower-Test.postman_environment.json b/test/DataPower-Test.postman_environment.json new file mode 100644 index 0000000..9cb820a --- /dev/null +++ b/test/DataPower-Test.postman_environment.json @@ -0,0 +1,25 @@ +{ + "id": "datapower-test-env-001", + "name": "DataPower Test Environment", + "values": [ + { + "key": "BASE_URL", + "value": "https://datapower.example.com:5554", + "type": "default", + "enabled": true + }, + { + "key": "USERNAME", + "value": "admin", + "type": "default", + "enabled": true + }, + { + "key": "PASSWORD", + "value": "", + "type": "secret", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..ec8faad --- /dev/null +++ b/test/README.md @@ -0,0 +1,117 @@ +# DataPower Test Setup + +Tools to populate a DataPower test appliance with domains and certificates so you can validate the Discovery and Inventory jobs in the IBM DataPower Orchestrator. + +## What it creates + +| Resource | Count | Location | +|----------|-------|----------| +| Application domains | 10 | `test-domain-01` through `test-domain-10` | +| Certs in `default/pubcert` | 10 | appliance-wide, visible from every domain (filestore PEMs only) | +| Cert + key files in `default/sharedcert` | 20 | 10 cert + 10 key PEMs | +| `default` CryptoCertificate / CryptoKey objects (sharedcert) | 10 + 10 | each pointing at a `sharedcert:///` PEM | +| Cert + key files in `{domain}/cert` | 200 | 10 cert + 10 key files per domain (10 domains x 20 files) | +| Per-domain CryptoCertificate config objects | 100 | 10 per domain, each pointing at one cert PEM in `cert:///` | +| Per-domain CryptoKey config objects | 100 | 10 per domain, each pointing at one key PEM in `cert:///` | + +Every cert and key uploaded is a **unique** self-signed pair, so Inventory results will show distinct thumbprints (no duplicates). + +> **Why config objects matter:** for per-domain `cert/` directories the orchestrator's Inventory enumerates `CryptoCertificate` config objects (`/mgmt/config/{domain}/CryptoCertificate`), *not* the filestore. PEMs sitting in `cert:///` without a matching CryptoCertificate object are invisible to Inventory. Folder 5 creates both. Pubcert / sharedcert are read from the filestore directly, so they don't need config objects. + +After running, Discovery should return **30 store paths** (10 domains x 3 directories: `cert`, `pubcert`, `sharedcert`). + +## Files + +| File | Purpose | +|------|---------| +| `generate-test-certs.ps1` | Generates 120 unique cert+key pairs as Postman iteration-data JSON files under `data/` | +| `DataPower-Test-Setup.postman_collection.json` | Postman collection with all the upload operations | +| `DataPower-Test.postman_environment.json` | Environment template (URL + credentials) | +| `data/*.json` | Generated iteration-data files (gitignored) | + +## Setup + +### 1. Generate the test certs + +```powershell +cd test +pwsh -File generate-test-certs.ps1 +``` + +This writes three iteration-data files into `test/data/`: + +| File | Rows | Columns | Used by | +|------|------|---------|---------| +| `pubcert-data.json` | 10 | `certPemB64` | folder 3 | +| `sharedcert-data.json` | 10 | `certPemB64`, `keyPemB64` | folder 4 | +| `perdomain-data.json` | 100 | `certPemB64`, `keyPemB64` | folder 5 | + +### 2. Import into Postman + +1. **Import collection**: Postman -> Import -> `DataPower-Test-Setup.postman_collection.json` +2. **Import environment**: Postman -> Import -> `DataPower-Test.postman_environment.json` +3. **Select the environment** in Postman's top-right dropdown +4. **Set environment variables**: + - `BASE_URL` -> your DataPower REST API URL (typically `https://your-appliance:5554`) + - `USERNAME` -> DataPower admin user + - `PASSWORD` -> DataPower admin password + +### 3. Run the folders in order + +Use **Collection Runner** (Postman -> Runner) for each folder: + +| # | Folder | Iterations | Data file | +|---|--------|------------|-----------| +| 1 | Create Domains | **10** | - | +| 2 | Save Default Domain Config | 1 | - | +| 3 | Populate Pubcert | from data | `data/pubcert-data.json` | +| 4 | Populate Sharedcert | from data | `data/sharedcert-data.json` (4 requests per iteration: filestore PUT cert, filestore PUT key, POST CryptoCertificate in `default`, POST CryptoKey in `default`) | +| 5 | Populate Per-Domain Cert Directory | from data | `data/perdomain-data.json` (4 requests per iteration: filestore PUT cert, filestore PUT key, POST CryptoCertificate, POST CryptoKey) | +| 6 | Save All Domains | **10** | - | +| 7 | Verify | 1 | - | + +For each folder: +1. Click "Runner" in Postman +2. Drag the folder into the runner +3. If the table above lists a data file, drop it into the "Data" slot — Iterations auto-fills from the row count +4. Otherwise set "Iterations" to the value above +5. Click "Run" + +### 4. Verify + +The "Verify" folder has GET requests that mirror the Discovery job's calls: + +- `GET /mgmt/domains/config/` - should return all 10 test-domain-XX entries (plus default and any pre-existing) +- `GET /mgmt/filestore/test-domain-01` - should list directory entries including `cert`, `pubcert`, `sharedcert` +- `GET /mgmt/filestore/test-domain-01/cert` - should list 20 files (10 certs + 10 keys) +- `GET /mgmt/config/test-domain-01/CryptoCertificate` - should list 10 CryptoCertificate objects (this is what Inventory actually reads) +- `GET /mgmt/config/test-domain-01/CryptoKey` - should list 10 CryptoKey objects +- `GET /mgmt/filestore/default/pubcert` - should list at least 10 test-pubcert-XX.pem files +- `GET /mgmt/filestore/default/sharedcert` - should list at least 10 test-shared-XX.pem files + +If those all return data, run the Discovery job from Keyfactor Command and confirm it surfaces the expected 30 store paths. + +## Cleanup + +To remove the test data when you're done, run the **Cleanup (optional)** folder with 10 iterations. This deletes the 10 test domains and the 20 appliance-wide cert files (pubcert + sharedcert). Files inside `{domain}/cert` are removed automatically when the domain is deleted. + +> **Notes:** +> - Files inside per-domain `cert/` and the per-domain `CryptoCertificate` / `CryptoKey` objects are removed implicitly when the test domain is deleted, so the Cleanup folder doesn't enumerate them. +> - The `default` domain's `CryptoCertificate` / `CryptoKey` objects (created by folder 4 for sharedcert) are NOT cascaded — Cleanup deletes them explicitly. Same for the appliance-wide pubcert / sharedcert filestore entries. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|--------------| +| 401 Unauthorized | Check `USERNAME` / `PASSWORD` env vars; verify the user has REST Management Interface access | +| 404 on `/mgmt/domains/config/` | REST mgmt interface may not be enabled on your DataPower; check `xml-mgmt` config | +| 409 Conflict on domain create | Domain already exists - either delete it first or skip iteration | +| 400 with "duplicate" on filestore PUT | File already exists with that name - delete first or use a different filename pattern | +| Empty cert/key on upload | Data file wasn't attached in the Runner. Re-run with the matching `data/*.json` selected. | +| Discovery returns 0 results | Check the orchestrator log for errors. Verify with the GET endpoints in folder 7. | + +## Notes + +- The certs are self-signed and intended for **lab use only**. Do not expose this appliance publicly. +- Each cert/key uploaded is unique - Inventory results will surface 10 distinct thumbprints per directory, which exercises duplicate-detection paths in the orchestrator more thoroughly than reusing one cert. +- Re-running `generate-test-certs.ps1` overwrites the data files; the certs in your appliance keep whatever was uploaded most recently. diff --git a/test/generate-test-certs.ps1 b/test/generate-test-certs.ps1 new file mode 100644 index 0000000..03b4f41 --- /dev/null +++ b/test/generate-test-certs.ps1 @@ -0,0 +1,94 @@ +# Copyright 2024 Keyfactor +# +# Generates unique self-signed cert + key pairs for the DataPower test setup +# and writes them as Postman Collection Runner iteration-data files. +# +# Output (relative to this script): +# data/pubcert-data.json - 10 rows: { certPemB64 } +# data/sharedcert-data.json - 10 rows: { certPemB64 } +# data/perdomain-data.json - 100 rows: { certPemB64, keyPemB64 } +# +# In Postman Collection Runner, drop the matching JSON file into the "Data" +# slot when running each folder; iteration count is taken from the file. + +param( + [int]$ValidDays = 365 +) + +$ErrorActionPreference = 'Stop' + +function ConvertTo-Pem { + param([byte[]]$Bytes, [string]$Header) + $b64 = [Convert]::ToBase64String($Bytes) + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine("-----BEGIN $Header-----") + for ($i = 0; $i -lt $b64.Length; $i += 64) { + $len = [Math]::Min(64, $b64.Length - $i) + [void]$sb.AppendLine($b64.Substring($i, $len)) + } + [void]$sb.AppendLine("-----END $Header-----") + return $sb.ToString() +} + +function New-CertKeyPair { + param([string]$Subject, [int]$Days) + + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $Subject, + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + $notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5) + $notAfter = [DateTimeOffset]::UtcNow.AddDays($Days) + $cert = $req.CreateSelfSigned($notBefore, $notAfter) + + $certBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + $certPem = ConvertTo-Pem -Bytes $certBytes -Header 'CERTIFICATE' + + # PKCS#8 unencrypted (-----BEGIN PRIVATE KEY-----). DataPower's filestore + # validator rejects PKCS#1 keys (-----BEGIN RSA PRIVATE KEY-----) with 400. + $keyBytes = $rsa.ExportPkcs8PrivateKey() + $keyPem = ConvertTo-Pem -Bytes $keyBytes -Header 'PRIVATE KEY' + + return [PSCustomObject]@{ + CertPemB64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($certPem)) + KeyPemB64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($keyPem)) + } + } + finally { + $rsa.Dispose() + } +} + +$dataDir = Join-Path $PSScriptRoot 'data' +New-Item -ItemType Directory -Force -Path $dataDir | Out-Null + +Write-Host "Generating 10 pubcert pairs..." -ForegroundColor Cyan +$pubcert = @(1..10 | ForEach-Object { + $p = New-CertKeyPair -Subject "CN=Pubcert-Test-$($_.ToString('00'))" -Days $ValidDays + [PSCustomObject]@{ certPemB64 = $p.CertPemB64 } +}) +$pubcert | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $dataDir 'pubcert-data.json') -Encoding ASCII + +Write-Host "Generating 10 sharedcert pairs..." -ForegroundColor Cyan +$sharedcert = @(1..10 | ForEach-Object { + $p = New-CertKeyPair -Subject "CN=Sharedcert-Test-$($_.ToString('00'))" -Days $ValidDays + [PSCustomObject]@{ certPemB64 = $p.CertPemB64; keyPemB64 = $p.KeyPemB64 } +}) +$sharedcert | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $dataDir 'sharedcert-data.json') -Encoding ASCII + +Write-Host "Generating 100 per-domain cert+key pairs..." -ForegroundColor Cyan +$perdomain = @(1..100 | ForEach-Object { + $p = New-CertKeyPair -Subject "CN=Perdomain-Test-$($_.ToString('000'))" -Days $ValidDays + [PSCustomObject]@{ certPemB64 = $p.CertPemB64; keyPemB64 = $p.KeyPemB64 } +}) +$perdomain | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $dataDir 'perdomain-data.json') -Encoding ASCII + +Write-Host "" +Write-Host "Done. Wrote:" -ForegroundColor Green +Write-Host " $dataDir\pubcert-data.json (10 rows)" +Write-Host " $dataDir\sharedcert-data.json (10 rows)" +Write-Host " $dataDir\perdomain-data.json (100 rows)" From adf2cb9e12dc5abba282ca0e4b25943af6e10832 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 11:57:11 -0400 Subject: [PATCH 10/21] Harden Inventory paths against NullReferenceException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetCerts and GetPublicCerts both unconditionally called ci.InventoryBlackList.Split(','), which NRE'd whenever Command sent the property as JSON null (the common case when the user leaves the optional "Inventory Black List" field empty during store approval — DefaultValueHandling.Populate doesn't override an explicit null). - New ParseInventoryBlacklist helper returns a case-insensitive HashSet, tolerates null/whitespace, and trims empty entries. - GetCerts: null-guard viewCertificateCollection?.CryptoCertificates and null-check items in the loop. - GetPublicCerts: tighten the PubFileStoreLocation?.PubFileStore?.PubFiles chain into one local so any link being null degrades gracefully instead of throwing. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/RequestManager.cs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/DataPower/RequestManager.cs b/DataPower/RequestManager.cs index 3fcf12b..afe7728 100644 --- a/DataPower/RequestManager.cs +++ b/DataPower/RequestManager.cs @@ -58,6 +58,19 @@ private string ResolvePamField(string name, string value) return _resolver.Resolve(value); } + // Parse the InventoryBlackList store property into a case-insensitive set. + // Tolerates null/empty (Command may send the property as JSON null when the user + // leaves the field blank, which DefaultValueHandling.Populate doesn't override). + private static HashSet ParseInventoryBlacklist(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + return new HashSet( + raw.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0), + StringComparer.OrdinalIgnoreCase); + } + public bool DoesCryptoCertificateObjectExist(CertStoreInfo ci, string cryptoCertObjectName, DataPowerClient apiClient) { @@ -1040,19 +1053,18 @@ public JobResult GetPublicCerts(InventoryJobConfiguration config, DataPowerClien _logger.LogTrace($"Public Cert List Response {JsonConvert.SerializeObject(viewCertificateCollection)}"); var intCount = 0; - var s = ci.InventoryBlackList.Split(','); - + var blackList = ParseInventoryBlacklist(ci.InventoryBlackList); var intMax = ci.InventoryPageSize; - var blackList = s; - _logger.LogTrace($"Max Inventory: {intMax} Inventory Black List Count: {blackList.Length}"); + _logger.LogTrace($"Max Inventory: {intMax} Inventory Black List Count: {blackList.Count}"); _logger.LogTrace("Got App Config Settings from File"); // ReSharper disable once CollectionNeverQueried.Local var inventoryItems = new List(); - if (viewCertificateCollection.PubFileStoreLocation.PubFileStore?.PubFiles != null) - foreach (var pc in viewCertificateCollection.PubFileStoreLocation.PubFileStore.PubFiles) + var pubFiles = viewCertificateCollection?.PubFileStoreLocation?.PubFileStore?.PubFiles; + if (pubFiles != null) + foreach (var pc in pubFiles) { _logger.LogTrace($"Looping through public files: {pc.Name}"); var viewCertDetail = new ViewPubCertificateDetailRequest(pc.Name); @@ -1133,13 +1145,13 @@ public JobResult GetCerts(InventoryJobConfiguration config, DataPowerClient apiC _logger.LogTrace($"Get Certs Response: {JsonConvert.SerializeObject(viewCertificateCollection)}"); // ReSharper disable once CollectionNeverQueried.Local var inventoryItems = new List(); - var s = ci.InventoryBlackList.Split(','); - var blackList = s; + var blackList = ParseInventoryBlacklist(ci.InventoryBlackList); _logger.LogTrace("Start loop"); - foreach (var cc in viewCertificateCollection.CryptoCertificates) - if (!string.IsNullOrEmpty(cc.Name)) + var cryptoCerts = viewCertificateCollection?.CryptoCertificates ?? Array.Empty(); + foreach (var cc in cryptoCerts) + if (cc != null && !string.IsNullOrEmpty(cc.Name)) { _logger.LogTrace($"Looping through Certificate Store files: {cc.Name}"); From a5e9d896cf6f585ca6e183811d6a2f4c406a6360 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 12:29:18 -0400 Subject: [PATCH 11/21] Surface inventory counts in FlowLogger summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without enabling Trace-level logging on the orchestrator agent, an empty inventory result tells you nothing about where the certs disappear (parse? blacklist? per-cert detail fetch?). Add two flow steps inside each inventory path so the failure breadcrumb makes the count visible in the standard FlowLogger summary the user already sees: - GetCerts.ParseResponse — certCount=N, blacklistCount=N - GetCerts.SubmitInventory — itemCount=N - GetPublicCerts.ParseResponse — pubFileCount=N, blacklistCount=N - GetPublicCerts.SubmitInventory — itemCount=N Plumb FlowLogger through GetCerts/GetPublicCerts as an optional parameter (defaults to null so internal callers don't break). Existing per-cert trace messages (request/response/loop iteration) already live at LogTrace and are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/Jobs/Inventory.cs | 4 ++-- DataPower/RequestManager.cs | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/DataPower/Jobs/Inventory.cs b/DataPower/Jobs/Inventory.cs index dd8021e..05258d0 100644 --- a/DataPower/Jobs/Inventory.cs +++ b/DataPower/Jobs/Inventory.cs @@ -113,8 +113,8 @@ private JobResult PerformInventory(InventoryJobConfiguration config, SubmitInven var inventoryResult = flow.Step( storePath.Contains(publicCertStoreName) ? "GetPublicCerts" : "GetCerts", () => storePath.Contains(publicCertStoreName) - ? _reqManager.GetPublicCerts(config, apiClient, submitInventory, ci) - : _reqManager.GetCerts(config, apiClient, submitInventory, ci)); + ? _reqManager.GetPublicCerts(config, apiClient, submitInventory, ci, flow) + : _reqManager.GetCerts(config, apiClient, submitInventory, ci, flow)); flow.Step("Result", $"{inventoryResult.Result}"); diff --git a/DataPower/RequestManager.cs b/DataPower/RequestManager.cs index afe7728..179eea1 100644 --- a/DataPower/RequestManager.cs +++ b/DataPower/RequestManager.cs @@ -1042,7 +1042,7 @@ private void ReplaceCryptoObject(CertStoreInfo ci, string cryptoCertObjectName, } public JobResult GetPublicCerts(InventoryJobConfiguration config, DataPowerClient apiClient, - SubmitInventoryUpdate submitInventory, CertStoreInfo ci) + SubmitInventoryUpdate submitInventory, CertStoreInfo ci, FlowLogger flow = null) { try { @@ -1063,6 +1063,7 @@ public JobResult GetPublicCerts(InventoryJobConfiguration config, DataPowerClien // ReSharper disable once CollectionNeverQueried.Local var inventoryItems = new List(); var pubFiles = viewCertificateCollection?.PubFileStoreLocation?.PubFileStore?.PubFiles; + flow?.Step("GetPublicCerts.ParseResponse", $"pubFileCount={pubFiles?.Length ?? 0}, blacklistCount={blackList.Count}"); if (pubFiles != null) foreach (var pc in pubFiles) { @@ -1116,6 +1117,7 @@ public JobResult GetPublicCerts(InventoryJobConfiguration config, DataPowerClien } _logger.LogTrace($"Inventory Items: {JsonConvert.SerializeObject(inventoryItems)}"); + flow?.Step("GetPublicCerts.SubmitInventory", $"itemCount={inventoryItems.Count}"); submitInventory.Invoke(inventoryItems); _logger.LogTrace("Submitted Inventory Items..."); @@ -1134,7 +1136,7 @@ public JobResult GetPublicCerts(InventoryJobConfiguration config, DataPowerClien } public JobResult GetCerts(InventoryJobConfiguration config, DataPowerClient apiClient, - SubmitInventoryUpdate submitInventory, CertStoreInfo ci) + SubmitInventoryUpdate submitInventory, CertStoreInfo ci, FlowLogger flow = null) { try { @@ -1150,6 +1152,7 @@ public JobResult GetCerts(InventoryJobConfiguration config, DataPowerClient apiC _logger.LogTrace("Start loop"); var cryptoCerts = viewCertificateCollection?.CryptoCertificates ?? Array.Empty(); + flow?.Step("GetCerts.ParseResponse", $"certCount={cryptoCerts.Length}, blacklistCount={blackList.Count}"); foreach (var cc in cryptoCerts) if (cc != null && !string.IsNullOrEmpty(cc.Name)) { @@ -1194,7 +1197,10 @@ public JobResult GetCerts(InventoryJobConfiguration config, DataPowerClient apiC } } + _logger.LogTrace($"Submitting {inventoryItems.Count} inventory items"); + flow?.Step("GetCerts.SubmitInventory", $"itemCount={inventoryItems.Count}"); submitInventory.Invoke(inventoryItems); + _logger.LogTrace("Submitted Inventory Items."); return new JobResult { From b3ab2c00afefb84be4fd2ee8538207c9cc632f4d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 12:42:40 -0400 Subject: [PATCH 12/21] Wire RequestManager logger into the orchestrator's NLog pipeline The RequestManager constructor was newing up a bare LoggerFactory and asking it for a logger: var loggerFactory = (ILoggerFactory) new LoggerFactory(); var reqLogger = loggerFactory.CreateLogger(); That factory has no providers attached, so every _logger.LogTrace and _logger.LogError call inside RequestManager (the entire request/response breadcrumb in GetCerts and GetPublicCerts plus all the management paths) was silently dropped. The job log shows lots of trace lines from DataPower.Jobs.Inventory but nothing from DataPower.RequestManager. Use LogHandler.GetClassLogger() like the rest of the codebase (Inventory, Discovery, Management, JobBase). Field type goes from ILogger to ILogger to match what LogHandler returns. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/RequestManager.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/DataPower/RequestManager.cs b/DataPower/RequestManager.cs index 179eea1..1d02d95 100644 --- a/DataPower/RequestManager.cs +++ b/DataPower/RequestManager.cs @@ -37,7 +37,7 @@ namespace Keyfactor.Extensions.Orchestrator.DataPower { public class RequestManager { - private readonly ILogger _logger; + private readonly ILogger _logger; private string _protocol; private IPAMSecretResolver _resolver; private string ServerUserName { get; set; } @@ -46,9 +46,10 @@ public class RequestManager public RequestManager(IPAMSecretResolver resolver) { - var loggerFactory = (ILoggerFactory) new LoggerFactory(); - var reqLogger = loggerFactory.CreateLogger(); - _logger = reqLogger; + // Must use LogHandler so we plug into the orchestrator's NLog pipeline. + // A bare-new LoggerFactory has no providers, so LogTrace/LogError calls + // get silently dropped. + _logger = LogHandler.GetClassLogger(); _resolver = resolver; } From 86c54c3b00421fa95887bbca3d5e0f25f619004d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 13:01:16 -0400 Subject: [PATCH 13/21] Surface DataPower error response body in API call failures When DataPower returns 4xx/5xx, the appliance includes a short JSON message explaining what's wrong (e.g. "Cannot find requested certificate object", "Invalid action name"). The client was capturing that body into DataPowerApiException.ResponseBody but never putting it in the exception message or the ErrorLog, so callers downstream only saw "HTTP 400 BadRequest" with no detail. - ApiRequestString error log now includes the body alongside the status - DataPowerApiException message now ends with "Body: {body}", so the body appears wherever the exception's Message is rendered (per-cert "Certificate not retrievable" log lines, etc.) Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/Client/DataPowerClient.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/DataPower/Client/DataPowerClient.cs b/DataPower/Client/DataPowerClient.cs index a232833..10ea657 100644 --- a/DataPower/Client/DataPowerClient.cs +++ b/DataPower/Client/DataPowerClient.cs @@ -542,9 +542,11 @@ public string ApiRequestString(string strCall, string strPostUrl, string strMeth } } - _logger.LogError(webEx, "END APIRequestString error: {ErrorMessage}", LogHandler.FlattenException(webEx)); + _logger.LogError(webEx, + "END APIRequestString error for {Operation}: HTTP {Status} {StatusName}, body: {Body}", + strCall, (int)statusCode, statusCode, responseBody); throw new DataPowerApiException( - $"DataPower API call '{strCall}' failed with HTTP {(int)statusCode} {statusCode}.", + $"DataPower API call '{strCall}' failed with HTTP {(int)statusCode} {statusCode}. Body: {responseBody}", statusCode, strCall, responseBody, webEx); } catch (DataPowerApiException) From c3a9d022e8eca94f7ea58bb0bb8e38bd13f312c5 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 14:26:21 -0400 Subject: [PATCH 14/21] Add x509 extensions to generated test certs DataPower's CryptoCertificate loader rejected our barebones self-signed certs with "The specified certificate has an unreadable, corrupt, or invalid certificate file or has an invalid password." The certs parsed fine with openssl, but DataPower expects a real end-entity TLS cert. Add the standard extensions a normal server cert carries: - BasicConstraints (cA=false, critical) - KeyUsage (DigitalSignature + KeyEncipherment, critical) - ExtendedKeyUsage (serverAuth, clientAuth) - SubjectKeyIdentifier Co-Authored-By: Claude Opus 4.7 (1M context) --- test/generate-test-certs.ps1 | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/generate-test-certs.ps1 b/test/generate-test-certs.ps1 index 03b4f41..7d20c26 100644 --- a/test/generate-test-certs.ps1 +++ b/test/generate-test-certs.ps1 @@ -41,6 +41,28 @@ function New-CertKeyPair { [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) + + # DataPower's CryptoCertificate loader rejects barebones certs ("unreadable, + # corrupt, or invalid certificate file") - it expects a real end-entity TLS cert + # with the usual extensions. Add BasicConstraints, KeyUsage, EKU, and SKI. + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new( + $false, $false, 0, $true)) + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + ([System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment), + $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + [void]$ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.1')) # serverAuth + [void]$ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.2')) # clientAuth + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new( + $ekuOids, $false)) + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new( + $req.PublicKey, $false)) + $notBefore = [DateTimeOffset]::UtcNow.AddMinutes(-5) $notAfter = [DateTimeOffset]::UtcNow.AddDays($Days) $cert = $req.CreateSelfSigned($notBefore, $notAfter) From c91c464a80de49846bbbb2815e2defc7fa1aa530 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 15:44:18 -0400 Subject: [PATCH 15/21] Filter CryptoCertificate inventory by store path's URI scheme GET /mgmt/config/{domain}/CryptoCertificate returns every CryptoCertificate object in the domain, regardless of whether its Filename points at cert:///, pubcert:///, or sharedcert:///. GetCerts iterated over all of them, so an inventory job for "default\sharedcert" surfaced pubcert: items too (DigicertRoot, Goog showing up alongside sharedcert entries). Filter the list to objects whose Filename starts with "{ci.CertificateStore}:" so the inventory only contains items that actually live in the requested store. The FlowLogger ParseResponse step now reports both totals: GetCerts.ParseResponse - certCount=4 (filtered from 6 by scheme 'sharedcert:'), blacklistCount=0 so it's obvious when filtering is dropping items. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/RequestManager.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/DataPower/RequestManager.cs b/DataPower/RequestManager.cs index 1d02d95..880f0df 100644 --- a/DataPower/RequestManager.cs +++ b/DataPower/RequestManager.cs @@ -1152,8 +1152,21 @@ public JobResult GetCerts(InventoryJobConfiguration config, DataPowerClient apiC _logger.LogTrace("Start loop"); - var cryptoCerts = viewCertificateCollection?.CryptoCertificates ?? Array.Empty(); - flow?.Step("GetCerts.ParseResponse", $"certCount={cryptoCerts.Length}, blacklistCount={blackList.Count}"); + var allCryptoCerts = viewCertificateCollection?.CryptoCertificates ?? Array.Empty(); + + // /mgmt/config/{domain}/CryptoCertificate returns every CryptoCertificate + // object in the domain regardless of which filestore directory its file + // lives in. Filter to those whose Filename URI scheme matches the store + // path we're inventorying so a "default\sharedcert" job doesn't show + // pubcert: entries (and vice versa). + var storeScheme = (ci.CertificateStore ?? string.Empty).Trim() + ":"; + var cryptoCerts = allCryptoCerts + .Where(cc => cc?.CertFile != null && + cc.CertFile.StartsWith(storeScheme, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + flow?.Step("GetCerts.ParseResponse", + $"certCount={cryptoCerts.Length} (filtered from {allCryptoCerts.Length} by scheme '{storeScheme}'), blacklistCount={blackList.Count}"); foreach (var cc in cryptoCerts) if (cc != null && !string.IsNullOrEmpty(cc.Name)) { From 4bbd1f22f8f4f946c4f44cd6b3ff61364a587cdc Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 15:57:08 -0400 Subject: [PATCH 16/21] Discovery: honor "Directories to search" job field The cert-store directory filter was a hardcoded constant ({cert, pubcert, sharedcert}) and the Discovery form's "Directories to search" value was silently ignored. Operators with custom DataPower filestore schemes had no way to extend it, and a user could fill in the field expecting it to scope the run. Read the comma-separated value from JobProperties (trying common key casings: dirs, Dirs, directories, Directories, DirsToSearch). Trim, strip any trailing colon, dedupe (case-insensitive). Fall back to the standard {cert, pubcert, sharedcert} set when the field is empty or absent. The FlowLogger summary now exposes which list was applied: [OK] ResolveDirsToSearch - source=user (key=dirs), dirs=[cert,sharedcert] [OK] ResolveDirsToSearch - source=default, dirs=[cert,pubcert,sharedcert] docs/discovery-overview.{md,html} updated to describe the field. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/Jobs/Discovery.cs | 41 +++++++++++++++++++++++++++++++++--- docs/discovery-overview.html | 6 +++--- docs/discovery-overview.md | 16 +++++++++++--- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/DataPower/Jobs/Discovery.cs b/DataPower/Jobs/Discovery.cs index 1a93167..653744b 100644 --- a/DataPower/Jobs/Discovery.cs +++ b/DataPower/Jobs/Discovery.cs @@ -26,14 +26,44 @@ namespace Keyfactor.Extensions.Orchestrator.DataPower.Jobs { public class Discovery : JobBase, IDiscoveryJobExtension { - // Certificate-relevant filestore directories on DataPower - private static readonly HashSet CertStoreDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) + // Default cert-relevant filestore directories on DataPower. Used when the + // operator leaves the Discovery job's "Directories to search" field empty. + private static readonly HashSet DefaultCertStoreDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) { "cert", "pubcert", "sharedcert" }; + // Keyfactor Command's Discovery form posts the comma-separated "Directories + // to search" value into JobProperties. Try the common key names since the + // exact casing has shifted across Command versions. + private static readonly string[] DirsToSearchKeys = { "dirs", "Dirs", "directories", "Directories", "DirsToSearch" }; + + private static (HashSet Dirs, string Source) ResolveDirsToSearch(DiscoveryJobConfiguration config) + { + if (config?.JobProperties != null) + { + foreach (var key in DirsToSearchKeys) + { + if (!config.JobProperties.TryGetValue(key, out var raw)) continue; + var s = raw?.ToString(); + if (string.IsNullOrWhiteSpace(s)) continue; + + var dirs = new HashSet( + s.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim().TrimEnd(':')) + .Where(d => d.Length > 0), + StringComparer.OrdinalIgnoreCase); + + if (dirs.Count > 0) + return (dirs, $"user (key={key})"); + } + } + + return (DefaultCertStoreDirectories, "default"); + } + public Discovery(IPAMSecretResolver resolver) : base(resolver) { Logger = LogHandler.GetClassLogger(); @@ -107,6 +137,11 @@ private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDisco "default"); }, $"host={config.ClientMachine}"); + var resolvedDirs = ResolveDirsToSearch(config); + var certStoreDirectories = resolvedDirs.Dirs; + flow.Step("ResolveDirsToSearch", + $"source={resolvedDirs.Source}, dirs=[{string.Join(",", certStoreDirectories)}]"); + List domains = null; flow.Step("ListDomains", () => { @@ -148,7 +183,7 @@ private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDisco // matching and before composing the store path. var certDirectories = directories .Select(d => d?.TrimEnd(':')) - .Where(d => !string.IsNullOrEmpty(d) && CertStoreDirectories.Contains(d)) + .Where(d => !string.IsNullOrEmpty(d) && certStoreDirectories.Contains(d)) .ToList(); foreach (var dir in certDirectories) diff --git a/docs/discovery-overview.html b/docs/discovery-overview.html index cb0010a..300618c 100644 --- a/docs/discovery-overview.html +++ b/docs/discovery-overview.html @@ -524,7 +524,7 @@

Without Discovery

With Discovery

  • Point Discovery at the appliance — it finds all domains automatically
  • -
  • All certificate store directories (cert, pubcert, sharedcert) detected per domain
  • +
  • Standard certificate store directories (cert, pubcert, sharedcert) detected per domain by default; configurable via the job's "Directories to search" field
  • Store paths returned in ready-to-use format: domain\store
  • New domains picked up on next scheduled Discovery run
  • Run once per environment to discover everything
  • @@ -596,7 +596,7 @@

    How Discovery Works

    3
    Discover Certificate Stores Per Domain
    -

    For each domain found, the orchestrator calls GET /mgmt/filestore/{domain} to list the top-level filestore directories. It filters for certificate-relevant directories: cert, pubcert, and sharedcert.

    +

    For each domain found, the orchestrator calls GET /mgmt/filestore/{domain} to list the top-level filestore directories. It filters those names against the Discovery job's Directories to search field (comma-separated, e.g. cert,pubcert,sharedcert). When the field is empty, the orchestrator falls back to the standard set: cert, pubcert, sharedcert. The trailing colon DataPower returns on each location name is stripped before matching. The FlowLogger summary records which list was used (source=user vs source=default).

    @@ -727,7 +727,7 @@

    DataPower API Calls Used

    /mgmt/filestore/{domain}
    -

    Returns the top-level filestore directories for a specific domain. The orchestrator filters for cert, pubcert, and sharedcert.

    +

    Returns the top-level filestore directories for a specific domain. The orchestrator filters those names against the Discovery job's "Directories to search" field (comma-separated; defaults to cert,pubcert,sharedcert).

    { "filestore": { diff --git a/docs/discovery-overview.md b/docs/discovery-overview.md index 0b27197..8120945 100644 --- a/docs/discovery-overview.md +++ b/docs/discovery-overview.md @@ -18,7 +18,7 @@ ### With Discovery (Automated) - Point Discovery at the appliance - it finds all domains automatically -- All certificate store directories (`cert`, `pubcert`, `sharedcert`) detected per domain +- Standard certificate store directories (`cert`, `pubcert`, `sharedcert`) detected per domain by default; configurable via the job's "Directories to search" field - Store paths returned in ready-to-use format: `domain\store` - New domains picked up on next scheduled Discovery run - Run once per environment to discover everything @@ -52,7 +52,17 @@ The orchestrator calls `GET /mgmt/domains/config/` on the DataPower REST Managem ### Step 3: Discover Certificate Stores Per Domain -For each domain found, the orchestrator calls `GET /mgmt/filestore/{domain}` to list the top-level filestore directories. It filters for certificate-relevant directories: `cert`, `pubcert`, and `sharedcert`. +For each domain found, the orchestrator calls `GET /mgmt/filestore/{domain}` to list the top-level filestore directories. It then filters those directories against the **Directories to search** value supplied with the Discovery job — a comma-separated list (e.g. `cert,pubcert,sharedcert`). When the field is left empty the orchestrator falls back to the standard set: `cert`, `pubcert`, `sharedcert`. The trailing colon DataPower returns on each location name (e.g. `cert:`) is stripped before matching, so the user-supplied values are written without the colon. The FlowLogger summary records which list was used: + +``` +[OK] ResolveDirsToSearch - source=user (key=dirs), dirs=[cert,sharedcert] +``` + +or + +``` +[OK] ResolveDirsToSearch - source=default, dirs=[cert,pubcert,sharedcert] +``` ### Step 4: Build Store Paths @@ -131,7 +141,7 @@ Returns all application domains configured on the DataPower appliance. ### `GET /mgmt/filestore/{domain}` -Returns the top-level filestore directories for a specific domain. The orchestrator filters for `cert`, `pubcert`, and `sharedcert`. +Returns the top-level filestore directories for a specific domain. The orchestrator filters those names against the Discovery job's "Directories to search" field (comma-separated; defaults to `cert,pubcert,sharedcert`). ```json { From 093409f0b60b5bbc4afe4033b364c6e0a39a6516 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 16:30:23 -0400 Subject: [PATCH 17/21] Coherent appliance-wide store handling for pubcert/sharedcert Three connected fixes that share a root cause: pubcert and sharedcert are appliance-wide on DataPower (owned by the default domain) but the code paths treated them like per-domain stores. Discovery emitted N copies (one per domain) all aliasing the same physical data; Add tried to PUT through the per-domain endpoint and got HTTP 403; the catch in AddCertStore swallowed the 403 and returned Success, so Command thought the cert was added when nothing was on the appliance. - Discovery: emit appliance-wide directories (pubcert, sharedcert) only under "default", not once per domain. Per-domain "cert" still emitted for every domain. A 10-domain appliance now produces 12 store paths (10 per-domain + default\pubcert + default\sharedcert) instead of 30. - AddCertStore: add a sharedcert guard mirroring the existing pubcert guard. Adds against \sharedcert return Failure with "You can only add to sharedcert on the default domain" instead of letting the appliance's 403 bubble up. - AddCertStore catch: was logging Trace, calling SaveConfig (persisting whatever partial state the appliance reached when the Add blew up), and falling through to return Success. Now: log Error with the full exception, extract the appliance's response body via DataPowerApiException.Find when present, and return Failure with the body in the FailureMessage so operators see what DataPower actually rejected. - docs/discovery-overview.{md,html}: describe the new emit shape, the per-domain vs appliance-wide split, and a migration note for existing deployments that have already approved \pubcert / \sharedcert as cert stores - those are now orphans and should be deleted in favor of default\pubcert / default\sharedcert. Co-Authored-By: Claude Opus 4.7 (1M context) --- DataPower/Jobs/Discovery.cs | 20 ++++++++++++++++++ DataPower/RequestManager.cs | 40 ++++++++++++++++++++++++++++++++++-- docs/discovery-overview.html | 12 +++++------ docs/discovery-overview.md | 26 ++++++++++++++--------- 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/DataPower/Jobs/Discovery.cs b/DataPower/Jobs/Discovery.cs index 653744b..f7750c9 100644 --- a/DataPower/Jobs/Discovery.cs +++ b/DataPower/Jobs/Discovery.cs @@ -35,6 +35,19 @@ public class Discovery : JobBase, IDiscoveryJobExtension "sharedcert" }; + // pubcert and sharedcert are appliance-wide on DataPower (owned by the + // default domain) - other domains can read them but writes must go through + // default. Discovery emits these only under "default" so operators don't + // get N copies of the same physical store, one per domain, all aliasing + // each other. + private static readonly HashSet ApplianceWideDirectories = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "pubcert", + "sharedcert" + }; + + private const string DefaultDomainName = "default"; + // Keyfactor Command's Discovery form posts the comma-separated "Directories // to search" value into JobProperties. Try the common key names since the // exact casing has shifted across Command versions. @@ -186,8 +199,15 @@ private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDisco .Where(d => !string.IsNullOrEmpty(d) && certStoreDirectories.Contains(d)) .ToList(); + var isDefault = string.Equals(domain.Name, DefaultDomainName, StringComparison.OrdinalIgnoreCase); foreach (var dir in certDirectories) { + if (ApplianceWideDirectories.Contains(dir) && !isDefault) + { + flow.Skip($"{domain.Name}\\{dir}", "appliance-wide; emitted only under default"); + continue; + } + var storePath = $"{domain.Name}\\{dir}"; discoveredLocations.Add(storePath); flow.Step($"Discovered-{storePath}"); diff --git a/DataPower/RequestManager.cs b/DataPower/RequestManager.cs index 880f0df..8abd899 100644 --- a/DataPower/RequestManager.cs +++ b/DataPower/RequestManager.cs @@ -769,6 +769,10 @@ public JobResult Add(ManagementJobConfiguration addConfig, CertStoreInfo ci, Nam var storePath = addConfig.CertificateStoreDetails.StorePath; _logger.LogTrace($"publicCertStoreName: {publicCertStoreName} storePath: {storePath}"); + // pubcert and sharedcert are appliance-wide on DataPower (owned by the + // default domain). DataPower's REST mgmt rejects writes through any + // non-default domain context with a 403. Reject upfront with a clear + // message instead of letting the appliance's "Forbidden." come back. if (storePath.Contains("pubcert")) { if (storePath != publicCertStoreName && (storePath != "default\\" + publicCertStoreName)) @@ -782,6 +786,19 @@ public JobResult Add(ManagementJobConfiguration addConfig, CertStoreInfo ci, Nam } } + if (storePath.Contains("sharedcert")) + { + if (storePath != "sharedcert" && storePath != "default\\sharedcert") + { + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = addConfig.JobHistoryId, + FailureMessage = "You can only add to sharedcert on the default domain" + }; + } + } + var result = storePath.Contains(publicCertStoreName) ? AddPubCert(addConfig, ci) : AddCertStore(addConfig, ci, np); @@ -883,8 +900,27 @@ private JobResult AddCertStore(ManagementJobConfiguration addConfig, CertStoreIn } catch (Exception ex) { - _logger.LogTrace($"Error on {alias}: {LogHandler.FlattenException(ex)}"); - apiClient.SaveConfig(); + // Silent-failure trap fix: this catch used to log Trace, run SaveConfig + // (persisting whatever partial state the appliance had reached when the + // Add blew up), and fall through to return Success. So a 403 / 404 / 500 + // from DataPower would surface as a green checkmark in Command with no + // cert anywhere on the appliance. Now: log Error and propagate Failure + // with the appliance's response body when available. + var apiEx = DataPowerApiException.Find(ex); + var detail = apiEx != null + ? $"{apiEx.Operation} returned HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}: {apiEx.ResponseBody}" + : ex.Message; + + _logger.LogError(ex, + "Add to {Domain}\\{Store} failed for alias {Alias}: {ErrorMessage}", + ci.Domain, ci.CertificateStore, alias, LogHandler.FlattenException(ex)); + + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = addConfig.JobHistoryId, + FailureMessage = $"Add failed for '{alias}' on {ci.Domain}\\{ci.CertificateStore}: {detail}" + }; } _logger.MethodExit(LogLevel.Debug); diff --git a/docs/discovery-overview.html b/docs/discovery-overview.html index 300618c..3fa84d1 100644 --- a/docs/discovery-overview.html +++ b/docs/discovery-overview.html @@ -543,15 +543,15 @@

    Customer Scenario

    Environment
    Impact
    Production
    -
    50 domains × 2 cert stores = 100 store definitions auto-discovered in one job
    +
    50 per-domain cert + default\pubcert + default\sharedcert = 52 stores auto-discovered in one job
    Test
    -
    40 domains × 2 cert stores = 80 store definitions auto-discovered in one job
    +
    40 per-domain cert + default\pubcert + default\sharedcert = 42 stores auto-discovered in one job
    Dev
    -
    30 domains × 2 cert stores = 60 store definitions auto-discovered in one job
    +
    30 per-domain cert + default\pubcert + default\sharedcert = 32 stores auto-discovered in one job
    Sandbox
    -
    20 domains × 2 cert stores = 40 store definitions auto-discovered in one job
    +
    20 per-domain cert + default\pubcert + default\sharedcert = 22 stores auto-discovered in one job
    Total
    -
    280 cert store definitions — discovered automatically with 4 Discovery jobs
    +
    148 cert store definitions — discovered automatically with 4 Discovery jobs
    @@ -596,7 +596,7 @@

    How Discovery Works

    3
    Discover Certificate Stores Per Domain
    -

    For each domain found, the orchestrator calls GET /mgmt/filestore/{domain} to list the top-level filestore directories. It filters those names against the Discovery job's Directories to search field (comma-separated, e.g. cert,pubcert,sharedcert). When the field is empty, the orchestrator falls back to the standard set: cert, pubcert, sharedcert. The trailing colon DataPower returns on each location name is stripped before matching. The FlowLogger summary records which list was used (source=user vs source=default).

    +

    For each domain found, the orchestrator calls GET /mgmt/filestore/{domain} to list the top-level filestore directories. It filters those names against the Discovery job's Directories to search field (comma-separated, e.g. cert,pubcert,sharedcert). When the field is empty, the orchestrator falls back to the standard set: cert, pubcert, sharedcert. The trailing colon DataPower returns on each location name is stripped before matching. The FlowLogger summary records which list was used (source=user vs source=default). Appliance-wide directories (pubcert, sharedcert) are emitted only under default — other domains can read them through their filestore view, but they alias the same physical data and writes through any non-default context are rejected by DataPower with HTTP 403. So Discovery surfaces default\pubcert and default\sharedcert exactly once, and per-domain <domain>\cert for each domain.

    diff --git a/docs/discovery-overview.md b/docs/discovery-overview.md index 8120945..d385230 100644 --- a/docs/discovery-overview.md +++ b/docs/discovery-overview.md @@ -32,11 +32,11 @@ A typical enterprise DataPower deployment with multiple environments, each conta | Environment | Impact | |-------------|--------| -| Production | 50 domains x 2 cert stores = **100 store definitions** auto-discovered in one job | -| Test | 40 domains x 2 cert stores = **80 store definitions** auto-discovered in one job | -| Dev | 30 domains x 2 cert stores = **60 store definitions** auto-discovered in one job | -| Sandbox | 20 domains x 2 cert stores = **40 store definitions** auto-discovered in one job | -| **Total** | **280 cert store definitions** - discovered automatically with 4 Discovery jobs | +| Production | 50 per-domain `cert` stores + `default\pubcert` + `default\sharedcert` = **52 stores** auto-discovered in one job | +| Test | 40 per-domain `cert` stores + `default\pubcert` + `default\sharedcert` = **42 stores** auto-discovered in one job | +| Dev | 30 per-domain `cert` stores + `default\pubcert` + `default\sharedcert` = **32 stores** auto-discovered in one job | +| Sandbox | 20 per-domain `cert` stores + `default\pubcert` + `default\sharedcert` = **22 stores** auto-discovered in one job | +| **Total** | **148 cert store definitions** - discovered automatically with 4 Discovery jobs | --- @@ -68,12 +68,16 @@ or Each domain + directory combination is formatted as a store path using the existing convention: `domain\directory` (e.g., `production-api\cert`). +> **Appliance-wide stores collapse to default.** `pubcert` and `sharedcert` are physically a single store on the appliance, owned by the `default` domain (other domains can read them but writes return HTTP 403). Discovery emits each appliance-wide directory **only once**, under `default` — `default\pubcert` and `default\sharedcert`. Per-domain `cert/` is emitted for every domain. So a 10-domain appliance produces 12 store paths (10 × `\cert` plus `default\pubcert` and `default\sharedcert`), not 30. + ### Step 5: Submit to Keyfactor Command All discovered store paths are submitted back to Keyfactor via the `SubmitDiscoveryUpdate` callback. Keyfactor Command can then auto-create the corresponding certificate store definitions. > **Resilient by design:** If the orchestrator cannot access a specific domain's filestore (e.g., due to permissions), it logs a warning and continues discovering the remaining domains. One inaccessible domain does not block the entire job. +> **Migration note for existing deployments:** Earlier versions emitted `\pubcert` and `\sharedcert` for every domain. If you've previously approved any of those non-default entries as cert stores, they're now orphaned (they alias the same physical data and Add/Remove against them is rejected by the appliance). Re-run Discovery, approve the canonical `default\pubcert` and `default\sharedcert`, and remove the duplicates. + --- ## Store Path Format @@ -86,11 +90,13 @@ All operations in the DataPower Orchestrator (Discovery, Inventory, Add, Remove) ### Certificate Store Directories -| Directory | Scope | Contents | -|---------------|-----------------|----------| -| `cert` | Per-domain | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | -| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | -| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | +| Directory | Scope | Discovery emits as | Contents | +|---------------|-----------------|-----------------------------|----------| +| `cert` | Per-domain | `\cert` (each domain) | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | +| `pubcert` | Appliance-wide | `default\pubcert` (once) | Public/trusted certificates shared across all domains | +| `sharedcert` | Appliance-wide | `default\sharedcert` (once) | Shared certificates that persist across firmware upgrades | + +Add and Remove against `\pubcert` or `\sharedcert` are rejected by the orchestrator with a clear failure message — DataPower itself returns HTTP 403 for these, since appliance-wide stores can only be mutated through the `default` domain. ### Examples From bc9c8103e5cc4d6de82204a7f6d6c7406c0999fb Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Fri, 1 May 2026 16:54:55 -0400 Subject: [PATCH 18/21] Delete docsource/fortiweb.md --- docsource/fortiweb.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 docsource/fortiweb.md diff --git a/docsource/fortiweb.md b/docsource/fortiweb.md deleted file mode 100644 index d53d056..0000000 --- a/docsource/fortiweb.md +++ /dev/null @@ -1,20 +0,0 @@ -## Overview - -TODO Overview is a required section - -## Requirements - -TODO Requirements is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - -## Discovery Job Configuration - -TODO Discovery Job Configuration is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - -## Certificate Store Configuration - -TODO Certificate Store Configuration is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - -## Global Store Type Section - -TODO Global Store Type Section is an optional section. If this section doesn't seem necessary on initial glance, please delete it. Refer to the docs on [Confluence](https://keyfactor.atlassian.net/wiki/x/SAAyHg) for more info - From ea01431c68e2022facdbf8b75471105cc2621bd8 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Fri, 1 May 2026 17:01:05 -0400 Subject: [PATCH 19/21] Restructure docs to match doctool docsource contract The previous readme_source.md and docsource/content.md duplicated content that the doctool generates from integration-manifest.json (Compatibility, Support, Requirements & Prerequisites, the per-store Manual Creation Basic/Advanced/Custom Fields tables). When the shared keyfactor-starter-workflow.yml regenerates README.md, that produced duplicated headings and stale tables. docsource also carried a fortiweb.md file from a prior copy-paste that doesn't belong in this repo. - readme_source.md: trimmed to ~25 lines. Short overview, the vendor-side prereqs (REST mgmt enabled on 5554, RBAC for the API user, network reachability), and License. No more inline store-type-config tables, no more test-case dumps - those are either manifest-derived or development scratch. - docsource/content.md: rebuilt as the architectural overview. Mermaid flow, store-path format, the per-domain vs appliance-wide split with the new emit shape (12 paths for a 10-domain appliance, not 30), Discovery walkthrough including the "Directories to search" knob and the FlowLogger source=user|default line, Inventory + Management branching summary, optional store properties description, and a migration note for deployments that already approved \pubcert / \sharedcert as orphan stores. Removed the Requirements section (doctool generates it) and the test-case tables (development checklist, not customer-facing). - docsource/datapower.md: rebuilt as the per-store-type doc that doctool injects inside the store's
    block. Purpose header (with the cert / pubcert / sharedcert business-mapping table), prereqs specific to this store type (REST mgmt CLI snippet, curl-based access probe), operational notes (FlowLogger summary entries to watch for), and a Common Errors table covering the 403 on non-default sharedcert, the silent-failure surface, and the "unreadable certificate file" parser rejection. No Store Type Settings / Custom Fields tables - doctool generates those from Properties[]. - docsource/fortiweb.md: deleted. Wrong vendor; leftover. The standalone docs/discovery-overview.{md,html,pdf} are kept as internal feature-spec reference - they're not part of the doctool pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- docsource/content.md | 151 +++++++++++++++++----------------------- docsource/datapower.md | 58 +++++++++++++++- readme_source.md | 153 +++-------------------------------------- 3 files changed, 128 insertions(+), 234 deletions(-) diff --git a/docsource/content.md b/docsource/content.md index 6f8f212..8aac43e 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -1,126 +1,101 @@ ## Overview -The IBM DataPower Orchestrator allows for the management of certificates in the IBM DataPower platform. Discovery, Inventory, Add and Remove functions are supported. This integration can manage certificates in any domain and certificate store directory on a DataPower appliance. +The IBM DataPower Universal Orchestrator manages certificates on IBM DataPower appliances. It targets the appliance's REST Management Interface (typically port `5554`) and uses the same store-path model across every job type: `\`. -* DataPower - -## Requirements - -The IBM DataPower Orchestrator requires: -- A DataPower appliance with the REST Management Interface enabled (typically port 5554) -- API credentials with access to certificate management operations -- HTTPS connectivity between the Keyfactor Orchestrator and the DataPower appliance +```mermaid +flowchart LR + A[Keyfactor Command] -->|Discovery / Inventory / Add / Remove| B[Orchestrator] + B -->|HTTPS REST| C[DataPower REST Mgmt] + C -->|domains, filestore, CryptoCertificate, CryptoKey| D[(DataPower Appliance)] +``` ## Store Path Format -The Store Path identifies which domain and certificate store directory to target on the DataPower appliance. All Inventory, Management (Add/Remove), and Discovery operations use this format. - -### Format +Every Inventory, Management (Add / Remove), and Discovery operation uses the same path shape: ``` \ ``` -The path is composed of two parts separated by a backslash (`\`) or forward slash (`/`): - | Part | Description | Examples | |------|-------------|----------| -| **Domain** | The DataPower application domain. Every DataPower appliance has at least a `default` domain. Additional domains are created for environment or application isolation. | `default`, `production-api`, `staging`, `internal-services` | -| **Directory** | The certificate store directory within that domain. DataPower has several standard directories for certificate storage. | `cert`, `pubcert`, `sharedcert` | +| **Domain** | A DataPower application domain. Every appliance has at least `default`; additional domains are created for environment / application isolation. | `default`, `production-api`, `staging` | +| **Directory** | The certificate store directory within that domain. | `cert`, `pubcert`, `sharedcert` | -### Certificate Store Directories +### Per-Domain vs Appliance-Wide -| Directory | Scope | Contents | -|-----------|-------|----------| -| `cert` | Per-domain | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | -| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | -| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | +Two of the three directories are **appliance-wide**: every domain can read them, but they are physically a single store owned by the `default` domain. Mutations (Add / Remove) through any non-default domain context are rejected by DataPower with `HTTP 403 Forbidden`. Discovery and the orchestrator's Add path enforce this: -### Examples +| Directory | Scope | Discovery emits as | Contents | +|-----------|-------|--------------------|----------| +| `cert` | Per-domain | `\cert` (one per domain) | Domain-specific certificates and private keys, exposed as `CryptoCertificate` / `CryptoKey` configuration objects in that domain | +| `pubcert` | Appliance-wide | `default\pubcert` (once per appliance) | Public / trusted CA certificates the appliance uses to verify other parties | +| `sharedcert` | Appliance-wide | `default\sharedcert` (once per appliance) | Shared identity certs used by appliance-level services or every domain (e.g. the management-interface TLS cert, an enterprise-wide signing cert) | -| Store Path | Description | -|------------|-------------| -| `default\pubcert` | Public certificate store in the default domain | -| `default\cert` | Private key certificate store in the default domain | -| `production-api\cert` | Private key certificates in the production-api domain | -| `testdomain\pubcert` | Public certificates in the testdomain domain | +So a 10-domain appliance produces **12** discovered store paths (10 × `\cert` plus `default\pubcert` and `default\sharedcert`), not 30. -> **Tip:** The Discovery job can automatically find all valid domain and directory combinations on an appliance, eliminating the need to manually determine store paths. See [Discovery](#discovery) below. +> **Add / Remove against `\pubcert` or `\sharedcert`** is rejected by the orchestrator before the call leaves with `"You can only add to on the default domain"`. This matches DataPower's actual permission model and keeps operators from chasing silent 403s. ## Discovery -The Discovery job automatically enumerates all domains and certificate store directories on a DataPower appliance. This is especially useful for environments with many domains, as it eliminates the need to manually create certificate store definitions. +Discovery enumerates all domains on the appliance, lists each domain's filestore, and emits a store path for every certificate-relevant directory. ### How It Works -1. **Enumerate domains** — calls `GET /mgmt/domains/config/` to list every application domain on the appliance -2. **Discover stores per domain** — for each domain, calls `GET /mgmt/filestore/{domain}` to list the filestore directories -3. **Filter to certificate directories** — keeps only certificate-relevant directories (`cert`, `pubcert`, `sharedcert`) -4. **Return store paths** — submits the discovered paths (e.g., `production-api\cert`) to Keyfactor Command +1. **Enumerate domains** — `GET /mgmt/domains/config/` returns every application domain on the appliance. +2. **Resolve directory filter** — the comma-separated **Directories to search** field on the Discovery job is parsed; if blank, the orchestrator falls back to `cert,pubcert,sharedcert`. Trailing colons (`cert:`) are stripped before matching. +3. **List directories per domain** — `GET /mgmt/filestore/{domain}` returns every filestore *location*. The trailing-colon names returned by DataPower are matched against the resolved filter. +4. **Emit store paths** — `\cert` for every domain that has a `cert` directory; `default\pubcert` and `default\sharedcert` once each (other domains' views of those are skipped because they alias the same physical data). +5. **Submit to Command** — the discovered paths are sent back via `SubmitDiscoveryUpdate` for operator approval. + +The orchestrator is resilient to one inaccessible domain: it logs a warning and continues with the rest. ### Configuration -Discovery requires only the appliance connection details — no store path is needed: +Discovery only needs the appliance connection details — no store path is required: | Field | Description | |-------|-------------| -| Client Machine | The DataPower appliance hostname/IP and REST API port (e.g., `datapower.example.com:5554`) | -| Server Username | API username for DataPower (PAM eligible) | -| Server Password | API password for DataPower (PAM eligible) | +| **Client Machine** | DataPower appliance hostname/IP and REST mgmt port (e.g. `datapower.example.com:5554`) | +| **Server Username** | API username for DataPower (PAM-eligible) | +| **Server Password** | API password (PAM-eligible) | +| **Directories to search** | Comma-separated list of directory names to filter against (e.g. `cert,pubcert,sharedcert`). Leave blank to use the standard set. Custom DataPower scheme names can be included. | -### Example +The FlowLogger summary on the job's result records which filter list was applied: + +``` +[OK] ResolveDirsToSearch - source=user (key=dirs), dirs=[cert,sharedcert] +``` -Running Discovery against an appliance with 3 domains returns paths like: +vs ``` -default\cert -default\pubcert -production-api\cert -production-api\pubcert -staging\cert -staging\pubcert +[OK] ResolveDirsToSearch - source=default, dirs=[cert,pubcert,sharedcert] ``` -Each discovered path can become a certificate store definition in Keyfactor Command, ready for Inventory and Management operations. - -## Test Cases - -*** - -#### INVENTORY TEST CASES -Case Number|Case Name|Case Description|Expected Results|Passed -------------|---------|----------------|--------------|---------- -1|Pubcert Inventory No Black List Default Domain|Should Inventory Everything in the DataPower pubcert directory on the Default Domain|Keyfactor Inventory Matches pubcert default domain inventory|True -1a|Pubcert Inventory No Black List Default Domain using PAM Credentials|Should Inventory Everything in the DataPower pubcert directory on the Default Domain using credentials stored in a PAM Provider|Keyfactor Inventory Matches pubcert default domain inventory|True -1b|Pubcert Inventory With Black List Default Domain|Should Inventory Everything in the DataPower pubcert directory on the Default Domain Outside of Black List Items ex: Test.pem,Test2.pem|Keyfactor Inventory Matches pubcert default domain inventory outside of Black List Items|True -2|Pubcert Inventory No Black List *testdomain\pubcert* path|Should Inventory Everything in the DataPower pubcert directory on the *testdomain\pubcert* path|Keyfactor Inventory Matches pubcert default domain inventory|True -2a|Pubcert Inventory With Black List *testdomain\pubcert* path|Should Inventory Everything in the DataPower pubcert directory on the *testdomain\pubcert* path Outside of Black List Items ex: Cert1.pem,Cert2.pem|Keyfactor Inventory Matches pubcert default domain inventory outside of Black List Items|True -3|Private Key Cert Inventory No Black List Default Domain|Should Inventory Everything in the DataPower cert directory on the Default Domain|Keyfactor Inventory Matches pubcert default domain inventory|True -3a|Private Key Cert Inventory No Black List Default Domain with Credentials Stored in PAM Provider|Should Inventory Everything in the DataPower cert directory on the Default Domain with Credentials Stored in PAM Provider|Keyfactor Inventory Matches pubcert default domain inventory|True -3b|Private Key Cert Inventory With Black List Default Domain|Should Inventory Everything in the DataPower cert directory on the Default Domain Oustide of Black List Items ex: Test.pem,Test2.pem|Keyfactor Inventory Matches cert default domain inventory outside of Black List Items|True -4|Private Key Cert Inventory No Black List *testdomain\cert* path|Should Inventory Everything in the DataPower cert directory on the *testdomain\cert* path|Keyfactor Inventory Matches *testdomain\cert* path| inventory|True -4a|Private Key Cert Inventory With Black List *testdomain\cert* path||Should Inventory Everything in the DataPower cert directory on the *testdomain\cert* path|Keyfactor Inventory Matches *testdomain\cert* path Oustide of Black List Items ex: Test,Test2|Keyfactor Inventory Matches everything in *testdomain\cert* path outside of Black List Items - -*** - -#### ADD/REMOVE TEST CASES -Case Number|Case Name|Case Description|Overwrite Flag|Alias Name|Expected Results|Passed -------------|---------|----------------|--------------|----------|----------------|-------------- -1|Pubcert Add with Alias Default Domain|Will create new Cert, Key and Pem/crt entry|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created|True -1a|Pubcert Overwrite with Alias Default Domain|Will Replaced Cert, Key and Pem/crt entry|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced|True -1b|Pubcert Add without Alias Default Domain|Will create new Cert, Key and Pem/crt entry with GUID as name|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name|True -2|Private Key Add with Alias Default Domain|Will create new Cert, Key and Pem/crt entry|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created|True -2a|Private Key Overwrite with Alias Default Domain|Will Replaced Cert, Key and Pem/crt entry|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced|True -2b|Private Key Add without Alias Default Domain|Will create new Cert, Key and Pem/crt entry with GUID as name|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name|True -2c|Private Key Cert Add with Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry in *testdomain\cert* path|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created in *testdomain\pubcert* path|True -2d|Private Key Cert Add with Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry in *testdomain\cert* path with PAM Credentials|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created in *testdomain\pubcert* path gettting credentials from a PAM Provider|True -3a|Private Key Cert Overwrite with Alias *testdomain\cert* path|Will Replaced Cert, Key and Pem/crt entry in *testdomain\cert* path|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced in *testdomain\pubcert* path|True -3b|Private Key Cert Add without Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry with GUID as name in *testdomain\cert* path|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name in *testdomain\cert* path|True -4|Remove Private Key and Cert From Default Domain|Remove Private Key and Cert From Default Domain|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power|True -4a|Remove Private Key and Cert From *testdomain\cert* path|Remove Private Key and Cert From *testdomain\cert* path|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power *testdomain\cert* path|True -4b|Remove PubCert|Remove PubCert|False|cryptoobjs|Error Occurs, cannot remove Public Certs|True -4c|Remove Private Key and Cert From *testdomain\cert* path with PAM Credentials|Remove Private Key and Cert From *testdomain\cert* path using credentials stored in a PAM Provider|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power *testdomain\cert* path|True - -*** +## Inventory and Management + +Inventory and Add / Remove jobs target a specific store path. The orchestrator branches on the directory: + +- `\cert` and `default\sharedcert` → reads `CryptoCertificate` config objects from `/mgmt/config/{domain}/CryptoCertificate`, filters to those whose `Filename` URI scheme matches the store (so a `default\sharedcert` job ignores the `cert:///` and `pubcert:///` entries that share the domain), and submits the certs. +- `default\pubcert` → reads files directly from the `pubcert:` filestore. + +Every job emits a `[FLOW:...]` breadcrumb summary that is appended to the `JobResult.FailureMessage` regardless of success or failure. The summary lists every step (Validate, ParseConfig, CreateApiClient, GetCerts.ParseResponse, GetCerts.SubmitInventory, ...) with timing and any error reason. Operators can read it directly from the job-history pane in Command without enabling Trace logging. + +### Optional Store Properties + +| Property | Description | +|----------|-------------| +| **Inventory Black List** | Comma-separated alias names to exclude from Inventory results (e.g. `system-cert,internal-test`). Case-insensitive. Empty by default. | +| **Inventory Page Size** | Maximum number of certs returned per Inventory submission. Defaults to `100`. | +| **Public Cert Store Name** | Name of the appliance's public-cert directory (default `pubcert`). Override only if the appliance has been re-configured. | +| **Protocol** | `https` (default) or `http`. Use `http` for lab appliances without the REST mgmt TLS profile configured. | + +## Migration Note + +Earlier releases of this orchestrator emitted `\pubcert` and `\sharedcert` from Discovery — N copies all aliasing the same physical store. If your environment has previously approved any of those non-default entries as cert stores in Command, they are now orphans: +- Inventory against them returns nothing (the underlying objects all point at `:///` paths owned by `default`). +- Add and Remove are rejected by the orchestrator with a clear message. +Re-run Discovery, approve the canonical `default\pubcert` and `default\sharedcert`, and remove the duplicates from your Command instance. diff --git a/docsource/datapower.md b/docsource/datapower.md index 1253823..bfdf7c5 100644 --- a/docsource/datapower.md +++ b/docsource/datapower.md @@ -1,6 +1,58 @@ -## Keyfactor Orchestrator Integration: DataPower Setup +#### 🔐 Purpose -### Overview +This store type manages certificates on IBM DataPower appliances. A single store type covers all three DataPower certificate-storage models — per-domain `cert:`, appliance-wide `pubcert:`, and appliance-wide `sharedcert:` — by branching on the store path's directory at runtime. -The IBM DataPower Orchestrator supports Discovery, Inventory, Add, and Remove operations for certificates on DataPower appliances. For details on how store paths work across all operations, see the [Store Path Format](content.md#store-path-format) section in the main content documentation. +| Path shape | What it manages | Typical use | +|------------|-----------------|-------------| +| `\cert` | A specific domain's `CryptoCertificate` / `CryptoKey` configuration objects, plus the underlying PEM/key files in that domain's `cert:` filestore. | Per-application TLS identity. Each business service / tenant / domain has its own certs, isolated from other domains. | +| `default\pubcert` | Public certificates in the appliance's `pubcert:` filestore. | Trust anchors — CA roots and partner public certs the appliance uses to verify outbound TLS, signed assertions, and similar. | +| `default\sharedcert` | Appliance-wide certificates in `sharedcert:`, exposed through the `default` domain's `CryptoCertificate` objects. | Identity certs that survive firmware refreshes — the management-interface TLS cert, signing certs every domain reuses, etc. | +`pubcert` and `sharedcert` are physically a single store on the appliance, owned by `default`. Other domains can read them through their filestore view, but DataPower rejects writes through any non-default domain context with `HTTP 403`. The orchestrator enforces this up front: Add / Remove against `\pubcert` or `\sharedcert` is rejected with a clear failure message. + +#### Prerequisites Specific to This Store Type + +Before approving a discovered store or creating one manually: + +1. **REST Management Interface enabled** on the target appliance. From the DataPower CLI: + ``` + web-mgmt + admin-state enabled + port 9090 + exit + xml-mgmt + admin-state enabled + port 5554 + exit + ``` + The port `5554` is what goes into Client Machine. +2. **API user with Crypto Configuration access** in the target domain(s). Read-only is sufficient for Discovery and Inventory; Add and Remove require write on `CryptoCertificate`, `CryptoKey`, and the relevant filestore directories. +3. **Outbound network reachability** from the orchestrator host to the appliance over HTTPS on the REST mgmt port. + +Verify access from the orchestrator host with a quick probe (replace credentials as appropriate; `-k` skips cert verification for lab appliances with self-signed mgmt certs): + +```bash +curl -k -u admin:PASSWORD https://datapower.example.com:5554/mgmt/domains/config/ +``` + +If this returns a JSON list of domains, the orchestrator will work from this host with these credentials. + +#### Operational Notes + +- **Discovery vs manual creation.** Discovery emits exactly the store paths the orchestrator can manage: `\cert` per domain plus `default\pubcert` and `default\sharedcert` once each. Approving discovered paths is preferred over manual entry — it sidesteps typo-driven mismatches and the orphan stores left over from older Discovery emit shapes. +- **Inventory pagination.** The default page size of `100` is appropriate for typical DataPower appliances. Increase only if a single domain exceeds 100 `CryptoCertificate` objects and you want them in a single Inventory pass. +- **Black-list filtering.** `Inventory Black List` accepts comma-separated alias names (e.g. `system-cert,internal-test`). Matching is case-insensitive. Use this to keep system-managed certs out of Command's inventory. +- **FlowLogger summary.** Every job result — success or failure — has a multi-line `Flow: -ProcessJob` breadcrumb appended to its `FailureMessage`. The summary lists every step with timing and error reason, visible in Command's job-history pane without needing Trace logging on the agent. Useful entries to look for: + - `GetCerts.ParseResponse - certCount=N (filtered from M by scheme ':')` confirms the response was received and how many entries survived the URI-scheme filter. + - `GetCerts.SubmitInventory - itemCount=N` shows how many made it through per-cert detail fetch. + - `ResolveDirsToSearch - source=user|default, dirs=[...]` (Discovery only) confirms which "Directories to search" list was applied. + +#### Common Errors + +| Symptom | Most likely cause | +|---------|-------------------| +| `HTTP 403 Forbidden` on Add to `\sharedcert` or `\pubcert` | Target store is appliance-wide. Use `default\pubcert` / `default\sharedcert` instead. The orchestrator now rejects this up front with a clearer message. | +| Inventory returns 0 items but the appliance has certs | The store path may reference a directory whose URI scheme doesn't match the directory name (rare). Check `Get Certs Response` in the orchestrator log to see what `Filename` values the appliance returned. | +| `The specified certificate has an unreadable, corrupt, or invalid certificate file` on Inventory's per-cert detail fetch | DataPower's parser rejected the cert file. Common cause is a self-signed cert lacking standard X.509 extensions (BasicConstraints, KeyUsage). The `test/generate-test-certs.ps1` script in the repo generates lab certs with the right extensions for testing. | +| `401 Unauthorized` | API credentials are wrong, or the REST mgmt user lacks access to the target domain. | +| `404 Not Found` on `/mgmt/domains/config/` | REST mgmt interface is not enabled on the appliance, or the orchestrator is pointing at the wrong port. | diff --git a/readme_source.md b/readme_source.md index 631236f..f0807a1 100644 --- a/readme_source.md +++ b/readme_source.md @@ -1,150 +1,17 @@ -**IBM DataPower** +## Overview -**Overview** +The IBM DataPower Universal Orchestrator manages certificates on IBM DataPower appliances over the REST Management Interface. It supports Discovery, Inventory, Add, and Remove for both per-domain (`cert:`) and appliance-wide (`pubcert:`, `sharedcert:`) certificate stores. -The IBM DataPower Orchestrator allows for the management of certificates in the IBM DataPower platform. Discovery, Inventory, Add and Remove functions are supported. This integration can manage certificates in any domain and certificate store directory on a DataPower appliance. +## Vendor Configuration -For details on how store paths work, see the [Store Path Format](#store-path-format) section below. +Before installing the orchestrator extension: ---- +1. Enable the **REST Management Interface** on the DataPower appliance (typically port `5554`). The orchestrator does not support the legacy XML-Mgmt SOAP interface. +2. Provision a DataPower user with REST mgmt access and the **Crypto Configuration** privileges needed to read and create `CryptoCertificate` / `CryptoKey` configuration objects in every target domain. Read-only is sufficient for Discovery and Inventory; Add and Remove require write. +3. The orchestrator host must reach the appliance over HTTPS on the REST mgmt port. Self-signed appliance certs are accepted (lab use); pin a real cert for production. -**Store Path Format** +See the [DataPower Knowledge Center](https://www.ibm.com/docs/en/datapower-gateway) for instructions on enabling the REST mgmt interface and managing roles. -The Store Path identifies which domain and certificate store directory to target on the DataPower appliance. The format is: - -``` -\ -``` - -| Part | Description | Examples | -|------|-------------|----------| -| **Domain** | The DataPower application domain | `default`, `production-api`, `staging` | -| **Directory** | The certificate store directory | `cert`, `pubcert`, `sharedcert` | - -**Certificate Store Directories:** - -| Directory | Scope | Contents | -|-----------|-------|----------| -| `cert` | Per-domain | Domain-specific certificates and private keys | -| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | -| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | - -**Examples:** `default\pubcert`, `production-api\cert`, `testdomain\pubcert` - -> **Tip:** Use the Discovery job to automatically find all valid store paths on an appliance. - ---- - -**Discovery** - -The Discovery job automatically enumerates all domains and certificate store directories on a DataPower appliance. This eliminates the need to manually create certificate store definitions, especially useful for environments with many domains. - -**How it works:** -1. Calls `GET /mgmt/domains/config/` to list all application domains -2. For each domain, calls `GET /mgmt/filestore/{domain}` to find certificate directories -3. Filters for `cert`, `pubcert`, and `sharedcert` directories -4. Returns store paths (e.g., `production-api\cert`) to Keyfactor Command - -**Discovery Configuration:** - -CONFIG ELEMENT|DESCRIPTION ---------------|----------- -Client Machine|The DataPower appliance hostname/IP and REST API port (e.g., `datapower.example.com:5554`) -Server Username|API username for DataPower (PAM eligible) -Server Password|API password for DataPower (PAM eligible) - ---- - -**1) Create the new Certificate store Type for the New DataPower AnyAgent** - -#### STORE TYPE CONFIGURATION -SETTING TAB | CONFIG ELEMENT | DESCRIPTION -------|-----------|------------------ -Basic |Name |Descriptive name for the Store Type. IBM Data Power Universal can be used. -Basic |Short Name |The short name that identifies the registered functionality of the orchestrator. Must be DataPower. -Basic |Custom Capability|Unchecked -Basic |Job Types |Discovery, Inventory, Add, and Remove are the supported job types. -Basic |Needs Server |Must be checked -Basic |Blueprint Allowed |checked -Basic |Requires Store Password |Determines if a store password is required when configuring an individual store. This must be unchecked. -Basic |Supports Entry Password |Determined if an individual entry within a store can have a password. This must be unchecked. -Advanced |Store Path Type| Determines how the user will enter the store path when setting up the cert store. Freeform -Advanced |Supports Custom Alias |Determines if an individual entry within a store can have a custom Alias. Optional (if left blank, alias will be a GUID) -Advanced |Private Key Handling |Determines how the orchestrator deals with private keys. Optional -Advanced |PFX Password Style |Determines password style for the PFX Password. Default -Custom Fields|Inventory Page Size|Name:InventoryPageSize Display Name:Inventory Page Size Type:String Default Value:100 Required:True. This determines the page size during the inventory calls. (100 should be fine) -Custom Fields|Public Cert Store Name|Name:PublicCertStoreName Display Name:Public Cert Store Name:String Default Value:pubcert Required:True. This probably will remain pubcert unless someone changed the default name in DataPower. -Custom Fields|Protocol|Name:Protocol Display Name:Protocol Name:String Default Value:https Required:True. This should always be https in production, may need to change in test to http. -Custom Fields|Inventory Black List|Name:InventoryBlackList Display Name:Inventory Black List Name:String Default Value:Leave Blank Required:False. Comma seperated list of alias values you do not want to inventory from DataPower. -Custom Fields|Server Username|Api UserName for DataPower -Custom Fields|Server Password|Api Password for UserName Described Above -Custom Fields|Use SSL|Set this to true -Entry Parameters|N/A| There are no Entry Parameters - -![image.png](/images/CertStoreType-Basic.gif) - -![image.png](/images/CertStoreType-Advanced.gif) - -![image.png](/images/CertStoreType-CustomFields.gif) - - -#### STORE CONFIGURATION -CONFIG ELEMENT |DESCRIPTION -----------------|--------------- -Category |The type of certificate store to be configured. Select category based on the display name configured above "IBM Data Power Universal". -Container |This is a logical grouping of like stores. This configuration is optional and does not impact the functionality of the store. -Client Machine | The server and port the DataPower API runs on. This is typically port 5554 for the API. -Store Path |The `domain\directory` store path targeting a specific domain and certificate store. See [Store Path Format](#store-path-format) above. Examples: `default\pubcert`, `production-api\cert`. The Discovery job can find these automatically. -Inventory Page Size|This determines the page size during the inventory calls. (100 should be fine). -Public Cert Store Name| This probably will remain pubcert unless someone changed the default name in DataPower. -Protocol| This should always be https in production, may need to change in test to http. -Inventory Black List| Comma seperated list of alias values you do not want to inventory from DataPower. -Orchestrator |This is the orchestrator server registered with the appropriate capabilities to manage this certificate store type. -Inventory Schedule |The interval that the system will use to report on what certificates are currently in the store. -Use SSL |This should be checked. -User |The Data Power user that has access to the API and enroll and inventory functions in DataPower. -Password |Password for the user mentioned above. - -![image.png](/images/CertStore.gif) - -*** - -#### INVENTORY TEST CASES -Case Number|Case Name|Case Description|Expected Results|Passed -------------|---------|----------------|--------------|---------- -1|Pubcert Inventory No Black List Default Domain|Should Inventory Everything in the DataPower pubcert directory on the Default Domain|Keyfactor Inventory Matches pubcert default domain inventory|True -1a|Pubcert Inventory No Black List Default Domain using PAM Credentials|Should Inventory Everything in the DataPower pubcert directory on the Default Domain using credentials stored in a PAM Provider|Keyfactor Inventory Matches pubcert default domain inventory|True -1b|Pubcert Inventory With Black List Default Domain|Should Inventory Everything in the DataPower pubcert directory on the Default Domain Outside of Black List Items ex: Test.pem,Test2.pem|Keyfactor Inventory Matches pubcert default domain inventory outside of Black List Items|True -2|Pubcert Inventory No Black List *testdomain\pubcert* path|Should Inventory Everything in the DataPower pubcert directory on the *testdomain\pubcert* path|Keyfactor Inventory Matches pubcert default domain inventory|True -2a|Pubcert Inventory With Black List *testdomain\pubcert* path|Should Inventory Everything in the DataPower pubcert directory on the *testdomain\pubcert* path Outside of Black List Items ex: Cert1.pem,Cert2.pem|Keyfactor Inventory Matches pubcert default domain inventory outside of Black List Items|True -3|Private Key Cert Inventory No Black List Default Domain|Should Inventory Everything in the DataPower cert directory on the Default Domain|Keyfactor Inventory Matches pubcert default domain inventory|True -3a|Private Key Cert Inventory No Black List Default Domain with Credentials Stored in PAM Provider|Should Inventory Everything in the DataPower cert directory on the Default Domain with Credentials Stored in PAM Provider|Keyfactor Inventory Matches pubcert default domain inventory|True -3b|Private Key Cert Inventory With Black List Default Domain|Should Inventory Everything in the DataPower cert directory on the Default Domain Oustide of Black List Items ex: Test.pem,Test2.pem|Keyfactor Inventory Matches cert default domain inventory outside of Black List Items|True -4|Private Key Cert Inventory No Black List *testdomain\cert* path|Should Inventory Everything in the DataPower cert directory on the *testdomain\cert* path|Keyfactor Inventory Matches *testdomain\cert* path| inventory|True -4a|Private Key Cert Inventory With Black List *testdomain\cert* path||Should Inventory Everything in the DataPower cert directory on the *testdomain\cert* path|Keyfactor Inventory Matches *testdomain\cert* path Oustide of Black List Items ex: Test,Test2|Keyfactor Inventory Matches everything in *testdomain\cert* path outside of Black List Items - -*** - -#### ADD/REMOVE TEST CASES -Case Number|Case Name|Case Description|Overwrite Flag|Alias Name|Expected Results|Passed -------------|---------|----------------|--------------|----------|----------------|-------------- -1|Pubcert Add with Alias Default Domain|Will create new Cert, Key and Pem/crt entry|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created|True -1a|Pubcert Overwrite with Alias Default Domain|Will Replaced Cert, Key and Pem/crt entry|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced|True -1b|Pubcert Add without Alias Default Domain|Will create new Cert, Key and Pem/crt entry with GUID as name|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name|True -2|Private Key Add with Alias Default Domain|Will create new Cert, Key and Pem/crt entry|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created|True -2a|Private Key Overwrite with Alias Default Domain|Will Replaced Cert, Key and Pem/crt entry|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced|True -2b|Private Key Add without Alias Default Domain|Will create new Cert, Key and Pem/crt entry with GUID as name|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name|True -2c|Private Key Cert Add with Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry in *testdomain\cert* path|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created in *testdomain\pubcert* path|True -2d|Private Key Cert Add with Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry in *testdomain\cert* path with PAM Credentials|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created in *testdomain\pubcert* path gettting credentials from a PAM Provider|True -3a|Private Key Cert Overwrite with Alias *testdomain\cert* path|Will Replaced Cert, Key and Pem/crt entry in *testdomain\cert* path|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced in *testdomain\pubcert* path|True -3b|Private Key Cert Add without Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry with GUID as name in *testdomain\cert* path|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name in *testdomain\cert* path|True -4|Remove Private Key and Cert From Default Domain|Remove Private Key and Cert From Default Domain|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power|True -4a|Remove Private Key and Cert From *testdomain\cert* path|Remove Private Key and Cert From *testdomain\cert* path|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power *testdomain\cert* path|True -4b|Remove PubCert|Remove PubCert|False|cryptoobjs|Error Occurs, cannot remove Public Certs|True -4c|Remove Private Key and Cert From *testdomain\cert* path with PAM Credentials|Remove Private Key and Cert From *testdomain\cert* path using credentials stored in a PAM Provider|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power *testdomain\cert* path|True - -*** - -### License -[Apache](https://apache.org/licenses/LICENSE-2.0) +## License +[Apache 2.0](https://apache.org/licenses/LICENSE-2.0) From ecdc246c34ece95acfd7942bba4583828256180f Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Fri, 1 May 2026 21:03:13 +0000 Subject: [PATCH 20/21] Update generated docs --- README.md | 165 ++++++++++++++++++----------------------- docsource/datapower.md | 5 ++ 2 files changed, 77 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 057bd7e..c0b47fd 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,14 @@ ## Overview -The IBM DataPower Orchestrator allows for the management of certificates in the IBM DataPower platform. Discovery, Inventory, Add and Remove functions are supported. This integration can manage certificates in any domain and certificate store directory on a DataPower appliance. +The IBM DataPower Universal Orchestrator manages certificates on IBM DataPower appliances. It targets the appliance's REST Management Interface (typically port `5554`) and uses the same store-path model across every job type: `\`. -* DataPower +```mermaid +flowchart LR + A[Keyfactor Command] -->|Discovery / Inventory / Add / Remove| B[Orchestrator] + B -->|HTTPS REST| C[DataPower REST Mgmt] + C -->|domains, filestore, CryptoCertificate, CryptoKey| D[(DataPower Appliance)] +``` @@ -51,11 +56,6 @@ The DataPower Universal Orchestrator extension is supported by Keyfactor. If you Before installing the DataPower Universal Orchestrator extension, we recommend that you install [kfutil](https://github.com/Keyfactor/kfutil). Kfutil is a command-line tool that simplifies the process of creating store types, installing extensions, and instantiating certificate stores in Keyfactor Command. -The IBM DataPower Orchestrator requires: -- A DataPower appliance with the REST Management Interface enabled (typically port 5554) -- API credentials with access to certificate management operations -- HTTPS connectivity between the Keyfactor Orchestrator and the DataPower appliance - ## DataPower Certificate Store Type @@ -63,7 +63,7 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D -The IBM DataPower Orchestrator supports Discovery, Inventory, Add, and Remove operations for certificates on DataPower appliances. For details on how store paths work across all operations, see the [Store Path Format](content.md#store-path-format) section in the main content documentation. +TODO Overview is a required section @@ -364,118 +364,97 @@ Please refer to the **Universal Orchestrator (remote)** usage section ([PAM prov ## Discovering Certificate Stores with the Discovery Job -The Discovery job automatically enumerates all domains and certificate store directories on a DataPower appliance. This is especially useful for environments with many domains, as it eliminates the need to manually create certificate store definitions. +Discovery enumerates all domains on the appliance, lists each domain's filestore, and emits a store path for every certificate-relevant directory. ### How It Works -1. **Enumerate domains** — calls `GET /mgmt/domains/config/` to list every application domain on the appliance -2. **Discover stores per domain** — for each domain, calls `GET /mgmt/filestore/{domain}` to list the filestore directories -3. **Filter to certificate directories** — keeps only certificate-relevant directories (`cert`, `pubcert`, `sharedcert`) -4. **Return store paths** — submits the discovered paths (e.g., `production-api\cert`) to Keyfactor Command +1. **Enumerate domains** — `GET /mgmt/domains/config/` returns every application domain on the appliance. +2. **Resolve directory filter** — the comma-separated **Directories to search** field on the Discovery job is parsed; if blank, the orchestrator falls back to `cert,pubcert,sharedcert`. Trailing colons (`cert:`) are stripped before matching. +3. **List directories per domain** — `GET /mgmt/filestore/{domain}` returns every filestore *location*. The trailing-colon names returned by DataPower are matched against the resolved filter. +4. **Emit store paths** — `\cert` for every domain that has a `cert` directory; `default\pubcert` and `default\sharedcert` once each (other domains' views of those are skipped because they alias the same physical data). +5. **Submit to Command** — the discovered paths are sent back via `SubmitDiscoveryUpdate` for operator approval. + +The orchestrator is resilient to one inaccessible domain: it logs a warning and continues with the rest. ### Configuration -Discovery requires only the appliance connection details — no store path is needed: +Discovery only needs the appliance connection details — no store path is required: | Field | Description | |-------|-------------| -| Client Machine | The DataPower appliance hostname/IP and REST API port (e.g., `datapower.example.com:5554`) | -| Server Username | API username for DataPower (PAM eligible) | -| Server Password | API password for DataPower (PAM eligible) | +| **Client Machine** | DataPower appliance hostname/IP and REST mgmt port (e.g. `datapower.example.com:5554`) | +| **Server Username** | API username for DataPower (PAM-eligible) | +| **Server Password** | API password (PAM-eligible) | +| **Directories to search** | Comma-separated list of directory names to filter against (e.g. `cert,pubcert,sharedcert`). Leave blank to use the standard set. Custom DataPower scheme names can be included. | -### Example - -Running Discovery against an appliance with 3 domains returns paths like: +The FlowLogger summary on the job's result records which filter list was applied: ``` -default\cert -default\pubcert -production-api\cert -production-api\pubcert -staging\cert -staging\pubcert +[OK] ResolveDirsToSearch - source=user (key=dirs), dirs=[cert,sharedcert] ``` -Each discovered path can become a certificate store definition in Keyfactor Command, ready for Inventory and Management operations. +vs + +``` +[OK] ResolveDirsToSearch - source=default, dirs=[cert,pubcert,sharedcert] +``` ## Store Path Format -The Store Path identifies which domain and certificate store directory to target on the DataPower appliance. All Inventory, Management (Add/Remove), and Discovery operations use this format. - -### Format +Every Inventory, Management (Add / Remove), and Discovery operation uses the same path shape: ``` \ ``` -The path is composed of two parts separated by a backslash (`\`) or forward slash (`/`): - | Part | Description | Examples | |------|-------------|----------| -| **Domain** | The DataPower application domain. Every DataPower appliance has at least a `default` domain. Additional domains are created for environment or application isolation. | `default`, `production-api`, `staging`, `internal-services` | -| **Directory** | The certificate store directory within that domain. DataPower has several standard directories for certificate storage. | `cert`, `pubcert`, `sharedcert` | - -### Certificate Store Directories - -| Directory | Scope | Contents | -|-----------|-------|----------| -| `cert` | Per-domain | Domain-specific certificates and private keys (CryptoCertificate/CryptoKey objects) | -| `pubcert` | Appliance-wide | Public/trusted certificates shared across all domains | -| `sharedcert` | Appliance-wide | Shared certificates that persist across firmware upgrades | - -### Examples - -| Store Path | Description | -|------------|-------------| -| `default\pubcert` | Public certificate store in the default domain | -| `default\cert` | Private key certificate store in the default domain | -| `production-api\cert` | Private key certificates in the production-api domain | -| `testdomain\pubcert` | Public certificates in the testdomain domain | - -> **Tip:** The Discovery job can automatically find all valid domain and directory combinations on an appliance, eliminating the need to manually determine store paths. See [Discovery](#discovery) below. - -## Test Cases - -*** - -#### INVENTORY TEST CASES -Case Number|Case Name|Case Description|Expected Results|Passed -------------|---------|----------------|--------------|---------- -1|Pubcert Inventory No Black List Default Domain|Should Inventory Everything in the DataPower pubcert directory on the Default Domain|Keyfactor Inventory Matches pubcert default domain inventory|True -1a|Pubcert Inventory No Black List Default Domain using PAM Credentials|Should Inventory Everything in the DataPower pubcert directory on the Default Domain using credentials stored in a PAM Provider|Keyfactor Inventory Matches pubcert default domain inventory|True -1b|Pubcert Inventory With Black List Default Domain|Should Inventory Everything in the DataPower pubcert directory on the Default Domain Outside of Black List Items ex: Test.pem,Test2.pem|Keyfactor Inventory Matches pubcert default domain inventory outside of Black List Items|True -2|Pubcert Inventory No Black List *testdomain\pubcert* path|Should Inventory Everything in the DataPower pubcert directory on the *testdomain\pubcert* path|Keyfactor Inventory Matches pubcert default domain inventory|True -2a|Pubcert Inventory With Black List *testdomain\pubcert* path|Should Inventory Everything in the DataPower pubcert directory on the *testdomain\pubcert* path Outside of Black List Items ex: Cert1.pem,Cert2.pem|Keyfactor Inventory Matches pubcert default domain inventory outside of Black List Items|True -3|Private Key Cert Inventory No Black List Default Domain|Should Inventory Everything in the DataPower cert directory on the Default Domain|Keyfactor Inventory Matches pubcert default domain inventory|True -3a|Private Key Cert Inventory No Black List Default Domain with Credentials Stored in PAM Provider|Should Inventory Everything in the DataPower cert directory on the Default Domain with Credentials Stored in PAM Provider|Keyfactor Inventory Matches pubcert default domain inventory|True -3b|Private Key Cert Inventory With Black List Default Domain|Should Inventory Everything in the DataPower cert directory on the Default Domain Oustide of Black List Items ex: Test.pem,Test2.pem|Keyfactor Inventory Matches cert default domain inventory outside of Black List Items|True -4|Private Key Cert Inventory No Black List *testdomain\cert* path|Should Inventory Everything in the DataPower cert directory on the *testdomain\cert* path|Keyfactor Inventory Matches *testdomain\cert* path| inventory|True -4a|Private Key Cert Inventory With Black List *testdomain\cert* path||Should Inventory Everything in the DataPower cert directory on the *testdomain\cert* path|Keyfactor Inventory Matches *testdomain\cert* path Oustide of Black List Items ex: Test,Test2|Keyfactor Inventory Matches everything in *testdomain\cert* path outside of Black List Items - -*** - -#### ADD/REMOVE TEST CASES -Case Number|Case Name|Case Description|Overwrite Flag|Alias Name|Expected Results|Passed -------------|---------|----------------|--------------|----------|----------------|-------------- -1|Pubcert Add with Alias Default Domain|Will create new Cert, Key and Pem/crt entry|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created|True -1a|Pubcert Overwrite with Alias Default Domain|Will Replaced Cert, Key and Pem/crt entry|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced|True -1b|Pubcert Add without Alias Default Domain|Will create new Cert, Key and Pem/crt entry with GUID as name|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name|True -2|Private Key Add with Alias Default Domain|Will create new Cert, Key and Pem/crt entry|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created|True -2a|Private Key Overwrite with Alias Default Domain|Will Replaced Cert, Key and Pem/crt entry|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced|True -2b|Private Key Add without Alias Default Domain|Will create new Cert, Key and Pem/crt entry with GUID as name|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name|True -2c|Private Key Cert Add with Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry in *testdomain\cert* path|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created in *testdomain\pubcert* path|True -2d|Private Key Cert Add with Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry in *testdomain\cert* path with PAM Credentials|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created in *testdomain\pubcert* path gettting credentials from a PAM Provider|True -3a|Private Key Cert Overwrite with Alias *testdomain\cert* path|Will Replaced Cert, Key and Pem/crt entry in *testdomain\cert* path|true|cryptoobjs|Crypto Key Replaced, Crypto Cert Replaced, Pem/Crt Replaced in *testdomain\pubcert* path|True -3b|Private Key Cert Add without Alias *testdomain\cert* path|Will create new Cert, Key and Pem/crt entry with GUID as name in *testdomain\cert* path|False|cryptoobjs|Crypto Key Created, Crypto Cert Created, Pem/Crt created with GUID as name in *testdomain\cert* path|True -4|Remove Private Key and Cert From Default Domain|Remove Private Key and Cert From Default Domain|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power|True -4a|Remove Private Key and Cert From *testdomain\cert* path|Remove Private Key and Cert From *testdomain\cert* path|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power *testdomain\cert* path|True -4b|Remove PubCert|Remove PubCert|False|cryptoobjs|Error Occurs, cannot remove Public Certs|True -4c|Remove Private Key and Cert From *testdomain\cert* path with PAM Credentials|Remove Private Key and Cert From *testdomain\cert* path using credentials stored in a PAM Provider|False|cryptoobjs|Crypto Certificate, Crypto Key and Pem/Crt are removed from Data Power *testdomain\cert* path|True - -*** +| **Domain** | A DataPower application domain. Every appliance has at least `default`; additional domains are created for environment / application isolation. | `default`, `production-api`, `staging` | +| **Directory** | The certificate store directory within that domain. | `cert`, `pubcert`, `sharedcert` | + +### Per-Domain vs Appliance-Wide + +Two of the three directories are **appliance-wide**: every domain can read them, but they are physically a single store owned by the `default` domain. Mutations (Add / Remove) through any non-default domain context are rejected by DataPower with `HTTP 403 Forbidden`. Discovery and the orchestrator's Add path enforce this: + +| Directory | Scope | Discovery emits as | Contents | +|-----------|-------|--------------------|----------| +| `cert` | Per-domain | `\cert` (one per domain) | Domain-specific certificates and private keys, exposed as `CryptoCertificate` / `CryptoKey` configuration objects in that domain | +| `pubcert` | Appliance-wide | `default\pubcert` (once per appliance) | Public / trusted CA certificates the appliance uses to verify other parties | +| `sharedcert` | Appliance-wide | `default\sharedcert` (once per appliance) | Shared identity certs used by appliance-level services or every domain (e.g. the management-interface TLS cert, an enterprise-wide signing cert) | + +So a 10-domain appliance produces **12** discovered store paths (10 × `\cert` plus `default\pubcert` and `default\sharedcert`), not 30. + +> **Add / Remove against `\pubcert` or `\sharedcert`** is rejected by the orchestrator before the call leaves with `"You can only add to on the default domain"`. This matches DataPower's actual permission model and keeps operators from chasing silent 403s. + +## Inventory and Management + +Inventory and Add / Remove jobs target a specific store path. The orchestrator branches on the directory: + +- `\cert` and `default\sharedcert` → reads `CryptoCertificate` config objects from `/mgmt/config/{domain}/CryptoCertificate`, filters to those whose `Filename` URI scheme matches the store (so a `default\sharedcert` job ignores the `cert:///` and `pubcert:///` entries that share the domain), and submits the certs. +- `default\pubcert` → reads files directly from the `pubcert:` filestore. + +Every job emits a `[FLOW:...]` breadcrumb summary that is appended to the `JobResult.FailureMessage` regardless of success or failure. The summary lists every step (Validate, ParseConfig, CreateApiClient, GetCerts.ParseResponse, GetCerts.SubmitInventory, ...) with timing and any error reason. Operators can read it directly from the job-history pane in Command without enabling Trace logging. + +### Optional Store Properties + +| Property | Description | +|----------|-------------| +| **Inventory Black List** | Comma-separated alias names to exclude from Inventory results (e.g. `system-cert,internal-test`). Case-insensitive. Empty by default. | +| **Inventory Page Size** | Maximum number of certs returned per Inventory submission. Defaults to `100`. | +| **Public Cert Store Name** | Name of the appliance's public-cert directory (default `pubcert`). Override only if the appliance has been re-configured. | +| **Protocol** | `https` (default) or `http`. Use `http` for lab appliances without the REST mgmt TLS profile configured. | + +## Migration Note + +Earlier releases of this orchestrator emitted `\pubcert` and `\sharedcert` from Discovery — N copies all aliasing the same physical store. If your environment has previously approved any of those non-default entries as cert stores in Command, they are now orphans: + +- Inventory against them returns nothing (the underlying objects all point at `:///` paths owned by `default`). +- Add and Remove are rejected by the orchestrator with a clear message. + +Re-run Discovery, approve the canonical `default\pubcert` and `default\sharedcert`, and remove the duplicates from your Command instance. ## License diff --git a/docsource/datapower.md b/docsource/datapower.md index bfdf7c5..758ace0 100644 --- a/docsource/datapower.md +++ b/docsource/datapower.md @@ -56,3 +56,8 @@ If this returns a JSON list of domains, the orchestrator will work from this hos | `The specified certificate has an unreadable, corrupt, or invalid certificate file` on Inventory's per-cert detail fetch | DataPower's parser rejected the cert file. Common cause is a self-signed cert lacking standard X.509 extensions (BasicConstraints, KeyUsage). The `test/generate-test-certs.ps1` script in the repo generates lab certs with the right extensions for testing. | | `401 Unauthorized` | API credentials are wrong, or the REST mgmt user lacks access to the target domain. | | `404 Not Found` on `/mgmt/domains/config/` | REST mgmt interface is not enabled on the appliance, or the orchestrator is pointing at the wrong port. | + +## Overview + +TODO Overview is a required section + From f373082823b69f2974d3ff05acda759874ace12b Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Fri, 1 May 2026 17:15:54 -0400 Subject: [PATCH 21/21] Delete .claude directory --- .claude/settings.json | 24 ------------------------ .claude/settings.local.json | 7 ------- 2 files changed, 31 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index a0642f0..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:andergrove.com)", - "WebFetch(domain:www.orangespecs.com)", - "WebFetch(domain:blog.andergrove.com)", - "WebFetch(domain:github.com)", - "Bash(git checkout:*)", - "Read(//c/Users/bhill/.nuget/packages/keyfactor.orchestrators.iorchestratorjobextensions/**)", - "Bash(dotnet build:*)", - "Bash(dotnet /c/Users/bhill/.nuget/packages/ilspycmd/9.0.0.7894/tools/net8.0/any/ilspycmd.dll /c/Users/bhill/.nuget/packages/keyfactor.orchestrators.iorchestratorjobextensions/0.7.0/lib/netstandard2.0/Keyfactor.Orchestrators.IOrchestratorJobExtensions.dll)", - "Bash(pip install:*)", - "Bash(python3 -c ':*)", - "Bash(gh search:*)", - "WebFetch(domain:keyfactor.github.io)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:www.nuget.org)", - "Bash(powershell -Command ':*)" - ], - "additionalDirectories": [ - "c:\\Users\\bhill\\source\\repos\\ibm-datapower-orchestrator\\.claude" - ] - } -} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 00fc07d..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet build:*)" - ] - } -}