diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c64f0fd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)", + "Bash(git checkout:*)" + ] + } +} diff --git a/README.md b/README.md index b7fb319..f5e93ae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- CSCGlobal CA Gateway AnyCA Gateway REST Plugin + CSCGlobal CAPlugin AnyCA Gateway REST Plugin

@@ -38,10 +38,10 @@ This integration allows for the Synchronization, Enrollment, and Revocation of c ## Compatibility -The CSCGlobal CA Gateway AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is compatible with the Keyfactor AnyCA Gateway REST 24.2.0 and later. ## Support -The CSCGlobal CA Gateway AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. +The CSCGlobal CAPlugin AnyCA Gateway REST plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket with your Keyfactor representative. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. > To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. @@ -53,7 +53,7 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and 1. Install the AnyCA Gateway REST per the [official Keyfactor documentation](https://software.keyfactor.com/Guides/AnyCAGatewayREST/Content/AnyCAGatewayREST/InstallIntroduction.htm). -2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CSCGlobal CA Gateway AnyCA Gateway REST plugin](https://github.com/Keyfactor/cscglobal-caplugin/releases/latest) from GitHub. +2. On the server hosting the AnyCA Gateway REST, download and unzip the latest [CSCGlobal CAPlugin AnyCA Gateway REST plugin](https://github.com/Keyfactor/cscglobal-caplugin/releases/latest) from GitHub. 3. Copy the unzipped directory (usually called `net6.0` or `net8.0`) to the Extensions directory: @@ -64,11 +64,11 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and Program Files\Keyfactor\AnyCA Gateway\AnyGatewayREST\net8.0\Extensions ``` - > The directory containing the CSCGlobal CA Gateway AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. + > The directory containing the CSCGlobal CAPlugin AnyCA Gateway REST plugin DLLs (`net6.0` or `net8.0`) can be named anything, as long as it is unique within the `Extensions` directory. 4. Restart the AnyCA Gateway REST service. -5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the CSCGlobal CA Gateway plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. +5. Navigate to the AnyCA Gateway REST portal and verify that the Gateway recognizes the CSCGlobal CAPlugin plugin by hovering over the ⓘ symbol to the right of the Gateway on the top left of the portal. ## Configuration @@ -86,8 +86,8 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **ApiKey** - CSCGlobal API Key * **BearerToken** - CSCGlobal Bearer Token * **DefaultPageSize** - Default page size for use with the API. Default is 100 - * **TemplateSync** - Enable template sync. * **SyncFilterDays** - Number of days from today to filter certificates by expiration date during incremental sync. + * **RenewalWindowDays** - Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30. 2. PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION @@ -311,6 +311,55 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and * **Addtl Sans Comma Separated DCV Emails** - OPTIONAL: Additional SANs DCV Emails, comma separated +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + ## License diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.cs b/cscglobal-caplugin/CSCGlobalCAPlugin.cs index e1af2f0..5449f64 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.cs +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.cs @@ -35,68 +35,183 @@ public CSCGlobalCAPlugin() private ICscGlobalClient CscGlobalClient { get; set; } - public bool EnableTemplateSync { get; set; } - public int SyncFilterDays { get; set; } + public int RenewalWindowDays { get; set; } + //done public void Initialize(IAnyCAPluginConfigProvider configProvider, ICertificateDataReader certificateDataReader) { + using var flow = new FlowLogger(Logger, "Initialize"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Initialize called. configProvider is {Null}, certificateDataReader is {Null2}", + configProvider == null ? "NULL" : "present", + certificateDataReader == null ? "NULL" : "present"); + + flow.Step("ValidateInputs", () => + { + if (configProvider == null) + throw new ArgumentNullException(nameof(configProvider), "configProvider cannot be null in Initialize"); + if (certificateDataReader == null) + throw new ArgumentNullException(nameof(certificateDataReader), "certificateDataReader cannot be null in Initialize"); + }); + _certificateDataReader = certificateDataReader; - CscGlobalClient = new CscGlobalClient(configProvider); - var templateSync = configProvider.CAConnectionData["TemplateSync"].ToString(); - if (templateSync.ToUpper() == "ON") EnableTemplateSync = true; - if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + flow.Step("CreateCscGlobalClient", () => + { + Logger.LogTrace("Creating CscGlobalClient from configProvider..."); + CscGlobalClient = new CscGlobalClient(configProvider); + Logger.LogTrace("CscGlobalClient created successfully."); + }); + + flow.Step("ValidateConnectionData", () => { - var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); - if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + if (configProvider.CAConnectionData == null) { - SyncFilterDays = syncFilterDays; - Logger.LogDebug($"SyncFilterDays configured to {SyncFilterDays} days"); + Logger.LogError("CAConnectionData is null. Cannot read configuration."); + throw new InvalidOperationException("CAConnectionData is null on configProvider."); } - } + Logger.LogTrace("CAConnectionData keys: {Keys}", string.Join(", ", configProvider.CAConnectionData.Keys)); + }); + + flow.Step("ReadSyncFilterDays", () => + { + if (configProvider.CAConnectionData.ContainsKey(Constants.SyncFilterDays)) + { + var syncFilterDaysStr = configProvider.CAConnectionData[Constants.SyncFilterDays]?.ToString(); + Logger.LogTrace("SyncFilterDays raw value: '{Value}'", syncFilterDaysStr ?? "(null)"); + if (int.TryParse(syncFilterDaysStr, out var syncFilterDays)) + { + SyncFilterDays = syncFilterDays; + Logger.LogDebug("SyncFilterDays configured to {Days} days", SyncFilterDays); + } + else + { + Logger.LogWarning("SyncFilterDays value '{Value}' could not be parsed as int, using default 0.", syncFilterDaysStr); + } + } + else + { + Logger.LogTrace("SyncFilterDays key not found in CAConnectionData, using default 0."); + } + }); + + flow.Step("ReadRenewalWindowDays", () => + { + RenewalWindowDays = 30; // default + if (configProvider.CAConnectionData.TryGetValue(Constants.RenewalWindowDays, out var renewalWindowObj)) + { + Logger.LogTrace("RenewalWindowDays raw value: '{Value}'", renewalWindowObj?.ToString() ?? "(null)"); + if (int.TryParse(renewalWindowObj?.ToString(), out var renewalWindowDays) && renewalWindowDays > 0) + RenewalWindowDays = renewalWindowDays; + else + Logger.LogWarning("RenewalWindowDays value '{Value}' could not be parsed or was <= 0, using default 30.", renewalWindowObj); + } + else + { + Logger.LogTrace("RenewalWindowDays key not found in CAConnectionData, using default 30."); + } + Logger.LogDebug("RenewalWindowDays configured to {Days} days", RenewalWindowDays); + }, $"RenewalWindowDays={RenewalWindowDays}"); + Logger.MethodExit(LogLevel.Debug); } //done public async Task GetSingleRecord(string caRequestID) { + using var flow = new FlowLogger(Logger, $"GetSingleRecord({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("GetSingleRecord called with caRequestID='{CaRequestId}'", caRequestID ?? "(null)"); + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID (need at least 36 chars).", nameof(caRequestID)); + }); + try { - Logger.MethodEntry(LogLevel.Debug); - var keyfactorCaId = caRequestID?.Substring(0, 36); //todo fix to use pipe delimiter - Logger.LogTrace($"Keyfactor Ca Id: {keyfactorCaId}"); - var certificateResponse = - Task.Run(async () => await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId)) - .Result; + var keyfactorCaId = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"keyfactorCaId={keyfactorCaId}"); - Logger.LogTrace($"Single Cert JSON: {JsonConvert.SerializeObject(certificateResponse)}"); + CertificateResponse certificateResponse = null; + await flow.StepAsync("FetchCertFromCSC", async () => + { + certificateResponse = await CscGlobalClient.SubmitGetCertificateAsync(keyfactorCaId); + }); - var fileContent = - Encoding.ASCII.GetString( - Convert.FromBase64String(certificateResponse?.Certificate ?? string.Empty)); + if (certificateResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + Logger.LogWarning("GetSingleRecord: SubmitGetCertificateAsync returned null for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + return new AnyCAPluginCertificate + { + CARequestID = keyfactorCaId, + Certificate = string.Empty, + Status = _requestManager.MapReturnStatus(null) + }; + } + + flow.Step("ParseResponse", $"Status={certificateResponse.Status ?? "(null)"}"); + Logger.LogTrace("Single Cert JSON: {Json}", JsonConvert.SerializeObject(certificateResponse)); + + var rawCert = certificateResponse.Certificate ?? string.Empty; + string fileContent = string.Empty; + flow.Step("DecodeBase64", () => + { + try + { + fileContent = Encoding.ASCII.GetString(Convert.FromBase64String(rawCert)); + } + catch (FormatException fex) + { + Logger.LogError(fex, "GetSingleRecord: Failed to decode Base64 certificate content for keyfactorCaId='{KeyfactorCaId}'", keyfactorCaId); + fileContent = string.Empty; + } + }, $"length={rawCert.Length}"); - Logger.LogTrace($"File Content {fileContent}"); - var certData = fileContent?.Replace("\r\n", string.Empty); + var certData = fileContent.Replace("\r\n", string.Empty); var certString = string.Empty; if (!string.IsNullOrEmpty(certData)) - certString = GetEndEntityCertificate(certData); - Logger.LogTrace($"Cert String Content {certString}"); + { + flow.Step("ExtractLeafCert", () => + { + certString = GetEndEntityCertificate(certData); + }, $"inputLength={certData.Length}"); + } + else + { + flow.Skip("ExtractLeafCert", "certData empty after cleanup"); + } + + var mappedStatus = _requestManager.MapReturnStatus(certificateResponse.Status); + flow.Step("MapStatus", $"{certificateResponse.Status ?? "(null)"} -> {mappedStatus}"); Logger.MethodExit(LogLevel.Debug); return new AnyCAPluginCertificate { CARequestID = keyfactorCaId, - Certificate = certString, - Status = _requestManager.MapReturnStatus(certificateResponse?.Status) + Certificate = certString ?? string.Empty, + Status = mappedStatus }; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "GetSingleRecord: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {inner?.Message ?? ae.Message}", inner ?? ae); + } catch (Exception e) { - throw new Exception($"Error Occurred getting single cert {e.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "GetSingleRecord: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Error Occurred getting single cert for '{caRequestID}': {e.Message}", e); } } @@ -104,31 +219,56 @@ public async Task GetSingleRecord(string caRequestID) public async Task Synchronize(BlockingCollection blockingBuffer, DateTime? lastSync, bool fullSync, CancellationToken cancelToken) { - Logger.LogTrace($"Full Sync? {fullSync.ToString()}"); + var syncType = fullSync ? "Full" : "Incremental"; + using var flow = new FlowLogger(Logger, $"Synchronize-{syncType}"); Logger.MethodEntry(); + Logger.LogTrace("Synchronize called. fullSync={FullSync}, lastSync={LastSync}, blockingBuffer is {Null}", + fullSync, lastSync?.ToString("o") ?? "(null)", + blockingBuffer == null ? "NULL" : "present"); + + if (blockingBuffer == null) + throw new ArgumentNullException(nameof(blockingBuffer), "blockingBuffer cannot be null in Synchronize"); + try { if (fullSync) { - Logger.LogDebug("Performing full sync - no date filter applied"); - await SyncCertificates(blockingBuffer, cancelToken, null); + flow.Step("DetermineFilter", "Full sync - no date filter"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, null); + }); } else { var filterDays = SyncFilterDays > 0 ? SyncFilterDays : 5; var filterDate = DateTime.Today.Subtract(TimeSpan.FromDays(filterDays)); var dateFilter = filterDate.ToString("yyyy/MM/dd"); - Logger.LogDebug($"Performing incremental sync with expiration date filter: {dateFilter}"); - await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + flow.Step("DetermineFilter", $"Incremental, filterDays={filterDays}, cutoff={dateFilter}"); + await flow.StepAsync("FetchAndProcessCerts", async () => + { + await SyncCertificates(blockingBuffer, cancelToken, dateFilter); + }); } + flow.Step("CompleteAdding"); blockingBuffer.CompleteAdding(); } + catch (OperationCanceledException) + { + flow.Fail("Cancelled", "operation was cancelled"); + Logger.LogWarning("Synchronize: operation was cancelled."); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); + throw; + } catch (Exception e) { - Logger.LogError($"Csc Global Synchronize Task failed! {LogHandler.FlattenException(e)}"); + flow.Fail("SyncError", e.Message); + Logger.LogError(e, "Csc Global Synchronize Task failed! {FlatException}", LogHandler.FlattenException(e)); + if (!blockingBuffer.IsAddingCompleted) + blockingBuffer.CompleteAdding(); Logger.MethodExit(); - blockingBuffer.CompleteAdding(); throw; } @@ -138,70 +278,182 @@ public async Task Synchronize(BlockingCollection blockin private async Task SyncCertificates(BlockingCollection blockingBuffer, CancellationToken cancelToken, string? dateFilter) { + Logger.LogTrace("SyncCertificates: calling SubmitCertificateListRequestAsync with dateFilter='{DateFilter}'", dateFilter ?? "(null)"); var certs = await CscGlobalClient.SubmitCertificateListRequestAsync(dateFilter); + if (certs == null) + { + Logger.LogWarning("SyncCertificates: SubmitCertificateListRequestAsync returned null."); + return; + } + + if (certs.Results == null) + { + Logger.LogWarning("SyncCertificates: certificate list response Results collection is null."); + return; + } + + Logger.LogTrace("SyncCertificates: received {Count} certificate results.", certs.Results.Count); + var processedCount = 0; + var skippedCount = 0; + foreach (var currentResponseItem in certs.Results) { cancelToken.ThrowIfCancellationRequested(); - Logger.LogTrace($"Took Certificate ID {currentResponseItem?.Uuid} from Queue"); - var certStatus = _requestManager.MapReturnStatus(currentResponseItem?.Status); - //Keyfactor sync only seems to work when there is a valid cert and I can only get Active valid certs from Csc Global + if (currentResponseItem == null) + { + Logger.LogTrace("SyncCertificates: skipping null result item."); + skippedCount++; + continue; + } + + Logger.LogTrace("SyncCertificates: processing certificate UUID={Uuid}, Status='{Status}', CertificateType='{CertType}'", + currentResponseItem.Uuid ?? "(null)", + currentResponseItem.Status ?? "(null)", + currentResponseItem.CertificateType ?? "(null)"); + + var certStatus = _requestManager.MapReturnStatus(currentResponseItem.Status); + Logger.LogTrace("SyncCertificates: mapped status for UUID={Uuid}: {MappedStatus}", currentResponseItem.Uuid ?? "(null)", certStatus); + if (certStatus == Convert.ToInt32(EndEntityStatus.GENERATED) || certStatus == Convert.ToInt32(EndEntityStatus.REVOKED)) { - //One click renewal/reissue won't work for this implementation so there is an option to disable it by not syncing back template - var productId = "CscGlobal"; - if (EnableTemplateSync) productId = currentResponseItem?.CertificateType; + var productId = _requestManager.MapCertificateTypeToProductId(currentResponseItem.CertificateType); - var fileContent = - PreparePemTextFromApi( - currentResponseItem?.Certificate ?? string.Empty); + Logger.LogTrace("SyncCertificates: UUID={Uuid} qualifies for sync. CertificateType='{CertType}' -> ProductId='{ProductId}'", + currentResponseItem.Uuid, currentResponseItem.CertificateType ?? "(null)", productId); + + string fileContent; + try + { + fileContent = PreparePemTextFromApi(currentResponseItem.Certificate ?? string.Empty); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: PreparePemTextFromApi failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } if (fileContent.Length > 0) { - Logger.LogTrace($"File Content {fileContent}"); + Logger.LogTrace("SyncCertificates: fileContent length={Length} for UUID={Uuid}", fileContent.Length, currentResponseItem.Uuid); var certData = fileContent.Replace("\r\n", string.Empty); - var certString = GetEndEntityCertificate(certData); - if (certString.Length > 0) + string certString; + try + { + certString = GetEndEntityCertificate(certData); + } + catch (Exception ex) + { + Logger.LogError(ex, "SyncCertificates: GetEndEntityCertificate failed for UUID={Uuid}", currentResponseItem.Uuid); + skippedCount++; + continue; + } + + if (!string.IsNullOrEmpty(certString)) + { blockingBuffer.Add(new AnyCAPluginCertificate { - CARequestID = $"{currentResponseItem?.Uuid}", + CARequestID = $"{currentResponseItem.Uuid}", Certificate = certString, Status = certStatus, ProductID = productId }, cancelToken); + processedCount++; + Logger.LogTrace("SyncCertificates: added UUID={Uuid} to buffer.", currentResponseItem.Uuid); + } + else + { + Logger.LogTrace("SyncCertificates: certString 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++; } } + + Logger.LogDebug("SyncCertificates: completed. Processed={Processed}, Skipped={Skipped}, Total={Total}", + processedCount, skippedCount, certs.Results.Count); } //done public async Task Revoke(string caRequestID, string hexSerialNumber, uint revocationReason) { + using var flow = new FlowLogger(Logger, $"Revoke({caRequestID ?? "null"})"); + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Revoke called with caRequestID='{CaRequestId}', hexSerialNumber='{SerialNumber}', revocationReason={Reason}", + caRequestID ?? "(null)", hexSerialNumber ?? "(null)", revocationReason); + + flow.Step("ValidateInput", () => + { + if (string.IsNullOrEmpty(caRequestID)) + throw new ArgumentNullException(nameof(caRequestID), "caRequestID cannot be null or empty for Revoke."); + if (caRequestID.Length < 36) + throw new ArgumentException($"caRequestID '{caRequestID}' is too short to extract a UUID.", nameof(caRequestID)); + }); + try { - Logger.LogTrace("Staring Revoke Method"); - var revokeResponse = - Task.Run(async () => - await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36))).Result - ; //todo fix to use pipe delimiter + var uuid = caRequestID.Substring(0, 36); + flow.Step("ExtractUUID", $"uuid={uuid}"); - Logger.LogTrace($"Revoke Response JSON: {JsonConvert.SerializeObject(revokeResponse)}"); - Logger.MethodExit(LogLevel.Debug); + RevokeResponse revokeResponse = null; + await flow.StepAsync("SubmitRevokeToCSC", async () => + { + revokeResponse = await CscGlobalClient.SubmitRevokeCertificateAsync(uuid); + }); + + if (revokeResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + throw new InvalidOperationException($"Revoke received null response for UUID '{uuid}'."); + } + + Logger.LogTrace("Revoke Response JSON: {Json}", JsonConvert.SerializeObject(revokeResponse)); var revokeResult = _requestManager.GetRevokeResult(revokeResponse); + flow.Step("MapResult", $"result={revokeResult}"); if (revokeResult == (int)EndEntityStatus.FAILED) - if (!string.IsNullOrEmpty(revokeResponse?.RegistrationError?.Description)) - throw new HttpRequestException( - $"Revoke Failed with message {revokeResponse?.RegistrationError?.Description}"); + { + var errorDesc = revokeResponse.RegistrationError?.Description; + flow.Fail("RevokeResult", errorDesc ?? "(no description)"); + Logger.LogError("Revoke: failed for UUID='{Uuid}'. Error description: '{ErrorDesc}'", + uuid, errorDesc ?? "(no description)"); + if (!string.IsNullOrEmpty(errorDesc)) + throw new HttpRequestException($"Revoke Failed with message {errorDesc}"); + } + Logger.MethodExit(LogLevel.Debug); return revokeResult; } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + Logger.LogError(inner, "Revoke: AggregateException for caRequestID='{CaRequestId}': {Message}", caRequestID, inner?.Message ?? ae.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {inner?.Message ?? ae.Message}", inner ?? ae); + } + catch (HttpRequestException) + { + throw; // already logged in flow above + } catch (Exception e) { - throw new Exception($"Revoke Failed with message {e?.Message}"); + flow.Fail("UNHANDLED", e.Message); + Logger.LogError(e, "Revoke: Exception for caRequestID='{CaRequestId}': {Message}", caRequestID, e.Message); + throw new Exception($"Revoke Failed for '{caRequestID}' with message {e.Message}", e); } } @@ -209,128 +461,382 @@ await CscGlobalClient.SubmitRevokeCertificateAsync(caRequestID.Substring(0, 36)) public async Task Enroll(string csr, string subject, Dictionary san, EnrollmentProductInfo productInfo, RequestFormat requestFormat, EnrollmentType enrollmentType) { + using var flow = new FlowLogger(Logger, $"Enroll-{enrollmentType}"); Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("Enroll called. enrollmentType={EnrollmentType}, subject='{Subject}', productId='{ProductId}', requestFormat={RequestFormat}", + enrollmentType, subject ?? "(null)", + productInfo?.ProductID ?? "(null)", requestFormat); + Logger.LogTrace("Enroll: csr is {CsrStatus}, san has {SanCount} entries, productInfo is {PiStatus}", + string.IsNullOrEmpty(csr) ? "empty/null" : $"present ({csr.Length} chars)", + san?.Count ?? 0, + productInfo == null ? "NULL" : "present"); + + flow.Step("ValidateInputs", () => + { + if (productInfo == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null for Enroll."); + if (productInfo.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo.ProductParameters cannot be null for Enroll."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty for Enroll."); + }); + + Logger.LogTrace("Enroll: ProductParameters keys: [{Keys}]", + string.Join(", ", productInfo.ProductParameters.Keys)); RegistrationRequest enrollmentRequest; var priorSn = ""; ReissueRequest reissueRequest; RenewalRequest renewRequest; - if (productInfo.ProductParameters.ContainsKey("priorcertsn")) - { - priorSn = productInfo.ProductParameters["PriorCertSN"]; - Logger.LogDebug($"Prior cert sn: {priorSn}"); - } - - string uUId; - var customFields = await CscGlobalClient.SubmitGetCustomFields(); - switch (enrollmentType) + flow.Step("CheckPriorCertSN", () => { - case EnrollmentType.New: - Logger.LogTrace("Entering New Enrollment"); - //If they renewed an expired cert it gets here and this will not be supported - IRegistrationResponse enrollmentResponse; - if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) + if (productInfo.ProductParameters.ContainsKey("priorcertsn")) + { + if (productInfo.ProductParameters.ContainsKey("PriorCertSN")) { - enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); - Logger.LogTrace($"Enrollment Request JSON: {JsonConvert.SerializeObject(enrollmentRequest)}"); - enrollmentResponse = - Task.Run(async () => await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest)) - .Result; - Logger.LogTrace($"Enrollment Response JSON: {JsonConvert.SerializeObject(enrollmentResponse)}"); + priorSn = productInfo.ProductParameters["PriorCertSN"]; + Logger.LogDebug("Enroll: Prior cert SN: '{PriorSn}'", priorSn ?? "(null)"); } else { - return new EnrollmentResult - { - Status = 30, //failure - StatusMessage = "You cannot renew an expired cert please perform an new enrollment." - }; + Logger.LogWarning("Enroll: 'priorcertsn' key exists but 'PriorCertSN' (case-sensitive) not found."); } + } + }, string.IsNullOrEmpty(priorSn) ? "none" : $"SN={priorSn}"); - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetEnrollmentResult(enrollmentResponse); - case EnrollmentType.RenewOrReissue: - Logger.LogTrace("Entering Renew Enrollment"); - //Logic to determine renew vs reissue - var renewal = false; - var order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); - var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id); - if (expirationDate == null) - { - var localcert = await GetSingleRecord(order_id); - expirationDate = localcert.RevocationDate; - } + string uUId; + List customFields = null; + await flow.StepAsync("FetchCustomFields", async () => + { + customFields = await CscGlobalClient.SubmitGetCustomFields(); + }, $"count={customFields?.Count ?? 0}"); - if (expirationDate < DateTime.Now) renewal = true; - if (renewal) - { - //One click won't work for this implementation b/c we are missing enrollment params + if (customFields == null) + { + Logger.LogWarning("Enroll: SubmitGetCustomFields returned null, using empty list."); + customFields = new List(); + } + + try + { + switch (enrollmentType) + { + case EnrollmentType.New: + flow.Step("SelectPath", "New Enrollment"); + IRegistrationResponse enrollmentResponse; + if (!productInfo.ProductParameters.ContainsKey("PriorCertSN")) + { + enrollmentRequest = null; + flow.Step("BuildRegistrationRequest", () => + { + enrollmentRequest = _requestManager.GetRegistrationRequest(productInfo, csr, san, customFields); + }); + Logger.LogTrace("Enrollment Request JSON: {Json}", JsonConvert.SerializeObject(enrollmentRequest)); + + RegistrationResponse regResponse = null; + await flow.StepAsync("SubmitRegistrationToCSC", async () => + { + regResponse = await CscGlobalClient.SubmitRegistrationAsync(enrollmentRequest); + }); + enrollmentResponse = regResponse; + + if (enrollmentResponse == null) + { + flow.Fail("ParseResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Enrollment failed: CSC API returned a null response." + }; + } + flow.Step("ParseResponse", $"error={enrollmentResponse.RegistrationError != null}"); + Logger.LogTrace("Enrollment Response JSON: {Json}", JsonConvert.SerializeObject(enrollmentResponse)); + } + else + { + 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." + }; + } + + var enrollResult = _requestManager.GetEnrollmentResult(enrollmentResponse); + flow.Step("MapResult", $"Status={enrollResult?.Status}, ID={enrollResult?.CARequestID ?? "(null)"}"); + Logger.MethodExit(LogLevel.Debug); + return enrollResult; + + case EnrollmentType.RenewOrReissue: + flow.Step("SelectPath", "RenewOrReissue"); + + if (string.IsNullOrEmpty(priorSn)) + { + flow.Fail("ValidatePriorSN", "PriorCertSN is empty"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "RenewOrReissue failed: PriorCertSN is required but was not provided." + }; + } + + string order_id = null; + await flow.StepAsync("LookupOrderId", async () => + { + order_id = await _certificateDataReader.GetRequestIDBySerialNumber(priorSn); + }, $"orderId={order_id ?? "(null)"}"); + + if (string.IsNullOrEmpty(order_id)) + { + 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}'." + }; + } + + if (order_id.Length < 36) + { + 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." + }; + } + flow.Step("ValidateOrderId", $"orderId={order_id}"); + + // Determine renew vs reissue based on order expiry window. + var renewal = false; + try + { + CertificateResponse liveCert = null; + await flow.StepAsync("FetchLiveCertForDecision", async () => + { + liveCert = await CscGlobalClient.SubmitGetCertificateAsync(order_id[..36]); + }); + + if (liveCert != null && DateTime.TryParse(liveCert.OrderDate, out var orderDate)) + { + var orderExpiry = orderDate.AddYears(1); + var daysUntilOrderExpiry = (orderExpiry - DateTime.UtcNow).TotalDays; + renewal = daysUntilOrderExpiry <= RenewalWindowDays; + flow.Step("ComputeRenewalDecision", + $"orderDate={liveCert.OrderDate}, expiry={orderExpiry:dd-MMM-yyyy}, daysLeft={(int)daysUntilOrderExpiry}, window={RenewalWindowDays}, isRenewal={renewal}"); + } + else + { + flow.Skip("ComputeRenewalDecision", "orderDate unavailable, falling back to cert expiry"); + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"expirationDate={expirationDate?.ToString("o") ?? "(null)"}, isRenewal={renewal}"); + } + } + catch (Exception ex) + { + flow.Fail("FetchLiveCertForDecision", $"falling back: {ex.Message}"); + Logger.LogWarning(ex, "RenewOrReissue: failed to fetch live cert, falling back to cert expiry."); + try + { + var expirationDate = _certificateDataReader.GetExpirationDateByRequestId(order_id) + ?? (await GetSingleRecord(order_id)).RevocationDate; + renewal = expirationDate < DateTime.Now; + flow.Step("FallbackExpiryCheck", $"isRenewal={renewal}"); + } + catch (Exception fallbackEx) + { + flow.Fail("FallbackExpiryCheck", fallbackEx.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"RenewOrReissue failed: unable to determine renewal status for order '{order_id}'. {fallbackEx.Message}" + }; + } + } + + flow.Step("RenewalDecision", renewal ? "RENEWAL (paid order)" : "REISSUE (free under active order)"); + + if (renewal) + { + if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) + { + uUId = null; + await flow.StepAsync("LookupRenewalUUID", async () => + { + uUId = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(uUId)) + { + 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." + }; + } + flow.Step("ValidateRenewalUUID", $"uuid={uUId}"); + + RenewalRequest builtRenewRequest = null; + flow.Step("BuildRenewalRequest", () => + { + builtRenewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); + }); + renewRequest = builtRenewRequest; + Logger.LogTrace("Renewal Request JSON: {Json}", JsonConvert.SerializeObject(renewRequest)); + + RenewalResponse renewResponse = null; + await flow.StepAsync("SubmitRenewalToCSC", async () => + { + renewResponse = await CscGlobalClient.SubmitRenewalAsync(renewRequest); + }); + + if (renewResponse == null) + { + flow.Fail("ParseRenewalResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Renewal failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Renewal Response JSON: {Json}", JsonConvert.SerializeObject(renewResponse)); + var renewResult = _requestManager.GetRenewResponse(renewResponse); + flow.Step("MapRenewalResult", $"Status={renewResult?.Status}, Message={renewResult?.StatusMessage ?? "(null)"}"); + Logger.MethodExit(LogLevel.Debug); + return renewResult; + } + + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click renew unavailable"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = + "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." + }; + } + + // Reissue path if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) { - //priorCert = _certificateDataReader.get( - //DataConversion.HexToBytes(productInfo.ProductParameters["PriorCertSN"])); - //uUId = priorCert.CARequestID.Substring(0, 36); //uUId is a GUID - uUId = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - Logger.LogTrace($"Renew uUId: {uUId}"); - renewRequest = _requestManager.GetRenewalRequest(productInfo, uUId, csr, san, customFields); - Logger.LogTrace($"Renewal Request JSON: {JsonConvert.SerializeObject(renewRequest)}"); - var renewResponse = Task.Run(async () => await CscGlobalClient.SubmitRenewalAsync(renewRequest)) - .Result; - Logger.LogTrace($"Renewal Response JSON: {JsonConvert.SerializeObject(renewResponse)}"); + string requestid = null; + await flow.StepAsync("LookupReissueRequestId", async () => + { + requestid = await _certificateDataReader.GetRequestIDBySerialNumber( + productInfo.ProductParameters["PriorCertSN"]); + }); + + if (string.IsNullOrEmpty(requestid)) + { + 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." + }; + } + + if (requestid.Length < 36) + { + 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." + }; + } + + uUId = requestid.Substring(0, 36); + flow.Step("ExtractReissueUUID", $"uuid={uUId}"); + + ReissueRequest builtReissueRequest = null; + flow.Step("BuildReissueRequest", () => + { + builtReissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); + }); + reissueRequest = builtReissueRequest; + Logger.LogTrace("Reissue JSON: {Json}", JsonConvert.SerializeObject(reissueRequest)); + + ReissueResponse reissueResponse = null; + await flow.StepAsync("SubmitReissueToCSC", async () => + { + reissueResponse = await CscGlobalClient.SubmitReissueAsync(reissueRequest); + }); + + if (reissueResponse == null) + { + flow.Fail("ParseReissueResponse", "API returned null"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = "Reissue failed: CSC API returned a null response." + }; + } + + Logger.LogTrace("Reissue Response JSON: {Json}", JsonConvert.SerializeObject(reissueResponse)); + var reissueResult = _requestManager.GetReIssueResult(reissueResponse); + flow.Step("MapReissueResult", $"Status={reissueResult?.Status}, Message={reissueResult?.StatusMessage ?? "(null)"}"); Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetRenewResponse(renewResponse); + return reissueResult; } + flow.Fail("MissingEnrollmentParams", "Applicant Last Name not present — one-click reissue unavailable"); return new EnrollmentResult { - Status = 30, //failure + Status = 30, StatusMessage = "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." }; - } - - Logger.LogTrace("Entering Reissue Enrollment"); - //One click won't work for this implementation b/c we are missing enrollment params - if (productInfo.ProductParameters.ContainsKey("Applicant Last Name")) - { - var requestid = await _certificateDataReader.GetRequestIDBySerialNumber( - productInfo.ProductParameters["PriorCertSN"]); - uUId = requestid.Substring(0, 36); //uUId is a GUID - Logger.LogTrace($"Reissue uUId: {uUId}"); - reissueRequest = _requestManager.GetReissueRequest(productInfo, uUId, csr, san, customFields); - Logger.LogTrace($"Reissue JSON: {JsonConvert.SerializeObject(reissueRequest)}"); - var reissueResponse = Task.Run(async () => await CscGlobalClient.SubmitReissueAsync(reissueRequest)) - .Result; - Logger.LogTrace($"Reissue Response JSON: {JsonConvert.SerializeObject(reissueResponse)}"); - Logger.MethodExit(LogLevel.Debug); - return _requestManager.GetReIssueResult(reissueResponse); - } - return new EnrollmentResult - { - Status = 30, //failure - StatusMessage = - "One click Renew Is Not Available for this Certificate Type. Use the configure button instead." - }; + default: + flow.Fail("UnhandledType", $"enrollmentType={enrollmentType}"); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enroll failed: unhandled enrollment type '{enrollmentType}'." + }; + } + } + catch (AggregateException ae) + { + var inner = ae.Flatten().InnerException; + flow.Fail("UNHANDLED", inner?.Message ?? ae.Message); + 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}" + }; + } + catch (Exception ex) + { + flow.Fail("UNHANDLED", ex.Message); + Logger.LogError(ex, "Enroll: unhandled exception during {EnrollmentType}: {Message}", enrollmentType, ex.Message); + return new EnrollmentResult + { + Status = 30, + StatusMessage = $"Enrollment failed with error: {ex.Message}" + }; } - - Logger.MethodExit(LogLevel.Debug); - return null; } //done public async Task Ping() { Logger.MethodEntry(); + Logger.LogTrace("Ping: CscGlobalClient is {Null}", CscGlobalClient == null ? "NULL" : "present"); try { Logger.LogInformation("Ping request received"); } catch (Exception e) { - Logger.LogError($"There was an error contacting CSCGlobal: {e.Message}."); + Logger.LogError(e, "There was an error contacting CSCGlobal: {Message}", e.Message); throw new Exception($"Error attempting to ping CSCGlobal: {e.Message}.", e); } @@ -340,19 +846,53 @@ public async Task Ping() //do public async Task ValidateCAConnectionInfo(Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateCAConnectionInfo called. connectionInfo is {Null}, keys=[{Keys}]", + connectionInfo == null ? "NULL" : "present", + connectionInfo != null ? string.Join(", ", connectionInfo.Keys) : ""); + + if (connectionInfo == null) + { + Logger.LogError("ValidateCAConnectionInfo: connectionInfo is null."); + throw new ArgumentNullException(nameof(connectionInfo), "connectionInfo cannot be null."); + } + + Logger.MethodExit(LogLevel.Debug); } //do public async Task ValidateProductInfo(EnrollmentProductInfo productInfo, Dictionary connectionInfo) { + Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("ValidateProductInfo called. productInfo is {Null}, productId='{ProductId}'", + productInfo == null ? "NULL" : "present", + productInfo?.ProductID ?? "(null)"); + + if (productInfo == null) + { + Logger.LogError("ValidateProductInfo: productInfo is null."); + throw new ArgumentNullException(nameof(productInfo), "productInfo cannot be null."); + } + + if (string.IsNullOrEmpty(productInfo.ProductID)) + { + Logger.LogError("ValidateProductInfo: productInfo.ProductID is null or empty."); + 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) throw new ArgumentException($"Cannot find {productInfo.ProductID}", "ProductId"); - - Logger.LogInformation($"Validated {certType} ({certType})configured for AnyGateway"); + if (certType == null) + { + 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.MethodExit(LogLevel.Debug); } //done @@ -388,19 +928,19 @@ public Dictionary GetCAConnectorAnnotations() DefaultValue = "100", Type = "String" }, - [Constants.TemplateSync] = new() - { - Comments = "Enable template sync.", - Hidden = false, - DefaultValue = "false", - Type = "Bool" - }, [Constants.SyncFilterDays] = new() { Comments = "Number of days from today to filter certificates by expiration date during incremental sync.", Hidden = false, DefaultValue = "5", Type = "Number" + }, + [Constants.RenewalWindowDays] = new() + { + Comments = "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30.", + Hidden = false, + DefaultValue = "30", + Type = "Number" } }; } diff --git a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj index 5118677..4d71ec5 100644 --- a/cscglobal-caplugin/CSCGlobalCAPlugin.csproj +++ b/cscglobal-caplugin/CSCGlobalCAPlugin.csproj @@ -3,7 +3,7 @@ true - net6.0;net8.0 + net6.0;net8.0;net10.0 Keyfactor.Extensions.CAPlugin.CSCGlobal true enable @@ -23,6 +23,12 @@ + + + + + + diff --git a/cscglobal-caplugin/Client/CscGlobalClient.cs b/cscglobal-caplugin/Client/CscGlobalClient.cs index 0a5c7c5..032f535 100644 --- a/cscglobal-caplugin/Client/CscGlobalClient.cs +++ b/cscglobal-caplugin/Client/CscGlobalClient.cs @@ -23,13 +23,58 @@ public sealed class CscGlobalClient : ICscGlobalClient public CscGlobalClient(IAnyCAPluginConfigProvider config) { - Logger = LogHandler.GetClassLogger(); + Logger = LogHandler.GetClassLogger(); + + if (config == null) + throw new ArgumentNullException(nameof(config), "config cannot be null in CscGlobalClient constructor."); + + if (config.CAConnectionData == null) + throw new InvalidOperationException("CAConnectionData is null on config provider."); + + Logger.LogTrace("CscGlobalClient: CAConnectionData keys=[{Keys}]", string.Join(", ", config.CAConnectionData.Keys)); + if (config.CAConnectionData.ContainsKey(Constants.CscGlobalApiKey)) { - BaseUrl = new Uri(config.CAConnectionData[Constants.CscGlobalUrl].ToString()); - ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey].ToString(); - Authorization = config.CAConnectionData[Constants.BearerToken].ToString(); + var rawUrl = config.CAConnectionData.ContainsKey(Constants.CscGlobalUrl) + ? config.CAConnectionData[Constants.CscGlobalUrl]?.ToString() + : null; + if (string.IsNullOrEmpty(rawUrl)) + { + Logger.LogError("CscGlobalClient: CscGlobalUrl is missing or empty in CAConnectionData."); + throw new InvalidOperationException("CscGlobalUrl is required but was not configured."); + } + + Logger.LogTrace("CscGlobalClient: BaseUrl='{BaseUrl}'", rawUrl); + BaseUrl = new Uri(rawUrl); + + ApiKey = config.CAConnectionData[Constants.CscGlobalApiKey]?.ToString(); + if (string.IsNullOrEmpty(ApiKey)) + { + Logger.LogError("CscGlobalClient: ApiKey is empty or null."); + throw new InvalidOperationException("ApiKey is required but was not configured."); + } + Logger.LogTrace("CscGlobalClient: ApiKey is present (length={Length}).", ApiKey.Length); + + if (!config.CAConnectionData.ContainsKey(Constants.BearerToken)) + { + Logger.LogError("CscGlobalClient: BearerToken key not found in CAConnectionData."); + throw new InvalidOperationException("BearerToken is required but was not configured."); + } + Authorization = config.CAConnectionData[Constants.BearerToken]?.ToString(); + if (string.IsNullOrEmpty(Authorization)) + { + Logger.LogError("CscGlobalClient: BearerToken is empty or null."); + throw new InvalidOperationException("BearerToken is required but was empty."); + } + Logger.LogTrace("CscGlobalClient: BearerToken is present (length={Length}).", Authorization.Length); + RestClient = ConfigureRestClient(); + Logger.LogTrace("CscGlobalClient: RestClient configured successfully."); + } + else + { + Logger.LogError("CscGlobalClient: ApiKey key '{Key}' not found in CAConnectionData. Client will not be functional.", Constants.CscGlobalApiKey); + throw new InvalidOperationException($"Required key '{Constants.CscGlobalApiKey}' not found in CAConnectionData."); } } @@ -41,25 +86,42 @@ public CscGlobalClient(IAnyCAPluginConfigProvider config) public async Task SubmitRegistrationAsync( RegistrationRequest registerRequest) { + Logger.LogTrace("SubmitRegistrationAsync: sending registration request..."); + if (registerRequest == null) + throw new ArgumentNullException(nameof(registerRequest)); + + var requestJson = JsonConvert.SerializeObject(registerRequest); + Logger.LogTrace("SubmitRegistrationAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/registration", new StringContent( - JsonConvert.SerializeObject(registerRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(registerRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRegistrationAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRegistrationAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRegistrationAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RegistrationResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var registrationResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRegistrationAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRegistrationAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var registrationResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRegistrationAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + registrationResponse?.Result == null ? "null" : "present", + registrationResponse?.RegistrationError == null ? "null" : "present"); return registrationResponse; } } @@ -67,31 +129,42 @@ public async Task SubmitRegistrationAsync( public async Task SubmitRenewalAsync( RenewalRequest renewalRequest) { + Logger.LogTrace("SubmitRenewalAsync: sending renewal request..."); + if (renewalRequest == null) + throw new ArgumentNullException(nameof(renewalRequest)); + + var requestJson = JsonConvert.SerializeObject(renewalRequest); + Logger.LogTrace("SubmitRenewalAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/renewal", new StringContent( - JsonConvert.SerializeObject(renewalRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(renewalRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRenewalAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRenewalAsync: response body: {Body}", rawBody ?? "(null)"); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response - { - var rawErrorResponse = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("Logging Error Response Raw"); - Logger.LogTrace(rawErrorResponse); - var errorResponse = - JsonConvert.DeserializeObject(rawErrorResponse, - settings); + if (resp.StatusCode == HttpStatusCode.BadRequest) + { + Logger.LogWarning("SubmitRenewalAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRenewalAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RenewalResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var rawRenewResponse = await resp.Content.ReadAsStringAsync(); - Logger.LogTrace("Logging Success Response Raw"); - Logger.LogTrace(rawRenewResponse); - var renewalResponse = - JsonConvert.DeserializeObject(rawRenewResponse); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRenewalAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitRenewalAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var renewalResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRenewalAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + renewalResponse?.Result == null ? "null" : "present", + renewalResponse?.RegistrationError == null ? "null" : "present"); return renewalResponse; } } @@ -99,69 +172,145 @@ public async Task SubmitRenewalAsync( public async Task SubmitReissueAsync( ReissueRequest reissueRequest) { + Logger.LogTrace("SubmitReissueAsync: sending reissue request..."); + if (reissueRequest == null) + throw new ArgumentNullException(nameof(reissueRequest)); + + var requestJson = JsonConvert.SerializeObject(reissueRequest); + Logger.LogTrace("SubmitReissueAsync: request JSON: {Json}", requestJson); + using (var resp = await RestClient.PostAsync("/dbs/api/v2/tls/reissue", new StringContent( - JsonConvert.SerializeObject(reissueRequest), Encoding.ASCII, "application/json"))) + requestJson, Encoding.ASCII, "application/json"))) { - Logger.LogTrace(JsonConvert.SerializeObject(reissueRequest)); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitReissueAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitReissueAsync: response body: {Body}", rawBody ?? "(null)"); var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitReissueAsync: received 400 BadRequest."); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitReissueAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new ReissueResponse(); response.RegistrationError = errorResponse; response.Result = null; return response; } - var reissueResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitReissueAsync: unexpected HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + throw new HttpRequestException($"SubmitReissueAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var reissueResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitReissueAsync: deserialized response. Result is {Null}, RegistrationError is {Null2}", + reissueResponse?.Result == null ? "null" : "present", + reissueResponse?.RegistrationError == null ? "null" : "present"); return reissueResponse; } } public async Task SubmitGetCertificateAsync(string certificateId) { + Logger.LogTrace("SubmitGetCertificateAsync: fetching certificate for id='{CertificateId}'", certificateId ?? "(null)"); + + if (string.IsNullOrEmpty(certificateId)) + throw new ArgumentNullException(nameof(certificateId), "certificateId cannot be null or empty."); + using (var resp = await RestClient.GetAsync($"/dbs/api/v2/tls/certificate/{certificateId}")) { - resp.EnsureSuccessStatusCode(); - var getCertificateResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCertificateAsync: HTTP {StatusCode} for certificateId='{CertificateId}': {Body}", + (int)resp.StatusCode, certificateId, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var getCertificateResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitGetCertificateAsync: deserialized. Status='{Status}', OrderDate='{OrderDate}', Certificate is {Null}", + getCertificateResponse?.Status ?? "(null)", + getCertificateResponse?.OrderDate ?? "(null)", + string.IsNullOrEmpty(getCertificateResponse?.Certificate) ? "empty/null" : "present"); return getCertificateResponse; } } public async Task> SubmitGetCustomFields() { + Logger.LogTrace("SubmitGetCustomFields: fetching custom fields..."); + using (var resp = await RestClient.GetAsync("/dbs/api/v2/admin/customfields")) { - resp.EnsureSuccessStatusCode(); - var getCustomFieldsResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitGetCustomFields: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitGetCustomFields: HTTP {StatusCode}: {Body}", (int)resp.StatusCode, rawBody); + resp.EnsureSuccessStatusCode(); // will throw + } + + Logger.LogTrace("SubmitGetCustomFields: response body: {Body}", rawBody ?? "(null)"); + var getCustomFieldsResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (getCustomFieldsResponse == null) + { + Logger.LogWarning("SubmitGetCustomFields: deserialized response is null, returning empty list."); + return new List(); + } + + if (getCustomFieldsResponse.CustomFields == null) + { + Logger.LogWarning("SubmitGetCustomFields: CustomFields property is null, returning empty list."); + return new List(); + } + + Logger.LogTrace("SubmitGetCustomFields: received {Count} custom fields.", getCustomFieldsResponse.CustomFields.Count); return getCustomFieldsResponse.CustomFields; } } public async Task SubmitRevokeCertificateAsync(string uuId) { + Logger.LogTrace("SubmitRevokeCertificateAsync: revoking certificate UUID='{Uuid}'", uuId ?? "(null)"); + + if (string.IsNullOrEmpty(uuId)) + throw new ArgumentNullException(nameof(uuId), "uuId cannot be null or empty."); + using (var resp = await RestClient.PutAsync($"/dbs/api/v2/tls/revoke/{uuId}", new StringContent(""))) { + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitRevokeCertificateAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); + Logger.LogTrace("SubmitRevokeCertificateAsync: response body: {Body}", rawBody ?? "(null)"); + var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; - if (resp.StatusCode == HttpStatusCode.BadRequest) //Csc Sends Errors back in 400 Json Response + if (resp.StatusCode == HttpStatusCode.BadRequest) { - var errorResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync(), - settings); + Logger.LogWarning("SubmitRevokeCertificateAsync: received 400 BadRequest for UUID='{Uuid}'.", uuId); + var errorResponse = JsonConvert.DeserializeObject(rawBody ?? "{}", settings); + Logger.LogTrace("SubmitRevokeCertificateAsync: error description='{Desc}'", errorResponse?.Description ?? "(null)"); var response = new RevokeResponse(); response.RegistrationError = errorResponse; response.RevokeSuccess = null; return response; } - var getRevokeResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + if (!resp.IsSuccessStatusCode) + { + Logger.LogError("SubmitRevokeCertificateAsync: unexpected HTTP {StatusCode} for UUID='{Uuid}': {Body}", (int)resp.StatusCode, uuId, rawBody); + throw new HttpRequestException($"SubmitRevokeCertificateAsync failed with HTTP {(int)resp.StatusCode}: {rawBody}"); + } + + var getRevokeResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + Logger.LogTrace("SubmitRevokeCertificateAsync: deserialized. RevokeSuccess is {Null}, RegistrationError is {Null2}", + getRevokeResponse?.RevokeSuccess == null ? "null" : "present", + getRevokeResponse?.RegistrationError == null ? "null" : "present"); return getRevokeResponse; } } @@ -169,23 +318,37 @@ public async Task SubmitRevokeCertificateAsync(string uuId) public async Task SubmitCertificateListRequestAsync(string? dateFilter = null) { Logger.MethodEntry(LogLevel.Debug); + Logger.LogTrace("SubmitCertificateListRequestAsync: dateFilter='{DateFilter}'", dateFilter ?? "(null)"); + var filterQuery = "filter=status=in=(ACTIVE,REVOKED)"; if (!string.IsNullOrEmpty(dateFilter)) { filterQuery += $";effectiveDate=ge={dateFilter}"; } - Logger.LogTrace($"Certificate list filter query: {filterQuery}"); + Logger.LogTrace("SubmitCertificateListRequestAsync: filter query: {FilterQuery}", filterQuery); + var resp = RestClient.GetAsync($"/dbs/api/v2/tls/certificate?{filterQuery}").Result; + var rawBody = await resp.Content.ReadAsStringAsync(); + Logger.LogTrace("SubmitCertificateListRequestAsync: HTTP {StatusCode}, body length={Length}", (int)resp.StatusCode, rawBody?.Length ?? 0); if (!resp.IsSuccessStatusCode) { - var responseMessage = resp.Content.ReadAsStringAsync().Result; Logger.LogError( - $"Failed Request to Keyfactor. Retrying request. Status Code {resp.StatusCode} | Message: {responseMessage}"); + "SubmitCertificateListRequestAsync: failed request. StatusCode={StatusCode}, Body={Body}", + (int)resp.StatusCode, rawBody); + } + + var certificateListResponse = JsonConvert.DeserializeObject(rawBody ?? "{}"); + + if (certificateListResponse == null) + { + Logger.LogWarning("SubmitCertificateListRequestAsync: deserialized response is null."); + return new CertificateListResponse(); } - var certificateListResponse = - JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + Logger.LogTrace("SubmitCertificateListRequestAsync: Results count={Count}", + certificateListResponse.Results?.Count ?? 0); + Logger.MethodExit(LogLevel.Debug); return certificateListResponse; } diff --git a/cscglobal-caplugin/Constants.cs b/cscglobal-caplugin/Constants.cs index 4d6b4da..d588706 100644 --- a/cscglobal-caplugin/Constants.cs +++ b/cscglobal-caplugin/Constants.cs @@ -13,8 +13,8 @@ public class Constants public static string CscGlobalApiKey = "ApiKey"; public static string BearerToken = "BearerToken"; public static string DefaultPageSize = "DefaultPageSize"; - public static string TemplateSync = "TemplateSync"; public static string SyncFilterDays = "SyncFilterDays"; + public static string RenewalWindowDays = "RenewalWindowDays"; } public class ProductIDs @@ -26,8 +26,8 @@ public class ProductIDs "CSC TrustedSecure UC Certificate", "CSC TrustedSecure Premium Wildcard Certificate", "CSC TrustedSecure Domain Validated SSL", - "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate" + "CSC Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" }; } diff --git a/cscglobal-caplugin/FlowLogger.cs b/cscglobal-caplugin/FlowLogger.cs new file mode 100644 index 0000000..5696fcd --- /dev/null +++ b/cscglobal-caplugin/FlowLogger.cs @@ -0,0 +1,241 @@ +// Copyright 2021 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.Diagnostics; +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; + +public enum FlowStepStatus +{ + Success, + Failed, + Skipped, + InProgress +} + +public class FlowStep +{ + public string Name { get; set; } + public FlowStepStatus Status { get; set; } + public string Detail { get; set; } + public long ElapsedMs { get; set; } + public List Children { get; } = new(); +} + +///

