From 12cd68df668ac4552d8f472598f921ffa2ed8243 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Wed, 6 May 2026 19:42:52 +0000 Subject: [PATCH 1/2] Feature/discovery job (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Datapower Discovery Job * Update generated docs * fixed path documentation * Update generated docs * 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) * 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) * Update keyfactor-starter-workflow.yml * Fix Discovery: parse filestore.location[] and strip trailing colon 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) * Add test setup for populating a DataPower lab appliance 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) * Harden Inventory paths against NullReferenceException 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) * Surface inventory counts in FlowLogger summary 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) * 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) * 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) * 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) * 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) * 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) * 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) * Delete docsource/fortiweb.md * 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) * Update generated docs * Delete .claude directory --------- Co-authored-by: Keyfactor Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 7 - .../workflows/keyfactor-starter-workflow.yml | 11 +- .gitignore | 8 + CHANGELOG.md | 52 +- DataPower/Client/DataPowerApiException.cs | 72 ++ DataPower/Client/DataPowerClient.cs | 170 +++- DataPower/FlowLogger.cs | 243 ++++++ DataPower/Jobs/Discovery.cs | 249 ++++++ DataPower/Jobs/Inventory.cs | 131 ++- DataPower/Jobs/JobBase.cs | 126 +++ DataPower/Jobs/Management.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/FileStoreLocation.cs | 27 + DataPower/RequestManager.cs | 104 ++- DataPower/manifest.json | 4 + README.md | 155 ++-- docs/discovery-overview.html | 813 ++++++++++++++++++ docs/discovery-overview.md | 191 ++++ docs/discovery-overview.pdf | Bin 0 -> 49672 bytes docsource/content.md | 144 +++- docsource/datapower.md | 63 +- docsource/fortiweb.md | 20 - integration-manifest.json | 10 +- readme_source.md | 104 +-- .../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 +++ 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 | 116 +++ 37 files changed, 4009 insertions(+), 361 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 DataPower/Client/DataPowerApiException.cs create mode 100644 DataPower/FlowLogger.cs create mode 100644 DataPower/Jobs/Discovery.cs create mode 100644 DataPower/Jobs/JobBase.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/FileStoreLocation.cs create mode 100644 docs/discovery-overview.html create mode 100644 docs/discovery-overview.md create mode 100644 docs/discovery-overview.pdf delete mode 100644 docsource/fortiweb.md 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 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/.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:*)" - ] - } -} 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 }} 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 997681a..10ea657 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 = "location"; + + // DataPower returns a single object instead of an array when only one location exists + if (strResponse.Contains($"\"{containerName}\"") && + !strResponse.Contains($"\"{containerName}\" : [") && + !strResponse.Contains($"\"{containerName}\":[")) + { + strResponse = FixDataPowerBadJson(strResponse, containerName); + } + + var response = JsonConvert.DeserializeObject(strResponse); + if (response?.FileStore?.Locations == null) + return new List(); + + return response.FileStore.Locations + .Select(d => d.Name) + .ToList(); + } + catch (Exception e) + { + _logger.LogError($"Error In DataPowerClient.ListFileStoreDirectories: {LogHandler.FlattenException(e)}"); + throw; + } + } + public bool SaveConfig() { try @@ -405,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; }; @@ -424,25 +493,102 @@ 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 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}. Body: {responseBody}", + 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 new file mode 100644 index 0000000..f7750c9 --- /dev/null +++ b/DataPower/Jobs/Discovery.cs @@ -0,0 +1,249 @@ +// 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 : JobBase, IDiscoveryJobExtension + { + // 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" + }; + + // 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. + 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(); + } + + public string ExtensionName => "DataPower"; + + public JobResult ProcessJob(DiscoveryJobConfiguration jobConfiguration, + SubmitDiscoveryUpdate submitDiscoveryUpdate) + { + if (jobConfiguration == null) + { + Logger.LogError("ProcessJob called with null jobConfiguration."); + return FailureResult(0, "DiscoveryJobConfiguration is null."); + } + + if (submitDiscoveryUpdate == null) + { + 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) + { + 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, FlowLogger flow) + { + try + { + var protocol = "https"; + flow.Step("ParseProtocol", () => + { + 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}"); + + DataPowerClient apiClient = null; + flow.Step("CreateApiClient", () => + { + apiClient = new DataPowerClient( + ResolvePamField("ServerUserName", config.ServerUsername), + ResolvePamField("ServerPassword", config.ServerPassword), + baseUrl, + "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", () => + { + domains = apiClient.ListDomains(); + }, $"will populate domains"); + + var domainCount = domains?.Count ?? 0; + Logger.LogTrace($"Found {domainCount} domain(s)"); + + var discoveredLocations = new List(); + + if (domainCount == 0) + { + flow.Skip("DiscoverDirectories", "no domains returned"); + } + else + { + flow.Branch($"PerDomain (count={domainCount})"); + try + { + foreach (var domain in domains) + { + 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); + }); + + // 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 + .Select(d => d?.TrimEnd(':')) + .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}"); + } + } + 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); + } + } + } + finally + { + flow.EndBranch(); + } + } + + flow.Step("SubmitDiscovery", () => submitDiscovery.Invoke(discoveredLocations), + $"locationCount={discoveredLocations.Count}"); + + Logger.MethodExit(LogLevel.Debug); + + flow.Step("Result", $"SUCCESS - {discoveredLocations.Count} locations discovered"); + return SuccessResult(config.JobHistoryId, flow.GetSummary()); + } + catch (Exception e) + { + 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..05258d0 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, flow) + : _reqManager.GetCerts(config, apiClient, submitInventory, ci, flow)); - 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 +} 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..648b8d4 --- /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("location")] public FileStoreLocation[] Locations { 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/FileStoreLocation.cs b/DataPower/Models/SupportingObjects/FileStoreLocation.cs new file mode 100644 index 0000000..7a95678 --- /dev/null +++ b/DataPower/Models/SupportingObjects/FileStoreLocation.cs @@ -0,0 +1,27 @@ +// 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 +{ + // 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; } + + [JsonProperty("href")] public string Href { get; set; } + } +} diff --git a/DataPower/RequestManager.cs b/DataPower/RequestManager.cs index 3fcf12b..8abd899 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; } @@ -58,6 +59,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) { @@ -755,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)) @@ -768,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); @@ -869,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); @@ -1029,7 +1079,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 { @@ -1040,19 +1090,19 @@ 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; + flow?.Step("GetPublicCerts.ParseResponse", $"pubFileCount={pubFiles?.Length ?? 0}, blacklistCount={blackList.Count}"); + if (pubFiles != null) + foreach (var pc in pubFiles) { _logger.LogTrace($"Looping through public files: {pc.Name}"); var viewCertDetail = new ViewPubCertificateDetailRequest(pc.Name); @@ -1104,6 +1154,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..."); @@ -1122,7 +1173,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 { @@ -1133,13 +1184,27 @@ 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 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)) { _logger.LogTrace($"Looping through Certificate Store files: {cc.Name}"); @@ -1182,7 +1247,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 { 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/README.md b/README.md index ab09b87..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. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. +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,8 +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 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 - ## DataPower Certificate Store Type @@ -60,7 +63,7 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D - +TODO Overview is a required section @@ -71,7 +74,7 @@ To use the DataPower Universal Orchestrator extension, you **must** create the D |--------------|------------------------------------------------------------------------------------------------------------------------| | Add | ✅ Checked | | Remove | 🔲 Unchecked | -| Discovery | 🔲 Unchecked | +| Discovery | ✅ Checked | | Reenrollment | 🔲 Unchecked | | Create | 🔲 Unchecked | @@ -114,7 +117,7 @@ the Keyfactor Command Portal | 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 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 | @@ -182,6 +185,7 @@ the Keyfactor Command Portal 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) @@ -189,6 +193,7 @@ the Keyfactor Command Portal 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) @@ -196,6 +201,7 @@ the Keyfactor Command Portal 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) @@ -203,6 +209,7 @@ the Keyfactor Command Portal 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) @@ -210,6 +217,7 @@ the Keyfactor Command Portal 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) @@ -285,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). | @@ -317,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). | @@ -355,47 +363,98 @@ 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 +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** — `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 only needs the appliance connection details — no store path is required: + +| Field | Description | +|-------|-------------| +| **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. | + +The FlowLogger summary on the job's result records which filter list was applied: + +``` +[OK] ResolveDirsToSearch - source=user (key=dirs), dirs=[cert,sharedcert] +``` + +vs + +``` +[OK] ResolveDirsToSearch - source=default, dirs=[cert,pubcert,sharedcert] +``` + + + + +## Store Path Format + +Every Inventory, Management (Add / Remove), and Discovery operation uses the same path shape: + +``` +\ +``` + +| Part | Description | Examples | +|------|-------------|----------| +| **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. -## 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 - -*** +Re-run Discovery, approve the canonical `default\pubcert` and `default\sharedcert`, and remove the duplicates from your Command instance. ## License diff --git a/docs/discovery-overview.html b/docs/discovery-overview.html new file mode 100644 index 0000000..3fa84d1 --- /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
  • +
  • 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
  • +
  • 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 per-domain cert + default\pubcert + default\sharedcert = 52 stores auto-discovered in one job
