Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
v1.4.0
- Add ability to manage custom VDOMs. PLEASE NOTE this release contains a breaking change. Store Path MUST contain the value for the VDOM the certificate will be managing. `root` must be entered to manage the default 'root' VDOM.

Comment on lines +1 to +3

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR title indicates a 1.4.1 merge, but the changelog entry added here is v1.4.0. If this PR is meant to merge 1.4.1, consider adding/updating the v1.4.1 section (or confirm the PR title/version is correct).

Copilot uses AI. Check for mistakes.
v1.3.0
- Add support for renewing certificate bound to the HTTPS server

Expand Down
2 changes: 2 additions & 0 deletions Fortigate/Api/cmdb_certificate_resource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ public class cmdb_certificate_resource
public string file_content { get; set; }

public string scope { get; set; }

public string vdom { get; set; }
}
}
89 changes: 65 additions & 24 deletions Fortigate/FortigateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class FortigateStore
{
private ILogger logger { get; set; }
private string FortigateHost { get; set; }
private string VDOM { get; set; }


private static readonly string available_certificates = "/api/v2/monitor/system/available-certificates";
Expand All @@ -47,8 +48,7 @@ public class FortigateStore
private static readonly string import_certificate_api = "/api/v2/monitor/vpn-certificate/local/import";

private static readonly string get_certificate_api = "/api/v2/cmdb/certificate/local/";

private static readonly string update_certificate_api = "/api/v2/cmdb/certificate/local/";
private static readonly string get_vdom_api = "/api/v2/cmdb/system/vdom/";

//api/v2/cmdb/vpn.certificate/local/test?vdom=root
private static readonly string delete_certificate_api = "/api/v2/cmdb/vpn.certificate/local/";
Expand All @@ -63,7 +63,7 @@ public class FortigateStore
};
private readonly HttpClient client;

public FortigateStore(string fortigateHost, string accessToken)
public FortigateStore(string fortigateHost, string accessToken, string vdom)
{
logger = LogHandler.GetClassLogger(this.GetType());

Expand All @@ -72,6 +72,9 @@ public FortigateStore(string fortigateHost, string accessToken)
client = new HttpClient(handler);
FortigateHost = fortigateHost;
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}");
VDOM = string.IsNullOrEmpty(vdom) ? "root" : vdom;

ValidateVDOM();

