diff --git a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs index a763f2e..d04e7ec 100644 --- a/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs +++ b/cscglobal-caplugin.Tests/CSCGlobalCAPluginTests.cs @@ -758,10 +758,19 @@ public void GetTemplateParameterAnnotations_ReturnsExpectedKeys() } [Fact] - public void GetProductIds_ReturnsCanonicalTenProducts() + public void GetProductIds_ReturnsCanonicalAndLegacyNames() { var plugin = MakePlugin(); - Assert.Equal(10, plugin.GetProductIds().Count); + var ids = plugin.GetProductIds(); + + // Command uses this list to auto-resolve a CA for a template when none is explicitly + // selected, so pre-1.2.0 legacy-named templates need their names advertised here too, + // not just the 10 canonical names. + Assert.Equal(17, ids.Count); + Assert.Contains("CSC TrustedSecure OV", ids); + Assert.Contains("CSC TrustedSecure DV Wildcard, Multiple Names", ids); + Assert.Contains("CSC TrustedSecure EV Certificate", ids); + Assert.Contains("CSC TrustedSecure Domain Validated SSL", ids); } // --------------------------------------------------------------------- diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index 6480bb4..fccf9c8 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -434,7 +434,11 @@ private async Task SyncCertificates(BlockingCollection b if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); + // CSC's list/sync API returns the certificate's current product name directly + // (e.g. "CSC TrustedSecure DV"), which already matches the canonical Product ID + // used for enrollment - no reverse lookup needed, same as the CSC-name-is-truth + // approach taken on feature/ev-ov-dv-multiname-certs. + var productId = currentResponseItem.CertificateType ?? "CscGlobal"; Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); @@ -490,26 +494,6 @@ private async Task SyncCertificates(BlockingCollection b Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); skippedCount++; } - else - { - Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); - skippedCount++; - } - else - { - Logger.LogTrace("SyncCertificates: fileContent was empty for UUID={Uuid}, skipping.", currentResponseItem.Uuid); - skippedCount++; - } - } - else - { - Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); - skippedCount++; - } - else - { - Logger.LogTrace("SyncCertificates: UUID={Uuid} status {Status} not eligible for sync, skipping.", currentResponseItem.Uuid, certStatus); - skippedCount++; } else { @@ -643,17 +627,13 @@ public async Task Enroll(string csr, string subject, Dictionar flow.Step("CheckPriorCertSN", () => { - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + // Command sends this key as "PriorCertSN" (proper case) - a prior version of this + // check gated on "priorcertsn" (lowercase) instead, which Command never actually + // sends, so this block silently never ran and PriorCertSN was never populated. + if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) - { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); - } - else - { - Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); - } + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); } }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); @@ -698,8 +678,8 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("ParseResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Enrollment failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed: CSC API returned a null response." }; } flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); @@ -710,18 +690,15 @@ await flow.StepAsync("SubmitRegistrationToCSC", async () => flow.Fail("RejectExpiredRenew", "PriorCertSN present on New enrollment"); return new EnrollmentResult { - Status = 30, - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nYou cannot renew an expired cert please perform an new enrollment." }; } var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); - await flow.StepAsync("PublishCnameDcv", async () => - { - await TryPublishCnameDcvAsync(productInfo, enrollResult); - }); + await flow.StepAsync("DcvAutoPublish", () => TryPublishCnameDcvAsync(productInfo, enrollResult)); EnrollmentResult? newPolled = null; await flow.StepAsync("PollForIssuance", async () => @@ -731,10 +708,12 @@ await flow.StepAsync("PollForIssuance", async () => if (newPolled != null) { flow.Step("PollResult", "issued during poll window"); + AttachFlowSummary(newPolled, flow); Logger.MethodExit(LogLevel.Debug); return newPolled; } + AttachFlowSummary(enrollResult, flow); Logger.MethodExit(LogLevel.Debug); return enrollResult; @@ -746,8 +725,8 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); return new EnrollmentResult { - Status = 30, - StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: PriorCertSN is required but was not provided." }; } @@ -762,8 +741,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"no order found for SN={priorSn}"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: could not find order ID for serial number '{priorSn}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: could not find order ID for serial number '{priorSn}'." }; } @@ -772,8 +751,8 @@ await flow.StepAsync("LookupOrderId", async () => flow.Fail("ValidateOrderId", $"order_id too short ({order_id.Length} chars)"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: order ID '{order_id}' is too short to extract a UUID." }; } flow.Step("ValidateOrderId", $"orderId={order_id}"); @@ -821,8 +800,8 @@ await flow.StepAsync("FetchLiveCertForDecision", async () => flow.Fail("FallbackExpiryCheck", fallbackEx.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" }; } } @@ -845,8 +824,8 @@ await flow.StepAsync("LookupRenewalUUID", async () => flow.Fail("ValidateRenewalUUID", "could not resolve PriorCertSN"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: could not resolve prior certificate serial number to a request ID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: could not resolve prior certificate serial number to a request ID." }; } flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); @@ -870,8 +849,8 @@ await flow.StepAsync("SubmitRenewalToCSC", async () => flow.Fail("ParseRenewalResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Renewal failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nRenewal failed: CSC API returned a null response." }; } @@ -884,6 +863,7 @@ await flow.StepAsync("PollForIssuance", async () => { renewPolled = await TryPollForIssuedCertAsync(renewResult?.CARequestID); }); + AttachFlowSummary(renewPolled ?? renewResult, flow); Logger.MethodExit(LogLevel.Debug); return renewPolled ?? renewResult; } @@ -891,9 +871,9 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); return new EnrollmentResult { - Status = 30, + Status = (int)EndEntityStatus.FAILED, StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + $"{flow.GetSummary()}\n\nOne click Renew Is Not Available for this Certificate Type. Use the configure button instead." }; } @@ -912,8 +892,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => flow.Fail("ValidateReissueRequestId", "could not resolve PriorCertSN"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: could not resolve prior certificate serial number to a request ID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: could not resolve prior certificate serial number to a request ID." }; } @@ -922,8 +902,8 @@ await flow.StepAsync("LookupReissueRequestId", async () => flow.Fail("ValidateReissueRequestId", $"requestid too short ({requestid.Length} chars)"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Reissue failed: request ID '{requestid}' is too short to extract a UUID." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: request ID '{requestid}' is too short to extract a UUID." }; } @@ -949,8 +929,8 @@ await flow.StepAsync("SubmitReissueToCSC", async () => flow.Fail("ParseReissueResponse", "API returned null"); return new EnrollmentResult { - Status = 30, - StatusMessage = "Reissue failed: CSC API returned a null response." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nReissue failed: CSC API returned a null response." }; } @@ -963,6 +943,7 @@ await flow.StepAsync("PollForIssuance", async () => { reissuePolled = await TryPollForIssuedCertAsync(reissueResult?.CARequestID); }); + AttachFlowSummary(reissuePolled ?? reissueResult, flow); Logger.MethodExit(LogLevel.Debug); return reissuePolled ?? reissueResult; } @@ -970,7 +951,7 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, + Status = (int)EndEntityStatus.FAILED, StatusMessage = $"{flow.GetSummary()}\n\nOne click Reissue Is Not Available for this Certificate Type. Use the configure button instead." }; @@ -979,8 +960,8 @@ await flow.StepAsync("PollForIssuance", async () => flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnroll failed: unhandled enrollment type '{enrollmentType}'." }; } } @@ -991,8 +972,8 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(inner, "Enroll: AggregateException during {EnrollmentType}: {Message}", enrollmentType, inner?.Message ?? ae.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {inner?.Message ?? ae.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {inner?.Message ?? ae.Message}" }; } catch (Exception ex) @@ -1001,12 +982,40 @@ await flow.StepAsync("PollForIssuance", async () => Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); return new EnrollmentResult { - Status = 30, - StatusMessage = $"Enrollment failed with error: {ex.Message}" + Status = (int)EndEntityStatus.FAILED, + StatusMessage = $"{flow.GetSummary()}\n\nEnrollment failed with error: {ex.Message}" }; } } + // CSC Global business-level failures (e.g. "Open order in progress") come back from + // RequestManager as a terse StatusMessage with no context on what the plugin actually did + // before hitting that error - prepend the flow's step-by-step summary so the message shown + // to the requester in Command explains what ran, not just how it ended. Command's enrollment + // UI does not surface StatusMessage on a successful/pending result at all - only + // EnrollmentContext is - so attach the summary there instead, as its own entry alongside + // whatever DCV instructions came back. Must be called after TryPublishCnameDcvAsync, which + // treats every EnrollmentContext entry as a candidate DNS record to publish - calling this + // first would make it try to publish "Flow Summary" as a CNAME. + private static void AttachFlowSummary(EnrollmentResult? result, FlowLogger flow) + { + if (result == null) + return; + + if (result.Status == (int)EndEntityStatus.FAILED) + { + result.StatusMessage = $"{flow.GetSummary()}\n\n{result.StatusMessage}"; + return; + } + + // One EnrollmentContext entry per step (rather than one entry holding the whole + // multi-line summary) so Command's bulleted rendering shows a readable line per step + // instead of a single run-on blob. + result.EnrollmentContext ??= new Dictionary(); + foreach (var entry in flow.GetSummaryEntries()) + result.EnrollmentContext[entry.Key] = entry.Value; + } + //done public async Task Ping() { @@ -1101,17 +1110,14 @@ public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, throw new ArgumentException("ProductID cannot be null or empty.", nameof(productInfo)); } - var certType = ProductIDs.productIds.Find(x => - x.Equals(productInfo.ProductID, StringComparison.InvariantCultureIgnoreCase)); - - if (certType == null) + if (!_requestManager.IsKnownProductId(productInfo.ProductID)) { Logger.LogError("ValidateProductInfo: cannot find product ID '{ProductId}'. Known IDs: [{KnownIds}]", productInfo.ProductID, string.Join(", ", ProductIDs.productIds)); throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); } - Logger.LogInformation("Validated {CertType} configured for AnyGateway", certType); + Logger.LogInformation("Validated {ProductId} configured for AnyGateway", productInfo.ProductID); Logger.MethodExit(LogLevel.Debug); } @@ -1285,8 +1291,16 @@ public Dictionary GetTemplateParameterAnnotations() //done public List GetProductIds() { - - return ProductIDs.productIds; + // Command uses this list to auto-resolve a CA for a template when none is explicitly + // selected (matches a template's product ID against each CA's advertised list). Existing + // Certificate Templates created before the 1.2.0 product rename are still configured with + // the pre-1.2.0 legacy names, so those must be advertised here too, or auto-resolution + // fails for them with "No CA was found to issue the specified template" even though + // explicit CA selection still works (that path goes through the more lenient + // ValidateProductInfo, which already accepts legacy names via legacyProductIdAliases). + return ProductIDs.productIds + .Concat(ProductIDs.legacyProductIdAliases.Keys) + .ToList(); } #region PRIVATE @@ -1379,19 +1393,21 @@ record = null; /// resolves for its domain. No-op if the factory wasn't injected, the cert isn't using CNAME /// validation, or the response contains no CNAME details. Failures are logged but never thrown — /// manual publishing remains a fallback so the enrollment result is still returned to Keyfactor. + /// Returns a short description of what happened (published/skipped/why), surfaced as the + /// flow step's detail so a no-op for non-CNAME methods doesn't look unexplained. /// - private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) + private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, EnrollmentResult? enrollResult) { if (_validatorFactory == null) { Logger.LogTrace("TryPublishCnameDcvAsync: no IDomainValidatorFactory was injected, skipping auto-publish."); - return; + return "skipped - no DNS validator factory injected"; } if (enrollResult?.EnrollmentContext == null || enrollResult.EnrollmentContext.Count == 0) { Logger.LogTrace("TryPublishCnameDcvAsync: no CNAME entries in EnrollmentContext, skipping."); - return; + return "skipped - no DCV entries returned by CSC"; } var dcvMethod = productInfo?.ProductParameters != null @@ -1403,7 +1419,7 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En !string.Equals(dcvMethod, "CNAME", StringComparison.OrdinalIgnoreCase)) { Logger.LogTrace("TryPublishCnameDcvAsync: DCV method '{Method}' is not CNAME, skipping auto-publish.", dcvMethod ?? "(null)"); - return; + return $"skipped - DCV method is '{dcvMethod ?? "(none)"}', not CNAME"; } Logger.LogInformation( @@ -1489,6 +1505,9 @@ private async Task TryPublishCnameDcvAsync(EnrollmentProductInfo productInfo, En Logger.LogInformation( "TryPublishCnameDcvAsync: complete. Published={Published}, Failed={Failed}, Unresolved={Unresolved}", successCount, failCount, unresolvedCount); + + return $"published {successCount}, failed {failCount}, unresolved {unresolvedCount} " + + $"of {enrollResult.EnrollmentContext.Count} CNAME record(s)"; } //Trying to fix leaf extraction diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 6ce5c3e..7a5b722 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -22,6 +22,12 @@ public sealed class CscGlobalClient : ICscGlobalClient private readonly ILogger Logger; public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) + { + } + + // internal so the test project can supply a fake HttpMessageHandler via + // InternalsVisibleTo, instead of the client making real HTTP calls in unit tests. + internal CscGlobalClient(IAnyCAPluginConfigProvider config, HttpMessageHandler? handler) { Logger = LogHandler.GetClassLogger(); @@ -68,7 +74,7 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) : this(config, null) } Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); - RestClient = ConfigureRestClient(); + RestClient = ConfigureRestClient(handler); Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); } else diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index dc10866..f0284c2 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -23,14 +23,31 @@ public class ProductIDs { public static List productIds = new List() { - "CSC TrustedSecure Premium Certificate", - "CSC TrustedSecure EV Certificate", - "CSC TrustedSecure UC Certificate", - "CSC TrustedSecure Premium Wildcard Certificate", - "CSC TrustedSecure Domain Validated SSL", - "CSC Trusted Secure Domain Validated Wildcard SSL", - "CSC Trusted Secure Domain Validated UC Certificate" + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names" }; + + // Pre-1.2.0 template names. Existing Certificate Templates in Command may still + // reference these, so they're accepted as aliases for their canonical replacement. + public static Dictionary legacyProductIdAliases = + new(StringComparer.InvariantCultureIgnoreCase) + { + ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure OV", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure OV Wildcard", + ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure OV, Multiple Names", + ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV", + ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure DV", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC TrustedSecure DV Wildcard", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC TrustedSecure DV, Multiple Names" + }; } public class EnrollmentConfigConstants diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs index 5696fcd..fb90961 100644 --- a/cscglobal-caplugin/FlowLogger.cs +++ b/cscglobal-caplugin/FlowLogger.cs @@ -124,6 +124,42 @@ public async Task StepAsync(string name, Func action, string d return this; } + /// + /// Record an async step whose own return value becomes the step's detail - unlike the + /// parameter on the other overload (which is evaluated before + /// the action runs and so can't reflect anything the action decided), this reflects what + /// actually happened during execution (e.g. why a conditional step was a no-op). + /// + public async Task StepAsync(string name, Func> action) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + var detail = await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = detail; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK ({Elapsed}ms){Detail}", + _flowName, name, sw.ElapsedMilliseconds, detail != null ? $" {detail}" : ""); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = FlowStepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.Detail = ex.Message; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED ({Elapsed}ms): {Error}", + _flowName, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + return this; + } + /// Record a failed step without throwing. public FlowLogger Fail(string name, string reason = null) { @@ -219,6 +255,86 @@ private string RenderFlow() return sb.ToString(); } + /// + /// Concise step-by-step summary suitable for surfacing in a user-facing failure message + /// (unlike 's ASCII-art tree, which is meant for Trace logs only). + /// + public string GetSummary() + { + var hasFailures = _steps.Any(s => s.Status == FlowStepStatus.Failed) || + _steps.SelectMany(s => s.Children).Any(c => c.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {_totalTimer.ElapsedMilliseconds}ms"); + sb.AppendLine("----------------------------------------"); + + foreach (var step in _steps) + { + AppendSummaryLine(sb, step, 0); + foreach (var child in step.Children) + AppendSummaryLine(sb, child, 1); + } + + return sb.ToString(); + } + + private static void AppendSummaryLine(StringBuilder sb, FlowStep step, int indentLevel) + { + var indent = new string(' ', indentLevel * 2); + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + sb.AppendLine($"{indent}{icon} {step.Name}{elapsed}{detail}"); + } + + /// + /// Same information as , but as one entry per step instead of a + /// single multi-line block. Intended for callers (e.g. EnrollmentResult.EnrollmentContext) + /// whose rendering surface displays a dictionary as a bulleted list and doesn't respect + /// embedded newlines - each step becomes its own bullet instead of one run-on line. + /// + public Dictionary GetSummaryEntries() + { + var allSteps = _steps.Concat(_steps.SelectMany(s => s.Children)).ToList(); + var hasFailures = allSteps.Any(s => s.Status == FlowStepStatus.Failed); + var overallStatus = hasFailures ? "FAILED" : "OK"; + var succeeded = allSteps.Count(s => s.Status == FlowStepStatus.Success); + var failed = allSteps.Count(s => s.Status == FlowStepStatus.Failed); + var skipped = allSteps.Count(s => s.Status == FlowStepStatus.Skipped); + + var entries = new Dictionary + { + [$"Flow: {_flowName}"] = + $"[{overallStatus}] {_totalTimer.ElapsedMilliseconds}ms total - " + + $"{allSteps.Count} steps ({succeeded} ok, {failed} failed, {skipped} skipped)" + }; + + var stepNumber = 0; + foreach (var step in _steps) + { + stepNumber++; + AddSummaryEntry(entries, step, stepNumber, false); + + foreach (var child in step.Children) + { + stepNumber++; + AddSummaryEntry(entries, child, stepNumber, true); + } + } + + return entries; + } + + private static void AddSummaryEntry(Dictionary entries, FlowStep step, int stepNumber, bool indent) + { + var icon = GetStatusIcon(step.Status); + var time = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" - {step.Detail}" : ""; + var prefix = indent ? " " : ""; + entries[$"Flow Step {stepNumber:00}: {prefix}{step.Name}"] = $"{icon}{time}{detail}"; + } + private static string GetStatusIcon(FlowStepStatus status) { return status switch diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index f65d589..9f9bcb9 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -388,55 +388,39 @@ public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productI }; } - // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests). + // Each product has an entry for its current (1.2.0+) canonical name and its pre-1.2.0 legacy + // name, so existing Certificate Templates in Command using the old names keep working. + // Types 7/8/9 are new in 1.2.0 and have no legacy name. private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) { + ["CSC TrustedSecure OV"] = "0", ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure OV Wildcard"] = "1", ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure OV, Multiple Names"] = "2", ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV"] = "3", ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure DV"] = "4", ["CSC TrustedSecure Domain Validated SSL"] = "4", ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC TrustedSecure DV Wildcard"] = "5", ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC TrustedSecure DV, Multiple Names"] = "6", ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + ["CSC TrustedSecure EV, Multiple Names"] = "7", + ["CSC TrustedSecure OV Wildcard, Multiple Names"] = "8", + ["CSC TrustedSecure DV Wildcard, Multiple Names"] = "9", }; - // Reverse map: CSC API certificateType string -> Keyfactor product ID (used during sync) - // Note: CSC naming is inconsistent — first 4 types use "TrustedSecure" (no space), - // DV Wildcard and DV UC use "Trusted Secure" (with space), - // but CSC API returns DV SSL as "CSC Trusted Secure Domain Validated SSL" (with space) - // while the product ID is "CSC TrustedSecure Domain Validated SSL" (no space). - private static readonly Dictionary CodeToProductIdMap = new(StringComparer.OrdinalIgnoreCase) - { - // Premium - ["0"] = "CSC TrustedSecure Premium Certificate", - ["CSC TrustedSecure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - ["CSC Trusted Secure Premium Certificate"] = "CSC TrustedSecure Premium Certificate", - // Premium Wildcard - ["1"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC TrustedSecure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - ["CSC Trusted Secure Premium Wildcard Certificate"] = "CSC TrustedSecure Premium Wildcard Certificate", - // UC - ["2"] = "CSC TrustedSecure UC Certificate", - ["CSC TrustedSecure UC Certificate"] = "CSC TrustedSecure UC Certificate", - ["CSC Trusted Secure UC Certificate"] = "CSC TrustedSecure UC Certificate", - // EV - ["3"] = "CSC TrustedSecure EV Certificate", - ["CSC TrustedSecure EV Certificate"] = "CSC TrustedSecure EV Certificate", - ["CSC Trusted Secure EV Certificate"] = "CSC TrustedSecure EV Certificate", - // DV SSL — product ID has no space, but CSC API returns with space - ["4"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC TrustedSecure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - ["CSC Trusted Secure Domain Validated SSL"] = "CSC TrustedSecure Domain Validated SSL", - // DV Wildcard — product ID has space (matches CSC API) - ["5"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - ["CSC TrustedSecure Domain Validated Wildcard SSL"] = "CSC Trusted Secure Domain Validated Wildcard SSL", - // DV UC — product ID has space (matches CSC API) - ["6"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC Trusted Secure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - ["CSC TrustedSecure Domain Validated UC Certificate"] = "CSC Trusted Secure Domain Validated UC Certificate", - }; + /// + /// True if productId resolves to a known CSC certificate type - either its canonical + /// (1.2.0+) name or a pre-1.2.0 legacy name. Used by ValidateProductInfo so the list of + /// accepted names can't drift out of sync with what GetCertificateType actually resolves. + /// + public bool IsKnownProductId(string productId) => + !string.IsNullOrEmpty(productId) && ProductIdToCodeMap.ContainsKey(productId); private string GetCertificateType(string productId) { @@ -450,22 +434,6 @@ private string GetCertificateType(string productId) return "-1"; } - /// - /// Maps a CSC API certificateType value back to a Keyfactor product ID. - /// Handles numeric codes, descriptive strings, and passthrough of already-correct values. - /// - public string MapCertificateTypeToProductId(string cscCertificateType) - { - Logger.LogTrace("MapCertificateTypeToProductId: input='{CscCertType}'", cscCertificateType ?? "(null)"); - if (!string.IsNullOrEmpty(cscCertificateType) && CodeToProductIdMap.TryGetValue(cscCertificateType, out var productId)) - { - Logger.LogTrace("MapCertificateTypeToProductId: mapped '{CscCertType}' -> '{ProductId}'", cscCertificateType, productId); - return productId; - } - Logger.LogWarning("MapCertificateTypeToProductId: no mapping for '{CscCertType}', passing through as-is.", cscCertificateType); - return cscCertificateType ?? "CscGlobal"; - } - public Notifications GetNotifications(EnrollmentProductInfo productInfo) { Logger.LogTrace("GetNotifications: building notifications."); @@ -561,6 +529,14 @@ private List GetSubjectAlternativeNames(EnrollmentProduc ? productInfo.ProductParameters["Domain Control Validation Method"] : null; + // CSC Global rejects the request if any subjectAlternativeNames entry is missing + // domainControlValidation, so every SAN below must resolve to a non-null value - falling + // back to the primary CN's DCV email when no per-domain override matches. + var commonNameValidationEmail = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.CnDcvEmail) + ? productInfo.ProductParameters[EnrollmentConfigConstants.CnDcvEmail] + : null; + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", dnsNames.Length, methodType ?? "(null)"); @@ -579,18 +555,19 @@ private List GetSubjectAlternativeNames(EnrollmentProduc if (!string.IsNullOrEmpty(methodType) && methodType.ToUpper() == "EMAIL") { - var emailsRaw = productInfo.ProductParameters.ContainsKey("Addtl Sans Comma Separated DVC Emails") - ? productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"] + var emailsRaw = productInfo.ProductParameters.ContainsKey(EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails) + ? productInfo.ProductParameters[EnrollmentConfigConstants.AdditionalSansCommaSeparatedDcvEmails] : null; var emailAddresses = !string.IsNullOrEmpty(emailsRaw) ? emailsRaw.Split(',') : Array.Empty(); Logger.LogTrace("GetSubjectAlternativeNames: EMAIL validation, {Count} email addresses for domain='{Domain}'", emailAddresses.Length, domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName); + san.DomainControlValidation = GetDomainControlValidation(methodType, emailAddresses, domainName) + ?? GetDomainControlValidation(methodType, commonNameValidationEmail); } else { Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); - san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + san.DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail); } subjectNameList.Add(san); diff --git a/integration-manifest.json b/integration-manifest.json index e58e50f..0cd5293 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -94,6 +94,16 @@ } ], "product_ids": [ + "CSC TrustedSecure OV", + "CSC TrustedSecure OV Wildcard", + "CSC TrustedSecure OV, Multiple Names", + "CSC TrustedSecure EV", + "CSC TrustedSecure DV", + "CSC TrustedSecure DV Wildcard", + "CSC TrustedSecure DV, Multiple Names", + "CSC TrustedSecure EV, Multiple Names", + "CSC TrustedSecure OV Wildcard, Multiple Names", + "CSC TrustedSecure DV Wildcard, Multiple Names", "CSC TrustedSecure Premium Certificate", "CSC TrustedSecure EV Certificate", "CSC TrustedSecure UC Certificate", @@ -104,4 +114,4 @@ ] } } -} \ No newline at end of file +}