+/// Tracks high-level operation flow and renders a visual step diagram to Trace logs. +/// Usage: +/// using var flow = new FlowLogger(logger, "Enroll-New"); +/// flow.Step("ParseCSR"); +/// flow.Step("ValidateCSR", () => { ... }); +/// flow.Fail("CreateOrder", "API returned 400"); +/// // flow renders automatically on Dispose +/// +public sealed class FlowLogger : IDisposable +{ + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _totalTimer; + private readonly List _steps = new(); + private FlowStep _currentParent; + private bool _disposed; + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger; + _flowName = flowName; + _totalTimer = Stopwatch.StartNew(); + _logger.LogTrace("===== FLOW START: {FlowName} =====", _flowName); + } + + /// Record a completed step. + public FlowLogger Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Success, Detail = detail }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... OK{Detail}", + _flowName, name, detail != null ? $" ({detail})" : ""); + return this; + } + + /// Record a step that executes an action and times it. + public FlowLogger Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + 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 an async step that executes and times it. + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + _logger.LogTrace(" [{FlowName}] {StepName} ...", _flowName, name); + await action(); + sw.Stop(); + step.Status = FlowStepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + 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) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Failed, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... FAILED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Record a skipped step. + public FlowLogger Skip(string name, string reason = null) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.Skipped, Detail = reason }; + AddStep(step); + _logger.LogTrace(" [{FlowName}] {StepName} ... SKIPPED{Reason}", + _flowName, name, reason != null ? $": {reason}" : ""); + return this; + } + + /// Start a branch (group of child steps). + public FlowLogger Branch(string name) + { + var step = new FlowStep { Name = name, Status = FlowStepStatus.InProgress }; + AddStep(step); + _currentParent = step; + _logger.LogTrace(" [{FlowName}] >> Branch: {BranchName}", _flowName, name); + return this; + } + + /// End the current branch. + public FlowLogger EndBranch() + { + _currentParent = null; + return this; + } + + private void AddStep(FlowStep step) + { + if (_currentParent != null) + _currentParent.Children.Add(step); + else + _steps.Add(step); + } + + /// Render the visual flow diagram to Trace log. + private string RenderFlow() + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine($" ===== FLOW: {_flowName} ({_totalTimer.ElapsedMilliseconds}ms total) ====="); + sb.AppendLine(); + + for (var i = 0; i < _steps.Count; i++) + { + var step = _steps[i]; + var icon = GetStatusIcon(step.Status); + var elapsed = step.ElapsedMs > 0 ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.Detail) ? $" [{step.Detail}]" : ""; + + sb.AppendLine($" {icon} {step.Name}{elapsed}{detail}"); + + // Render children (branch) + if (step.Children.Count > 0) + { + for (var j = 0; j < step.Children.Count; j++) + { + var child = step.Children[j]; + var childIcon = GetStatusIcon(child.Status); + var childElapsed = child.ElapsedMs > 0 ? $" ({child.ElapsedMs}ms)" : ""; + var childDetail = !string.IsNullOrEmpty(child.Detail) ? $" [{child.Detail}]" : ""; + var connector = j < step.Children.Count - 1 ? "| " : " "; + sb.AppendLine($" |"); + sb.AppendLine($" +-- {childIcon} {child.Name}{childElapsed}{childDetail}"); + } + } + + // Connector between top-level steps + if (i < _steps.Count - 1) + { + sb.AppendLine(" |"); + sb.AppendLine(" v"); + } + } + + sb.AppendLine(); + + // Final status line + var finalStatus = _steps.Count > 0 && _steps.Last().Status == FlowStepStatus.Failed + ? "FAILED" : _steps.Any(s => s.Status == FlowStepStatus.Failed) ? "PARTIAL FAILURE" : "SUCCESS"; + sb.AppendLine($" ===== FLOW RESULT: {finalStatus} ====="); + + return sb.ToString(); + } + + private static string GetStatusIcon(FlowStepStatus status) + { + return status switch + { + FlowStepStatus.Success => "[OK]", + FlowStepStatus.Failed => "[FAIL]", + FlowStepStatus.Skipped => "[SKIP]", + FlowStepStatus.InProgress => "[...]", + _ => "[?]" + }; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _totalTimer.Stop(); + _logger.LogTrace(RenderFlow()); + } +} diff --git a/cscglobal-caplugin/RequestManager.cs b/cscglobal-caplugin/RequestManager.cs index aa19b1e..00e4de1 100644 --- a/cscglobal-caplugin/RequestManager.cs +++ b/cscglobal-caplugin/RequestManager.cs @@ -10,19 +10,48 @@ using Keyfactor.AnyGateway.Extensions; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Client.Models; using Keyfactor.Extensions.CAPlugin.CSCGlobal.Interfaces; +using Keyfactor.Logging; using Keyfactor.PKI.Enums.EJBCA; +using Microsoft.Extensions.Logging; namespace Keyfactor.Extensions.CAPlugin.CSCGlobal; public class RequestManager { + private readonly ILogger Logger = LogHandler.GetClassLogger(); public static Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); private List GetCustomFields(EnrollmentProductInfo productInfo, List customFields) { + Logger.LogTrace("GetCustomFields: productInfo is {Null}, customFields count={Count}", + productInfo == null ? "NULL" : "present", + customFields?.Count ?? 0); + var customFieldList = new List(); + if (customFields == null || productInfo?.ProductParameters == null) + { + Logger.LogTrace("GetCustomFields: returning empty list (null customFields or ProductParameters)."); + return customFieldList; + } + foreach (var field in customFields) + { + if (field == null) + { + Logger.LogTrace("GetCustomFields: skipping null field entry."); + continue; + } + + Logger.LogTrace("GetCustomFields: checking field Label='{Label}', Mandatory={Mandatory}", + field.Label ?? "(null)", field.Mandatory); + + if (string.IsNullOrEmpty(field.Label)) + { + Logger.LogTrace("GetCustomFields: skipping field with null/empty label."); + continue; + } + if (productInfo.ProductParameters.ContainsKey(field.Label)) { var newField = new CustomField @@ -30,32 +59,60 @@ private List GetCustomFields(EnrollmentProductInfo productInfo, Lis Name = field.Label, Value = productInfo.ProductParameters[field.Label] }; + Logger.LogTrace("GetCustomFields: matched field '{Label}' = '{Value}'", field.Label, newField.Value ?? "(null)"); customFieldList.Add(newField); } else if (field.Mandatory) { + Logger.LogError("GetCustomFields: mandatory field '{Label}' was not supplied. Available keys: [{Keys}]", + field.Label, string.Join(", ", productInfo.ProductParameters.Keys)); throw new Exception( $"Custom field {field.Label} is marked as mandatory, but was not supplied in the request."); } + else + { + Logger.LogTrace("GetCustomFields: optional field '{Label}' not found in ProductParameters, skipping.", field.Label); + } + } + Logger.LogTrace("GetCustomFields: returning {Count} custom fields.", customFieldList.Count); return customFieldList; } public EnrollmentResult GetRenewResponse(RenewalResponse renewResponse) { + Logger.LogTrace("GetRenewResponse: renewResponse is {Null}", renewResponse == null ? "NULL" : "present"); + + if (renewResponse == null) + { + Logger.LogError("GetRenewResponse: renewResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Renewal failed: received null response from CSC." + }; + } + if (renewResponse.RegistrationError != null) + { + Logger.LogWarning("GetRenewResponse: RegistrationError present. Description='{Desc}'", + renewResponse.RegistrationError.Description ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - CARequestID = renewResponse?.Result?.Status?.Uuid, - StatusMessage = renewResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + CARequestID = renewResponse.Result?.Status?.Uuid, + StatusMessage = renewResponse.RegistrationError.Description ?? "Renewal failed with unknown error." }; + } + var commonName = renewResponse.Result?.CommonName ?? "(unknown)"; + var uuid = renewResponse.Result?.Status?.Uuid; + Logger.LogTrace("GetRenewResponse: renewal succeeded for CommonName='{CommonName}', UUID='{Uuid}'", commonName, uuid ?? "(null)"); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - - StatusMessage = $"Renewal Successfully Completed For {renewResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Renewal Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } @@ -64,77 +121,210 @@ public EnrollmentResult GetEnrollmentResult( IRegistrationResponse registrationResponse) { + Logger.LogTrace("GetEnrollmentResult: registrationResponse is {Null}", registrationResponse == null ? "NULL" : "present"); + + if (registrationResponse == null) + { + Logger.LogError("GetEnrollmentResult: registrationResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: received null response from CSC." + }; + } + if (registrationResponse.RegistrationError != null) + { + Logger.LogWarning("GetEnrollmentResult: RegistrationError present. Description='{Desc}'", + registrationResponse.RegistrationError.Description ?? "(null)"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = registrationResponse.RegistrationError.Description ?? "Enrollment failed with unknown error." + }; + } + + if (registrationResponse.Result == null) + { + Logger.LogError("GetEnrollmentResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = registrationResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Enrollment failed: response Result is null." }; + } var cnames = new Dictionary(); if (registrationResponse.Result.DcvDetails != null && registrationResponse.Result.DcvDetails.Count > 0) + { + Logger.LogTrace("GetEnrollmentResult: processing {Count} DcvDetails.", registrationResponse.Result.DcvDetails.Count); foreach (var dcv in registrationResponse.Result.DcvDetails) { + if (dcv == null) + { + Logger.LogTrace("GetEnrollmentResult: skipping null DcvDetail."); + continue; + } + if (dcv.CName != null && !string.IsNullOrEmpty(dcv.CName.Name) && !string.IsNullOrEmpty(dcv.CName.Value)) { - cnames.Add(dcv.CName.Name, dcv.CName.Value); + if (!cnames.ContainsKey(dcv.CName.Name)) + { + Logger.LogTrace("GetEnrollmentResult: adding CName '{Name}'='{Value}'", dcv.CName.Name, dcv.CName.Value); + cnames.Add(dcv.CName.Name, dcv.CName.Value); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate CName key '{Name}', skipping.", dcv.CName.Name); + } } - if (string.IsNullOrEmpty(dcv.Email)) + if (!string.IsNullOrEmpty(dcv.Email)) { - cnames.Add(dcv.Email, dcv.Email); + if (!cnames.ContainsKey(dcv.Email)) + { + Logger.LogTrace("GetEnrollmentResult: adding DCV email '{Email}'", dcv.Email); + cnames.Add(dcv.Email, dcv.Email); + } + else + { + Logger.LogTrace("GetEnrollmentResult: duplicate email key '{Email}', skipping.", dcv.Email); + } } } - + } + else + { + Logger.LogTrace("GetEnrollmentResult: no DcvDetails to process."); + } + + var uuid = registrationResponse.Result.Status?.Uuid; + var commonName = registrationResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetEnrollmentResult: success. UUID='{Uuid}', CommonName='{CommonName}', cnames count={Count}", + uuid ?? "(null)", commonName, cnames.Count); + return new EnrollmentResult { - Status = (int)EndEntityStatus.EXTERNALVALIDATION, //success - CARequestID = registrationResponse.Result.Status.Uuid, + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, StatusMessage = - $"Order Successfully Created With Order Number {registrationResponse.Result.CommonName}", + $"Order Successfully Created With Order Number {commonName}", EnrollmentContext = cnames.Count > 0 ? cnames : null }; } public int GetRevokeResult(IRevokeResponse revokeResponse) { + Logger.LogTrace("GetRevokeResult: revokeResponse is {Null}", revokeResponse == null ? "NULL" : "present"); + + if (revokeResponse == null) + { + Logger.LogError("GetRevokeResult: revokeResponse is null, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + if (revokeResponse.RegistrationError != null) + { + Logger.LogWarning("GetRevokeResult: RegistrationError present. Description='{Desc}'", + revokeResponse.RegistrationError.Description ?? "(null)"); return (int)EndEntityStatus.FAILED; + } + Logger.LogTrace("GetRevokeResult: returning REVOKED."); return (int)EndEntityStatus.REVOKED; } public EnrollmentResult GetReIssueResult(IReissueResponse reissueResponse) { + Logger.LogTrace("GetReIssueResult: reissueResponse is {Null}", reissueResponse == null ? "NULL" : "present"); + + if (reissueResponse == null) + { + Logger.LogError("GetReIssueResult: reissueResponse is null."); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: received null response from CSC." + }; + } + if (reissueResponse.RegistrationError != null) + { + Logger.LogWarning("GetReIssueResult: RegistrationError present. Description='{Desc}'", + reissueResponse.RegistrationError.Description ?? "(null)"); + return new EnrollmentResult + { + Status = (int)EndEntityStatus.FAILED, + StatusMessage = reissueResponse.RegistrationError.Description ?? "Reissue failed with unknown error." + }; + } + + if (reissueResponse.Result == null) + { + Logger.LogError("GetReIssueResult: Result is null but no RegistrationError present."); return new EnrollmentResult { - Status = (int)EndEntityStatus.FAILED, //failure - StatusMessage = reissueResponse.RegistrationError.Description + Status = (int)EndEntityStatus.FAILED, + StatusMessage = "Reissue failed: response Result is null." }; + } + + var uuid = reissueResponse.Result.Status?.Uuid; + var commonName = reissueResponse.Result.CommonName ?? "(unknown)"; + Logger.LogTrace("GetReIssueResult: success. UUID='{Uuid}', CommonName='{CommonName}'", uuid ?? "(null)", commonName); return new EnrollmentResult { - Status = (int)EndEntityStatus.GENERATED, //success - CARequestID = reissueResponse.Result.Status.Uuid, - StatusMessage = $"Reissue Successfully Completed For {reissueResponse.Result.CommonName}" + Status = (int)EndEntityStatus.EXTERNALVALIDATION, + CARequestID = uuid, + StatusMessage = $"Reissue Successfully Submitted For {commonName}. Certificate will be available after next sync." }; } public DomainControlValidation GetDomainControlValidation(string methodType, string[] emailAddress, string domainName) { + Logger.LogTrace("GetDomainControlValidation(array): methodType='{MethodType}', domainName='{DomainName}', emailAddress count={Count}", + methodType ?? "(null)", domainName ?? "(null)", emailAddress?.Length ?? 0); + + if (emailAddress == null || emailAddress.Length == 0) + { + Logger.LogTrace("GetDomainControlValidation(array): no email addresses provided, returning null."); + return null; + } + foreach (var address in emailAddress) { - var email = new MailAddress(address); - if (domainName.Contains(email.Host.Split('.')[0])) - return new DomainControlValidation + if (string.IsNullOrEmpty(address)) + { + Logger.LogTrace("GetDomainControlValidation(array): skipping null/empty email address."); + continue; + } + + try + { + var email = new MailAddress(address); + var hostPart = email.Host?.Split('.')[0] ?? ""; + Logger.LogTrace("GetDomainControlValidation(array): checking email='{Email}', hostPart='{HostPart}' against domain='{Domain}'", + address, hostPart, domainName); + + if (!string.IsNullOrEmpty(domainName) && domainName.Contains(hostPart)) { - MethodType = methodType, - EmailAddress = email.ToString() - }; + Logger.LogTrace("GetDomainControlValidation(array): matched! Returning email='{Email}'", email.ToString()); + return new DomainControlValidation + { + MethodType = methodType, + EmailAddress = email.ToString() + }; + } + } + catch (FormatException fex) + { + Logger.LogWarning("GetDomainControlValidation(array): invalid email address '{Address}': {Message}", address, fex.Message); + } } + Logger.LogTrace("GetDomainControlValidation(array): no matching email found, returning null."); return null; } @@ -150,105 +340,190 @@ public DomainControlValidation GetDomainControlValidation(string methodType, str public RegistrationRequest GetRegistrationRequest(EnrollmentProductInfo productInfo, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRegistrationRequest: building registration request. ProductID='{ProductId}'", productInfo?.ProductID ?? "(null)"); + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + var cert = Pemify(csr); var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + Logger.LogTrace("GetRegistrationRequest: CSR encoded, length={Length}", encodedString.Length); + + var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRegistrationRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RegistrationRequest { Csr = encodedString, - ServerSoftware = "-1", //Just default to other, user does not need to fill this in + ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], - ShowPrice = true, //User should not have to fill this out + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, + ShowPrice = true, CustomFields = GetCustomFields(productInfo, customFields), SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, EvCertificateDetails = certificateType == "3" ? GetEvCertificateDetails(productInfo) : null }; } + // Maps Keyfactor product ID -> CSC API certificate type code (used for enrollment requests) + private static readonly Dictionary ProductIdToCodeMap = new(StringComparer.OrdinalIgnoreCase) + { + ["CSC TrustedSecure Premium Certificate"] = "0", + ["CSC TrustedSecure Premium Wildcard Certificate"] = "1", + ["CSC TrustedSecure UC Certificate"] = "2", + ["CSC TrustedSecure EV Certificate"] = "3", + ["CSC TrustedSecure Domain Validated SSL"] = "4", + ["CSC Trusted Secure Domain Validated SSL"] = "4", + ["CSC Trusted Secure Domain Validated Wildcard SSL"] = "5", + ["CSC Trusted Secure Domain Validated UC Certificate"] = "6", + }; + + // 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", + }; + private string GetCertificateType(string productId) { - switch (productId) + Logger.LogTrace("GetCertificateType: productId='{ProductId}'", productId ?? "(null)"); + if (!string.IsNullOrEmpty(productId) && ProductIdToCodeMap.TryGetValue(productId, out var code)) { - case "CSC TrustedSecure Premium Certificate": - return "0"; - case "CSC TrustedSecure EV Certificate": - return "3"; - case "CSC TrustedSecure UC Certificate": - return "2"; - case "CSC TrustedSecure Premium Wildcard Certificate": - return "1"; - case "CSC Trusted Secure Domain Validated SSL": - return "4"; - case "CSC Trusted Secure Domain Validated Wildcard SSL": - return "5"; - case "CSC Trusted Secure Domain Validated UC Certificate": - return "6"; - case "CSC TrustedSecure Domain Validated SSL": - return "4"; - case "CSC TrustedSecure Domain Validated Wildcard SSL": - return "5"; - case "CSC TrustedSecure Domain Validated UC Certificate": - return "6"; + Logger.LogTrace("GetCertificateType: mapped '{ProductId}' -> '{Code}'", productId, code); + return code; } - + Logger.LogWarning("GetCertificateType: no mapping found for '{ProductId}', returning -1.", 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."); + var emailsRaw = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Notification Email(s) Comma Separated") + ? productInfo.ProductParameters["Notification Email(s) Comma Separated"] + : null; + + Logger.LogTrace("GetNotifications: raw notification emails='{Emails}'", emailsRaw ?? "(null)"); + + var emailList = !string.IsNullOrEmpty(emailsRaw) + ? emailsRaw.Split(',').Where(e => !string.IsNullOrWhiteSpace(e)).ToList() + : new List(); + + Logger.LogTrace("GetNotifications: parsed {Count} notification emails.", emailList.Count); + return new Notifications { Enabled = true, - AdditionalNotificationEmails = productInfo.ProductParameters["Notification Email(s) Comma Separated"] - .Split(',').ToList() + AdditionalNotificationEmails = emailList }; } public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetRenewalRequest: building renewal request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + var cert = Pemify(csr); var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + + var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetRenewalRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new RenewalRequest { Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", CertificateType = certificateType, - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -259,54 +534,107 @@ public RenewalRequest GetRenewalRequest(EnrollmentProductInfo productInfo, strin private List GetSubjectAlternativeNames(EnrollmentProductInfo productInfo, Dictionary sans) { + Logger.LogTrace("GetSubjectAlternativeNames: building SANs."); var subjectNameList = new List(); - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; - foreach (var v in sans["dnsname"]) + if (sans == null || !sans.ContainsKey("dnsname")) { + Logger.LogTrace("GetSubjectAlternativeNames: no 'dnsname' key in SANs dictionary, returning empty list."); + return subjectNameList; + } + + var dnsNames = sans["dnsname"]; + if (dnsNames == null || dnsNames.Length == 0) + { + Logger.LogTrace("GetSubjectAlternativeNames: 'dnsname' array is null or empty, returning empty list."); + return subjectNameList; + } + + var methodType = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] + : null; + + Logger.LogTrace("GetSubjectAlternativeNames: processing {Count} DNS names, methodType='{MethodType}'", + dnsNames.Length, methodType ?? "(null)"); + + foreach (var v in dnsNames) + { + if (string.IsNullOrEmpty(v)) + { + Logger.LogTrace("GetSubjectAlternativeNames: skipping null/empty DNS name."); + continue; + } + var domainName = v; var san = new SubjectAlternativeName(); san.DomainName = domainName; - var emailAddresses = productInfo.ProductParameters["Addtl Sans Comma Separated DVC Emails"].Split(','); - if (methodType.ToUpper() == "EMAIL") + Logger.LogTrace("GetSubjectAlternativeNames: processing domain='{Domain}'", domainName); + + 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"] + : 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); - else //it is a CNAME validation so no email is needed + } + else + { + Logger.LogTrace("GetSubjectAlternativeNames: CNAME/other validation for domain='{Domain}'", domainName); san.DomainControlValidation = GetDomainControlValidation(methodType, ""); + } subjectNameList.Add(san); } + Logger.LogTrace("GetSubjectAlternativeNames: returning {Count} SANs.", subjectNameList.Count); return subjectNameList; } public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, string uUId, string csr, Dictionary sans, List customFields) { - //var cert = "-----BEGIN CERTIFICATE REQUEST-----\r\n"; - var cert = Pemify(csr); - //cert = cert + "\r\n-----END CERTIFICATE REQUEST-----"; + Logger.LogTrace("GetReissueRequest: building reissue request. UUID='{Uuid}', ProductID='{ProductId}'", + uUId ?? "(null)", productInfo?.ProductID ?? "(null)"); + + if (productInfo?.ProductParameters == null) + throw new ArgumentNullException(nameof(productInfo), "productInfo or ProductParameters cannot be null."); + if (string.IsNullOrEmpty(csr)) + throw new ArgumentNullException(nameof(csr), "CSR cannot be null or empty."); + if (string.IsNullOrEmpty(uUId)) + throw new ArgumentNullException(nameof(uUId), "uUId cannot be null or empty."); + var cert = Pemify(csr); var bytes = Encoding.UTF8.GetBytes(cert); var encodedString = Convert.ToBase64String(bytes); - var commonNameValidationEmail = productInfo.ProductParameters["CN DCV Email"]; - var methodType = productInfo.ProductParameters["Domain Control Validation Method"]; + + var commonNameValidationEmail = productInfo.ProductParameters.ContainsKey("CN DCV Email") + ? productInfo.ProductParameters["CN DCV Email"] : null; + var methodType = productInfo.ProductParameters.ContainsKey("Domain Control Validation Method") + ? productInfo.ProductParameters["Domain Control Validation Method"] : null; var certificateType = GetCertificateType(productInfo.ProductID); + Logger.LogTrace("GetReissueRequest: cnDcvEmail='{Email}', methodType='{Method}', certType='{CertType}'", + commonNameValidationEmail ?? "(null)", methodType ?? "(null)", certificateType); + return new ReissueRequest { Uuid = uUId, Csr = encodedString, ServerSoftware = "-1", - CertificateType = GetCertificateType(productInfo.ProductID), - Term = productInfo.ProductParameters["Term"], - ApplicantFirstName = productInfo.ProductParameters["Applicant First Name"], - ApplicantLastName = productInfo.ProductParameters["Applicant Last Name"], - ApplicantEmailAddress = productInfo.ProductParameters["Applicant Email Address"], - ApplicantPhoneNumber = productInfo.ProductParameters["Applicant Phone"], + CertificateType = certificateType, + Term = productInfo.ProductParameters.ContainsKey("Term") ? productInfo.ProductParameters["Term"] : null, + ApplicantFirstName = productInfo.ProductParameters.ContainsKey("Applicant First Name") ? productInfo.ProductParameters["Applicant First Name"] : null, + ApplicantLastName = productInfo.ProductParameters.ContainsKey("Applicant Last Name") ? productInfo.ProductParameters["Applicant Last Name"] : null, + ApplicantEmailAddress = productInfo.ProductParameters.ContainsKey("Applicant Email Address") ? productInfo.ProductParameters["Applicant Email Address"] : null, + ApplicantPhoneNumber = productInfo.ProductParameters.ContainsKey("Applicant Phone") ? productInfo.ProductParameters["Applicant Phone"] : null, DomainControlValidation = GetDomainControlValidation(methodType, commonNameValidationEmail), Notifications = GetNotifications(productInfo), - OrganizationContact = productInfo.ProductParameters["Organization Contact"], - BusinessUnit = productInfo.ProductParameters["Business Unit"], + OrganizationContact = productInfo.ProductParameters.ContainsKey("Organization Contact") ? productInfo.ProductParameters["Organization Contact"] : null, + BusinessUnit = productInfo.ProductParameters.ContainsKey("Business Unit") ? productInfo.ProductParameters["Business Unit"] : null, ShowPrice = true, SubjectAlternativeNames = certificateType == "2" ? GetSubjectAlternativeNames(productInfo, sans) : null, CustomFields = GetCustomFields(productInfo, customFields), @@ -316,15 +644,28 @@ public ReissueRequest GetReissueRequest(EnrollmentProductInfo productInfo, strin private EvCertificateDetails GetEvCertificateDetails(EnrollmentProductInfo productInfo) { + Logger.LogTrace("GetEvCertificateDetails: building EV details."); + var country = productInfo?.ProductParameters != null + && productInfo.ProductParameters.ContainsKey("Organization Country") + ? productInfo.ProductParameters["Organization Country"] + : null; + Logger.LogTrace("GetEvCertificateDetails: country='{Country}'", country ?? "(null)"); var evDetails = new EvCertificateDetails(); - evDetails.Country = productInfo.ProductParameters["Organization Country"]; + evDetails.Country = country; return evDetails; } public int MapReturnStatus(string cscGlobalStatus) { - var returnStatus = 0; + Logger.LogTrace("MapReturnStatus: input status='{Status}'", cscGlobalStatus ?? "(null)"); + + if (string.IsNullOrEmpty(cscGlobalStatus)) + { + Logger.LogWarning("MapReturnStatus: status is null or empty, returning FAILED."); + return (int)EndEntityStatus.FAILED; + } + int returnStatus; switch (cscGlobalStatus) { case "ACTIVE": @@ -340,10 +681,12 @@ public int MapReturnStatus(string cscGlobalStatus) returnStatus = (int)EndEntityStatus.REVOKED; break; default: + Logger.LogWarning("MapReturnStatus: unrecognized status '{Status}', returning FAILED.", cscGlobalStatus); returnStatus = (int)EndEntityStatus.FAILED; break; } + Logger.LogTrace("MapReturnStatus: mapped '{Status}' to {Result}", cscGlobalStatus, returnStatus); return returnStatus; } } \ No newline at end of file diff --git a/docsource/configuration.md b/docsource/configuration.md index d8c196e..54c3210 100644 --- a/docsource/configuration.md +++ b/docsource/configuration.md @@ -10,6 +10,55 @@ This integration is tested and confirmed as working for Anygateway REST 24.2 and The Root certificates for installation on the Anygateway server machine should be obtained from CSC. +## CA Connection Configuration + +When defining the Certificate Authority in the AnyCA Gateway REST portal, configure the following fields on the **CA Connection** tab: + +CONFIG ELEMENT | DESCRIPTION | DEFAULT +---------------|-------------|-------- +CscGlobalUrl | The base URL for the CSCGlobal API (e.g. `https://apis.cscglobal.com`) | (required) +ApiKey | Your CSCGlobal API key | (required) +BearerToken | Your CSCGlobal Bearer token for authentication | (required) +DefaultPageSize | Page size for API list requests | 100 +SyncFilterDays | Number of days from today used to filter certificates by expiration date during **incremental** sync. Only certificates expiring within this window are returned. Does not apply to full sync. | 5 +RenewalWindowDays | Number of days before the annual order expiry date within which a **RenewOrReissue** request triggers a paid **Renewal** rather than a free **Reissue**. See [Renewal vs. Reissue Logic](#renewal-vs-reissue-logic) below. | 30 + +## Renewal vs. Reissue Logic + +CSC Global subscriptions are annual orders. When Keyfactor Command sends a **RenewOrReissue** request, the plugin must decide whether to submit a **Renewal** (a new paid order) or a **Reissue** (a free re-key under the existing active order). + +The decision is based on the **RenewalWindowDays** setting and works as follows: + +1. The plugin fetches the original certificate from CSC and reads its `orderDate`. +2. It computes the **order expiry** as `orderDate + 1 year`. +3. It calculates **days remaining** until the order expires. +4. If `days remaining <= RenewalWindowDays`, the request is treated as a **Renewal** (new paid order). +5. If `days remaining > RenewalWindowDays`, the request is treated as a **Reissue** (free under the active order). + +**Example with default RenewalWindowDays = 30:** + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2026-03-15 +Days Left: 24 + +24 <= 30 --> RENEWAL (new paid order) +``` + +``` +Order Date: 2025-04-08 +Order Expiry: 2026-04-08 +Today: 2025-09-01 +Days Left: 219 + +219 > 30 --> REISSUE (free under active order) +``` + +**Fallback behavior:** If the plugin cannot retrieve the `orderDate` from CSC (e.g., API error or missing field), it falls back to checking the certificate's expiration date. If the certificate is already expired, it treats the request as a Renewal. + +**Note:** Both Renewal and Reissue submissions are asynchronous at CSC. The plugin returns a "pending" status and the issued certificate will appear in Keyfactor after the next sync cycle. + ## Certificate Template Creation Step PLEASE NOTE, AT THIS TIME THE RAPID_SSL TEMPLATE IS NOT SUPPORTED BY THE CSC API AND WILL NOT WORK WITH THIS INTEGRATION diff --git a/integration-manifest.json b/integration-manifest.json index 2b4b8c4..71d13f4 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -29,13 +29,13 @@ "name": "DefaultPageSize", "description": "Default page size for use with the API. Default is 100" }, - { - "name": "TemplateSync", - "description": "Enable template sync." - }, { "name": "SyncFilterDays", "description": "Number of days from today to filter certificates by expiration date during incremental sync." + }, + { + "name": "RenewalWindowDays", + "description": "Number of days before the annual order expiry within which a RenewOrReissue triggers a paid Renewal rather than a free Reissue. Default is 30." } ], "enrollment_config": [ @@ -94,8 +94,8 @@ "CSC TrustedSecure UC Certificate", "CSC TrustedSecure Premium Wildcard Certificate", "CSC TrustedSecure Domain Validated SSL", - "CSC TrustedSecure Domain Validated Wildcard SSL", - "CSC TrustedSecure Domain Validated UC Certificate" + "CSC Trusted Secure Domain Validated Wildcard SSL", + "CSC Trusted Secure Domain Validated UC Certificate" ] } }