+
Test
+
40 per-domain cert + default\pubcert + default\sharedcert = 42 stores auto-discovered in one job
+
Dev
+
30 per-domain cert + default\pubcert + default\sharedcert = 32 stores auto-discovered in one job
+
Sandbox
+
20 per-domain cert + default\pubcert + default\sharedcert = 22 stores auto-discovered in one job
+
Total
+
148 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 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.

+
+
+ +
+
+ +
+
+
+ +
+
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 those names against the Discovery job's "Directories to search" field (comma-separated; defaults to cert,pubcert,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/FileStoreLocation.cs
+

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

+
+
+ +

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/docs/discovery-overview.md b/docs/discovery-overview.md new file mode 100644 index 0000000..d385230 --- /dev/null +++ b/docs/discovery-overview.md @@ -0,0 +1,191 @@ +# 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 +- 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 +- 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 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 | + +--- + +## 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 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 + +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 + +All operations in the DataPower Orchestrator (Discovery, Inventory, Add, Remove) use a consistent store path format: + +``` +\ +``` + +### Certificate Store Directories + +| 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 + +| 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 those names against the Discovery job's "Directories to search" field (comma-separated; defaults to `cert,pubcert,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/FileStoreLocation.cs` | Name and href of one entry in `filestore.location[]` (e.g. `cert:`, `pubcert:`, `sharedcert:`). | + +### 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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\`. +```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 + +Every Inventory, Management (Add / Remove), and Discovery operation uses the same path shape: + +``` +\ +``` + +| Part | Description | Examples | +|------|-------------|----------| +| **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. + +## Discovery + +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** — `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 only needs the appliance connection details — no store path is required: + +| Field | Description | +|-------|-------------| +| **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. | + +The FlowLogger summary on the job's result records which filter list was applied: + +``` +[OK] ResolveDirsToSearch - source=user (key=dirs), dirs=[cert,sharedcert] +``` + +vs + +``` +[OK] ResolveDirsToSearch - source=default, dirs=[cert,pubcert,sharedcert] +``` + +## 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 002304d..758ace0 100644 --- a/docsource/datapower.md +++ b/docsource/datapower.md @@ -1,4 +1,63 @@ -## 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. + +| 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. | + +## Overview + +TODO Overview is a required section 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 - diff --git a/integration-manifest.json b/integration-manifest.json index 8303933..c4a9665 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 }, @@ -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..f0807a1 100644 --- a/readme_source.md +++ b/readme_source.md @@ -1,101 +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. Inventory, Add and Remove functions are supported. This integration can add/replace certificates in any domain\directory combination. For example default\pubcert +## Vendor Configuration ---- +Before installing the orchestrator extension: -**1) Create the new Certificate store Type for the New DataPower AnyAgent** +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 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 |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 +See the [DataPower Knowledge Center](https://www.ibm.com/docs/en/datapower-gateway) for instructions on enabling the REST mgmt interface and managing roles. -![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 |This will the domain\path combination to enroll and inventory to. If it is the default domain just put the path. -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) 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..afbab29 --- /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 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, + "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..58318bb --- /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 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, + "Style": "Default" + }, + "PrivateKeyAllowed": "Optional", + "JobProperties": [], + "ServerRequired": true, + "PowerShell": false, + "BlueprintAllowed": false, + "CustomAliasAllowed": "Required" +} +'@ + + +Write-Host "Completed." 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..7d20c26 --- /dev/null +++ b/test/generate-test-certs.ps1 @@ -0,0 +1,116 @@ +# 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 + ) + + # 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) + + $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 f21e3f962612567275c31af944864d72993bf9e3 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Thu, 21 May 2026 00:05:36 +0000 Subject: [PATCH 2/2] Bound FlowLogger summary length and aggregate per-domain failures (1.2.1) (#27) * Bound FlowLogger summary length and aggregate per-domain failures Discovery against an appliance with 235 domains produced a 50+ KB FlowLogger summary - two lines per failed domain ([FAIL] ListFileStore-X followed by [SKIP] Domain-X, both echoing the full HTTP 401 body). When the orchestrator returned that as JobResult.FailureMessage, Command's SQL update against AgentJobStatus.Message failed with "String or binary data would be truncated" and the entire job result was lost. The trace showed the job ran fine, but the customer saw a failed job. Two changes: - FlowLogger.GetSummary() now caps output at 3500 chars and appends a "[truncated, N chars omitted; check orchestrator log for full breadcrumb]" marker. Defense in depth - even if a future job somehow blows up the summary again, the result update won't fail. Trace log still carries the full picture. - Discovery aggregates per-domain failures by error signature instead of emitting one FAIL + one SKIP per failed domain. Identical 401s across N domains collapse into one [SKIP] DomainsFailed[HTTP 401 Unauthorized: Authentication failure.] - 114 domain(s): AashishSandbox, ADT_*, ADT_*, ADT_*, ADT_* (+109 more). The /_links self URL (which varies per domain) is stripped from the group key so the grouping actually works. Successful domains still emit a per-path Discovered-X step so operators can see what was found. Net effect on the user's 235-domain appliance: summary drops from ~50 KB / 235+ step lines to ~1 KB / ~10 step lines. Co-Authored-By: Claude Opus 4.7 (1M context) * Add 1.2.1 CHANGELOG entry for FlowLogger truncation fix --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++++++- DataPower/FlowLogger.cs | 15 ++++- DataPower/Jobs/Discovery.cs | 116 ++++++++++++++++++++++++++---------- 3 files changed, 120 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7190db1..f0427d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,25 @@ -## 1.2.0 - unreleased +## 1.2.1 + +### Fixed +* Discovery against appliances with a large number of application domains + (200+) where the orchestrator's API user lacks read access to many of them + no longer produces a SQL truncation failure in Keyfactor Command. Two + related fixes: + * `FlowLogger.GetSummary()` output is now capped at 3500 characters with + a `[truncated, N chars omitted; check orchestrator log for full + breadcrumb]` marker. Defense-in-depth against Command's + `AgentJobStatus.Message` column overflow (NVARCHAR(4000)). + * Discovery aggregates per-domain failures by error signature instead of + emitting one `FAIL` and one `SKIP` line per failed domain. Identical + HTTP errors across N domains collapse into one summary line with a + count and a 5-domain sample. Successful domains still emit one + `Discovered-` step each. + + Net effect on a 235-domain appliance with 114 inaccessible domains: the + breadcrumb summary drops from ~50 KB and 235+ step lines to ~1 KB and + ~10 step lines, well under Command's column cap. + +## 1.2.0 ### Added * Discovery job: automatically enumerates all application domains on a DataPower diff --git a/DataPower/FlowLogger.cs b/DataPower/FlowLogger.cs index 917f45c..8d67677 100644 --- a/DataPower/FlowLogger.cs +++ b/DataPower/FlowLogger.cs @@ -172,6 +172,12 @@ public void EndBranch() public bool HasFailures => _steps.Any(s => s.Status == StepStatus.Failed); + // Command's AgentJobStatus.Message column has a hard length cap (typically + // NVARCHAR(4000)). Truncate aggressively so a job result update never fails + // with "String or binary data would be truncated". Leaves headroom for the + // FailureMessage prefix the caller adds. + private const int MaxSummaryChars = 3500; + public string GetSummary() { var hasFailures = HasFailures; @@ -202,7 +208,14 @@ public string GetSummary() } sb.Append("----------------------------------------"); - return sb.ToString(); + var summary = sb.ToString(); + if (summary.Length > MaxSummaryChars) + { + var omitted = summary.Length - MaxSummaryChars; + summary = summary.Substring(0, MaxSummaryChars) + + $"\n... [truncated, {omitted} chars omitted; check orchestrator log for full breadcrumb]"; + } + return summary; } public void Dispose() diff --git a/DataPower/Jobs/Discovery.cs b/DataPower/Jobs/Discovery.cs index f7750c9..71dd91b 100644 --- a/DataPower/Jobs/Discovery.cs +++ b/DataPower/Jobs/Discovery.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using Keyfactor.Extensions.Orchestrator.DataPower.Client; using Keyfactor.Logging; using Keyfactor.Orchestrators.Common.Enums; @@ -53,6 +54,29 @@ public class Discovery : JobBase, IDiscoveryJobExtension // exact casing has shifted across Command versions. private static readonly string[] DirsToSearchKeys = { "dirs", "Dirs", "directories", "Directories", "DirsToSearch" }; + // Extracts a stable group key for an exception thrown by a per-domain + // ListFileStoreDirectories call. Strips domain-specific bits (the /_links/self/href + // URL changes per domain) so identical RBAC failures across hundreds of domains + // collapse into a single error group. + private static readonly Regex ErrorMessageRegex = new Regex( + "\"error\"\\s*:\\s*\\[\\s*\"([^\"]+)\"", + RegexOptions.Compiled | RegexOptions.Singleline); + + private static string ErrorSignatureOf(Exception ex) + { + var apiEx = DataPowerApiException.Find(ex); + if (apiEx != null) + { + var m = ErrorMessageRegex.Match(apiEx.ResponseBody ?? string.Empty); + if (m.Success) + return $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}: {m.Groups[1].Value}"; + return $"HTTP {(int)apiEx.StatusCode} {apiEx.StatusCode}"; + } + + var msg = ex?.Message ?? "(no message)"; + return msg.Length > 80 ? msg.Substring(0, 80) + "..." : msg; + } + private static (HashSet Dirs, string Source) ResolveDirsToSearch(DiscoveryJobConfiguration config) { if (config?.JobProperties != null) @@ -175,53 +199,81 @@ private JobResult PerformDiscovery(DiscoveryJobConfiguration config, SubmitDisco flow.Branch($"PerDomain (count={domainCount})"); try { + var listedOk = 0; + var emptyNameSkipped = 0; + + // Group per-domain failures by error signature instead of emitting a + // FAIL+SKIP line per failed domain. On appliances with 200+ domains and + // a non-trivial RBAC story, that easily produces a 50 KB summary which + // overflows Command's AgentJobStatus.Message column. One aggregated SKIP + // line per error class scales fine and is far more readable. + var errorGroups = new Dictionary>(StringComparer.Ordinal); + foreach (var domain in domains) { if (string.IsNullOrWhiteSpace(domain?.Name)) { - flow.Skip("Domain", "empty domain name"); + emptyNameSkipped++; continue; } + List directories; try { - List directories = null; - flow.Step($"ListFileStore-{domain.Name}", () => - { - 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 - .Select(d => d?.TrimEnd(':')) - .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}"); - } + directories = apiClient.ListFileStoreDirectories(domain.Name); } 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); + var signature = ErrorSignatureOf(ex); + if (!errorGroups.TryGetValue(signature, out var list)) + { + list = new List(); + errorGroups[signature] = list; + } + list.Add(domain.Name); + Logger.LogWarning(ex, + "Unable to list filestore directories for domain {DomainName}: {ErrorMessage}", + domain.Name, DescribeException(ex)); + continue; + } + + listedOk++; + + // 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 + .Select(d => d?.TrimEnd(':')) + .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) + continue; // appliance-wide; emitted only under default + + var storePath = $"{domain.Name}\\{dir}"; + discoveredLocations.Add(storePath); + flow.Step($"Discovered-{storePath}"); } } + + var listedDetail = $"listed={listedOk}/{domainCount}"; + if (emptyNameSkipped > 0) + listedDetail += $", emptyName={emptyNameSkipped}"; + if (errorGroups.Count > 0) + listedDetail += $", failed={errorGroups.Values.Sum(v => v.Count)}"; + flow.Step("ListFileStore", listedDetail); + + foreach (var kvp in errorGroups.OrderByDescending(g => g.Value.Count)) + { + var sample = kvp.Value.Take(5).ToList(); + var more = kvp.Value.Count - sample.Count; + var sampleStr = string.Join(", ", sample) + (more > 0 ? $" (+{more} more)" : ""); + flow.Skip($"DomainsFailed[{kvp.Key}]", $"{kvp.Value.Count} domain(s): {sampleStr}"); + } } finally {