logger.MethodExit(LogLevel.Debug);
}
Expand All @@ -80,9 +83,11 @@ public void Delete(string alias)
{
logger.MethodEntry(LogLevel.Debug);

Dictionary<string, string> parameters = new Dictionary<string, string>();
parameters.Add("vdom", VDOM);
try
{
DeleteResource(delete_certificate_api + alias);
DeleteResource(delete_certificate_api + alias, parameters);
}
catch (Exception ex)
{
Expand All @@ -106,8 +111,7 @@ public void UpdateUsage(string alias, string path, string name, string attribute

var endpoint = "/api/v2/cmdb/" + path + "/" + name;

var parameters = new Dictionary<String, String>();
parameters.Add("vdom", "root");
var parameters = new Dictionary<String, String> { { "vdom", VDOM } };

try
{
Expand All @@ -129,8 +133,7 @@ public Usage Usage(string alias, int qtype)
logger.MethodEntry(LogLevel.Debug);

var parameters = new Dictionary<String, String>();
parameters.Add("vdom", "root");
parameters.Add("scope", "global");
parameters.Add("vdom", VDOM);
parameters.Add("mkey", alias);
parameters.Add("qtypes", $"[{qtype.ToString()}]");

Expand Down Expand Up @@ -289,16 +292,14 @@ private void Insert(string alias, string cert, string privateKey, string passwor
certname = alias,
key_file_content = privateKey,
file_content = cert,
scope = "global",
//password = password,
scope = "vdom",
vdom = VDOM,
type = "regular"
};

var parameters = new Dictionary<String, String>();
parameters.Add("vdom", "root");
try
{
PostAsJson(import_certificate_api, cert_resource, parameters);
PostAsJson(import_certificate_api, cert_resource);

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Insert() no longer passes the VDOM as a query parameter when calling the FortiGate import endpoint. Previously this call included vdom=root via query params; now PostAsJson(import_certificate_api, cert_resource) sends no query params, so the request URL will not include vdom=<VDOM>. If the FortiGate API still requires vdom as a query parameter, imports will fail or target the wrong scope. Consider adding vdom=VDOM to the query params for this POST (either by passing additional params here, or by having PostAsJson/GetUrl include VDOM by default).

Suggested change
PostAsJson(import_certificate_api, cert_resource);
var importCertificateUrl = import_certificate_api;
var separator = importCertificateUrl.Contains("?") ? "&" : "?";
importCertificateUrl = $"{importCertificateUrl}{separator}vdom={HttpUtility.UrlEncode(VDOM)}";
PostAsJson(importCertificateUrl, cert_resource);

Copilot uses AI. Check for mistakes.
}
catch (Exception ex)
{
Expand All @@ -318,8 +319,11 @@ public Certificate[] List(string mkey)

try
{
string endpoint = string.IsNullOrEmpty(mkey) ? available_certificates : available_certificates;
Dictionary<String, String> parameters = mkey == null ? null : new Dictionary<string, string> { { "mkey", mkey } };
string endpoint = available_certificates;
Dictionary<String, String> parameters = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(mkey))
parameters.Add("mkey", mkey);
parameters.Add("vdom", VDOM);
var result = GetResource(endpoint, parameters);
certificates = JsonConvert.DeserializeObject<FortigateResponse<Certificate[]>>(result).results;
}
Expand All @@ -339,47 +343,84 @@ public Certificate[] List(string mkey)
return certificates;
}

public string DownloadFileAsString(string mkey, string type)
public string DownloadFileAsString(string mkey, string type, out bool isError)
{
logger.MethodEntry(LogLevel.Debug);

isError = false;
var parameters = new Dictionary<String, String>();
parameters.Add("mkey", mkey);
parameters.Add("type", type);
parameters.Add("vdom", VDOM);

string content = string.Empty;

try
{
var response = client.GetAsync(GetUrl(download_certificate, parameters)).GetAwaiter().GetResult();
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode)
throw new Exception($"Error retrieving certificate {mkey}: {content}");

return content;
}
catch (Exception ex)
{
logger.LogError(FortigateException.FlattenExceptionMessages(ex, $"Error retrieving downloading file {mkey}: "));
throw;
isError = true;
}
finally
{
logger.MethodExit(LogLevel.Debug);
}

return content;
}

public void ValidateVDOMScope(string alias)
{
logger.MethodEntry(LogLevel.Debug);

try
{
Certificate[] certs = List(alias);
if (certs.Length > 0 && certs[0].range.ToLower() == "global")
throw new Exception($"Certificate {alias} is scoped as global. Global certificates cannot be replaced or deleted by this integration.");
}
finally
{
logger.MethodExit(LogLevel.Debug);
}
}

private void ValidateVDOM()
{
logger.MethodEntry(LogLevel.Debug);

try
{
var response = client.GetAsync(GetUrl(get_vdom_api + VDOM, null)).GetAwaiter().GetResult();
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new FortigateException($"VDOM {VDOM} not found.");
if (!response.IsSuccessStatusCode)
throw new FortigateException($"Error retrieving VDOM {VDOM}. Status={response.StatusCode.ToString()}, Error={response.Content} {response.ReasonPhrase}");
Comment on lines +401 to +404

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidateVDOM() builds an error message using response.Content, which is an HttpContent object and won’t contain the response body unless you read it. This makes troubleshooting much harder and may log only the type name. Read the content via ReadAsStringAsync() (or similar) and include that string in the exception message.

Suggested change
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new FortigateException($"VDOM {VDOM} not found.");
if (!response.IsSuccessStatusCode)
throw new FortigateException($"Error retrieving VDOM {VDOM}. Status={response.StatusCode.ToString()}, Error={response.Content} {response.ReasonPhrase}");
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new FortigateException($"VDOM {VDOM} not found.");
if (!response.IsSuccessStatusCode)
throw new FortigateException($"Error retrieving VDOM {VDOM}. Status={response.StatusCode.ToString()}, Error={content} {response.ReasonPhrase}");

Copilot uses AI. Check for mistakes.
}
finally
{
logger.MethodExit(LogLevel.Debug);
}
}

private String PostAsJson(string endpoint, cmdb_certificate_resource obj, Dictionary<String, String> additionalParams = null)
private String PostAsJson(string endpoint, cmdb_certificate_resource obj)
{
logger.MethodEntry(LogLevel.Debug);

string content = "";
var url = GetUrl(endpoint, additionalParams);
var url = GetUrl(endpoint);
var stringContent = new StringContent(JsonConvert.SerializeObject(obj), Encoding.UTF8, "application/json");
stringContent.Headers.ContentType = new MediaTypeHeaderValue("application/json");

try
{
HttpResponseMessage responseMessage = client.PostAsync(url, stringContent).GetAwaiter().GetResult();
content = responseMessage.Content.ReadAsStringAsync().GetAwaiter().GetResult();
var content = responseMessage.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (!responseMessage.IsSuccessStatusCode)
throw new Exception($"Error adding certificate {obj.certname}: {content}");

Expand Down
23 changes: 17 additions & 6 deletions Fortigate/Inventory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using Keyfactor.Logging;
using Microsoft.Extensions.Logging;
using Keyfactor.Orchestrators.Extensions.Interfaces;
using Org.BouncyCastle.Tls;

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The using Org.BouncyCastle.Tls; directive appears to be unused in this file, which will generate warnings (and can fail builds if warnings are treated as errors). Remove the unused import (or add the missing code that requires it).

Suggested change
using Org.BouncyCastle.Tls;

Copilot uses AI. Check for mistakes.

namespace Keyfactor.Extensions.Orchestrator.Fortigate
{
Expand All @@ -37,19 +38,25 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd
ILogger logger = LogHandler.GetClassLogger(this.GetType());
logger.LogDebug($"Begin {config.Capability} for job id {config.JobId}...");
logger.LogDebug($"Client Machine: {config.CertificateStoreDetails.ClientMachine}");

FortigateStore store = new FortigateStore(config.CertificateStoreDetails.ClientMachine, PAMUtilities.ResolvePAMField(_resolver, logger, "Fortigate Access Key", config.CertificateStoreDetails.StorePassword));
logger.LogDebug($"Store Path: {config.CertificateStoreDetails.StorePath}");

List<CurrentInventoryItem> inventoryItems = new List<CurrentInventoryItem>();
bool atLeastOneError = false;

try
{
FortigateStore store = new FortigateStore(config.CertificateStoreDetails.ClientMachine, PAMUtilities.ResolvePAMField(_resolver, logger, "Fortigate Access Key", config.CertificateStoreDetails.StorePassword), config.CertificateStoreDetails.StorePath);

Api.Certificate[] certificates = store.List(null);

bool isError;

foreach (var cert in certificates)
{
if (cert.type == "local-cer")
{
var certFile = store.DownloadFileAsString(cert.name, cert.type);
var certFile = store.DownloadFileAsString(cert.name, cert.type, out isError);
if (isError) atLeastOneError = true;

var item = new CurrentInventoryItem()
{
Expand All @@ -66,16 +73,20 @@ public JobResult ProcessJob(InventoryJobConfiguration config, SubmitInventoryUpd
}
catch (Exception ex)
{
logger.LogError($"Exception for {config.Capability}: {FortigateException.FlattenExceptionMessages(ex, string.Empty)} for job id {config.JobId}");
return new JobResult() { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = config.JobHistoryId, FailureMessage = FortigateException.FlattenExceptionMessages(ex, $"Site {config.CertificateStoreDetails.ClientMachine}") };
logger.LogError($"Exception for {config.Capability}: {FortigateException.FlattenExceptionMessages(ex, string.Empty)} for job id {config.JobId} ");
return new JobResult() { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = config.JobHistoryId, FailureMessage = FortigateException.FlattenExceptionMessages(ex, $"Site {config.CertificateStoreDetails.ClientMachine} ") };
}

try
{
logger.LogDebug("Sending certificates back to Command:" + inventoryItems.Count);
submitInventory.Invoke(inventoryItems);
logger.LogDebug($"...End {config.Capability} job for job id {config.JobId}");
return new JobResult() { Result = OrchestratorJobStatusJobResult.Success, JobHistoryId = config.JobHistoryId };

if (atLeastOneError)
return new JobResult() { Result = OrchestratorJobStatusJobResult.Warning, JobHistoryId = config.JobHistoryId, FailureMessage = "At least one certificate was unable to be retrieved, Please check the log for more information." };
else
return new JobResult() { Result = OrchestratorJobStatusJobResult.Success, JobHistoryId = config.JobHistoryId };
Comment on lines 84 to +89

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FailureMessage text has a grammar/capitalization issue (comma splice and capital “Please” mid-sentence). Consider rephrasing to a single sentence and/or include actionable context (e.g., how many certificates failed, and that details are in logs).

Copilot uses AI. Check for mistakes.
}
catch (Exception ex)
{
Expand Down
9 changes: 6 additions & 3 deletions Fortigate/Management.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,14 @@ public JobResult ProcessJob(ManagementJobConfiguration config)

logger.LogDebug($"Begin {config.Capability} for job id {config.JobId}...");
logger.LogDebug($"Client Machine: {config.CertificateStoreDetails.ClientMachine}");

FortigateStore store = new FortigateStore(config.CertificateStoreDetails.ClientMachine, PAMUtilities.ResolvePAMField(_resolver, logger, "Fortigate Access Key", config.CertificateStoreDetails.StorePassword));
logger.LogDebug($"Store Path: {config.CertificateStoreDetails.StorePath}");

try
{
FortigateStore store = new FortigateStore(config.CertificateStoreDetails.ClientMachine, PAMUtilities.ResolvePAMField(_resolver, logger, "Fortigate Access Key", config.CertificateStoreDetails.StorePassword), config.CertificateStoreDetails.StorePath);

store.ValidateVDOMScope(config.JobCertificate.Alias);

//Management jobs, unlike Discovery, Inventory, and Reenrollment jobs can have 3 different purposes:
switch (config.OperationType)
{
Expand All @@ -74,7 +77,7 @@ public JobResult ProcessJob(ManagementJobConfiguration config)
}
catch (Exception ex)
{
logger.LogError($"Exception for {config.Capability}: {FortigateException.FlattenExceptionMessages(ex, string.Empty)} for job id {config.JobId}");
logger.LogError($"Exception for {config.Capability}: {FortigateException.FlattenExceptionMessages(ex, string.Empty)} for job id {config.JobId} ");
return new JobResult() { Result = OrchestratorJobStatusJobResult.Failure, JobHistoryId = config.JobHistoryId, FailureMessage = FortigateException.FlattenExceptionMessages(ex, $"Site {config.CertificateStoreDetails.ClientMachine}:") };
}

Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,18 @@

## Overview

The Fortigate Orchestrator Extension supports the following use cases:
1. Inventory of local user and factory cerificates
2. Ability to add new local certificates
3. Ability to replace bound* and unbound local user certificates (usually after renewal in Keyfactor Command)
4. Ability to delete **unbound** local user certificates
The Fortigate Orchestrator Extension supports the following use cases against a specified VDOM:
1. Inventory of local user and factory VDOM and globally scoped cerificates

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling: cerificates should be certificates.

Suggested change
1. Inventory of local user and factory VDOM and globally scoped cerificates
1. Inventory of local user and factory VDOM and globally scoped certificates

Copilot uses AI. Check for mistakes.
2. Ability to add new local VDOM scoped certificates
3. Ability to replace bound* and unbound local user VDOM scoped certificates
4. Ability to delete **unbound** local user VDOM scoped certificates

The Fortigate Orchestrator Extension DOES NOT support the following use cases:
1. The renewal or removal of certificates enrolled through the internal Fortigate CA
2. The renewal or removal of factory certificates
3. The removal of ANY certificate bound to a Fortigate object
4. Certificate enrollment using the internal Fortigate CA (Keyfactor's "reenrollment" or "on device key generation" use case)
4. The renewal/replacement of any globally scoped certificate.
5. Certificate enrollment using the internal Fortigate CA (Keyfactor's "reenrollment" or "on device key generation" use case)

\* Because the Fortigate API does not allow for updating certificates in place, and to avoid temporary outages, when replacing local certificates that are bound, it is necessary to create a new name (alias) for the certificate. The new name is created using the first 8 characters of the previous name (larger names truncated due to Fortigate name length constraints) allong with a suffix comprised of "--" and a 15 character hash of the current date/time. The replaced certificate with the old name is then removed from the Fortigate instance. For example, a bound certificate with the name "CertName" would be replaced and the name would then be "CertName--8DD76A97A98E4C1". The existing bindings would remain in place with the new name. At no point during the management job would any of the bound objects be left without a valid certificate binding.
Currently, the ability to renew bound certificates is limited to these binding types:
Expand Down Expand Up @@ -172,15 +173,14 @@ the Keyfactor Command Portal

1. **Download the latest Fortigate Universal Orchestrator extension from GitHub.**

Navigate to the [Fortigate Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/fortigate-orchestrator/releases/latest). Refer to the compatibility matrix below to determine whether the `net6.0` or `net8.0` asset should be downloaded. Then, click the corresponding asset to download the zip archive.
Navigate to the [Fortigate Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/fortigate-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive.

| Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `fortigate-orchestrator` .NET version to download |
| --------- | ----------- | ----------- | ----------- |
| Older than `11.0.0` | | | `net6.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net6.0` | | `net6.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |
| `11.6` _and_ newer | `net8.0` | | `net8.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |

Copilot AI Apr 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Markdown table row contains || and appears to have two rows merged into one line, which breaks table rendering. Split this into two separate table rows so the compatibility matrix renders correctly.

Suggested change
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` || Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `Disable` | `net6.0` |
| Between `11.0.0` and `11.5.1` (inclusive) | `net8.0` | `LatestMajor` | `net8.0` |

Copilot uses AI. Check for mistakes.
| `11.6` _and_ newer | `net8.0` | | `net8.0` |

Unzip the archive containing extension assemblies to a known location.

Expand Down Expand Up @@ -237,7 +237,7 @@ the Keyfactor Command Portal
| Category | Select "Fortigate" or the customized certificate store name from the previous step. |
| Container | Optional container to associate certificate store with. |
| Client Machine | The IP address or DNS of the Fortigate server |
| Store Path | This is not used in this integration, but is a required field in the UI. Just enter any value here |
| Store Path | Value must contain the VDOM this certificate store will be managing. `root` must be entered to manage the default 'root' VDOM. |
| Store Password | Enter the Fortigate API Token here |
| Orchestrator | Select an approved orchestrator capable of managing `Fortigate` certificates. Specifically, one with the `Fortigate` capability. |

Expand All @@ -263,7 +263,7 @@ the Keyfactor Command Portal
| Category | Select "Fortigate" or the customized certificate store name from the previous step. |
| Container | Optional container to associate certificate store with. |
| Client Machine | The IP address or DNS of the Fortigate server |
| Store Path | This is not used in this integration, but is a required field in the UI. Just enter any value here |
| Store Path | Value must contain the VDOM this certificate store will be managing. `root` must be entered to manage the default 'root' VDOM. |
| Store Password | Enter the Fortigate API Token here |
| Orchestrator | Select an approved orchestrator capable of managing `Fortigate` certificates. Specifically, one with the `Fortigate` capability. |

Expand Down
Loading