From 38c057a19ff1e218c1f6e48d53ee0300b0cf8e5b Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 4 May 2026 15:39:14 -0400 Subject: [PATCH 01/31] feat: add Discovery job for org-wide GCP cert manager enumeration Adds CertStores.GcpCertMgr.Discovery (IDiscoveryJobExtension) that lists every active GCP project the orchestrator's service account can see via Cloud Resource Manager v3 projects.search and emits one candidate store per (project, location) pair in canonical projects/{p}/locations/{l} form. Discovery scope is bounded by IAM (service account permissioned at the org root, per customer setup), not by query filtering. Also brings the orchestrator up to the canonical hardening pattern: ports FlowLogger.cs from the reference repo, adds JobBase.cs with IPAMSecretResolver injection + GoogleApiException unwrapping, and retrofits Inventory and Management onto the shared base. Job result FailureMessage now carries the flow summary on every path so operators can see what happened from job history alone. Bumps Keyfactor.Orchestrators.IOrchestratorJobExtensions 0.6.0 -> 0.7.0 to pick up IPAMSecretResolver. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 + CHANGELOG.md | 43 +- .../Client/GcpCertificateManagerClient.cs | 56 +- GcpCertManager/FlowLogger.cs | 243 ++++++++ GcpCertManager/GcpCertManager.csproj | 3 +- GcpCertManager/Jobs/Discovery.cs | 234 ++++++++ GcpCertManager/Jobs/Inventory.cs | 239 ++++---- GcpCertManager/Jobs/JobBase.cs | 143 +++++ GcpCertManager/Jobs/Management.cs | 540 +++++++----------- GcpCertManager/manifest.json | 4 + docsource/content.md | 4 +- docsource/gcpcertmgr.md | 44 ++ 12 files changed, 1074 insertions(+), 484 deletions(-) create mode 100644 GcpCertManager/FlowLogger.cs create mode 100644 GcpCertManager/Jobs/Discovery.cs create mode 100644 GcpCertManager/Jobs/JobBase.cs diff --git a/.gitignore b/.gitignore index dfcfd56..f27f50f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# Local credentials and per-machine IDE state - never commit +.secrets/ +*.env +.claude/ + # User-specific files *.rsuser *.suo diff --git a/CHANGELOG.md b/CHANGELOG.md index f760407..b111262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,47 @@ +v1.2.0 - unreleased +- Added Discovery job (`CertStores.GcpCertMgr.Discovery`) that enumerates all + GCP projects accessible to the orchestrator's service account and emits one + candidate store path per (project, location) pair in canonical + `projects/{projectId}/locations/{location}` form. + - Discovery-job ClientMachine is interpreted as the GCP Organization ID for + logging only; the actual project set is bounded by the service account's + IAM bindings (the customer scopes that at the org root). + - "Directories to search" is repurposed as a comma-separated list of GCP + locations (regions); defaults to `global` when blank. + - Service account credentials default to Application Default Credentials, + matching the recommended deployment on a GCE VM / GKE pod with workload + identity. An optional `ServiceAccountKey` JobProperty (file name relative + to the orchestrator extension dir) is supported for parity with the + inventory/management job configuration. +- Added `FlowLogger` and `JobBase` infrastructure shared across all three jobs + (Inventory, Management, Discovery). FlowLogger captures step-by-step traces + with timing, and the summary is appended to `JobResult.FailureMessage` on + every job result so operators can see what happened from job history alone. +- Added typed exception unwrapping (`JobBase.DescribeException`) that surfaces + `GoogleApiException` HTTP status + ErrorResponseContent through `AggregateException` + walls instead of letting them be flattened into generic `.Message`s. +- Added PAM secret resolution support via `IPAMSecretResolver` injected into + every job constructor. The existing `ServiceAccountKey` store property is a + file path (not a secret) so PAM has no effect on it today, but the plumbing + is in place for future PAM-eligible properties. +- Bumped `Keyfactor.Orchestrators.IOrchestratorJobExtensions` 0.6.0 → 0.7.0 to + pick up `IPAMSecretResolver` (no breaking changes for existing job behavior). +- Added `Google.Apis.CloudResourceManager.v3` package to support the project + enumeration that Discovery requires. + +### Known limitations +- Discovery emits candidate store paths in canonical GCP form; on approval the + operator must set the new store's ClientMachine to the project ID and the + Location custom property to the region (the canonical path encodes both, + but Keyfactor Command does not auto-populate them). This is documented in + `docsource/gcpcertmgr.md`. +- The discovery-job ClientMachine field (Organization ID) is informational; if + the service account has visibility into multiple organizations, Discovery + will emit projects from all of them. Constrain at IAM if that's not desired. + v1.1.0 - Implemented dual build for .net6/8 - Converted README to use doctool v1.0.2 -- Initial Public Version \ No newline at end of file +- Initial Public Version diff --git a/GcpCertManager/Client/GcpCertificateManagerClient.cs b/GcpCertManager/Client/GcpCertificateManagerClient.cs index b41febf..20679ec 100644 --- a/GcpCertManager/Client/GcpCertificateManagerClient.cs +++ b/GcpCertManager/Client/GcpCertificateManagerClient.cs @@ -8,6 +8,7 @@ using System.Reflection; using Google.Apis.Auth.OAuth2; using Google.Apis.CertificateManager.v1; +using Google.Apis.CloudResourceManager.v3; using Google.Apis.Services; using Google.Apis.Iam.v1; using Google.Apis.Iam.v1.Data; @@ -26,29 +27,28 @@ public CertificateManagerService GetGoogleCredentials(string credentialFileName) { ILogger _logger = LogHandler.GetClassLogger(); - //Credentials file needs to be in the same location of the executing assembly - GoogleCredential credentials; + var credentials = LoadCredentials(credentialFileName, _logger); - if (!string.IsNullOrEmpty(credentialFileName)) + var service = new CertificateManagerService(new BaseClientService.Initializer { - _logger.LogDebug("Has credential file name"); - var strExeFilePath = Assembly.GetExecutingAssembly().Location; - var strWorkPath = Path.GetDirectoryName(strExeFilePath); - var strSettingsJsonFilePath = Path.Combine(strWorkPath ?? string.Empty, credentialFileName); + HttpClientInitializer = credentials + }); - var stream = new FileStream(strSettingsJsonFilePath, - FileMode.Open - ); + return service; + } - credentials = GoogleCredential.FromStream(stream); - } - else - { - _logger.LogDebug("No credential file name"); - credentials = GoogleCredential.GetApplicationDefaultAsync().Result; - } + public CloudResourceManagerService GetCloudResourceManager(string credentialFileName) + { + ILogger _logger = LogHandler.GetClassLogger(); - var service = new CertificateManagerService(new BaseClientService.Initializer + // CloudResourceManager.search requires the cloud-platform scope when using ADC + // from a Compute Engine / GKE service account; FromStream credentials carry + // their own scopes from the JSON key. + var credentials = LoadCredentials(credentialFileName, _logger); + if (credentials.IsCreateScopedRequired) + credentials = credentials.CreateScoped(CloudResourceManagerService.Scope.CloudPlatformReadOnly); + + var service = new CloudResourceManagerService(new BaseClientService.Initializer { HttpClientInitializer = credentials }); @@ -56,6 +56,26 @@ public CertificateManagerService GetGoogleCredentials(string credentialFileName) return service; } + private static GoogleCredential LoadCredentials(string credentialFileName, ILogger logger) + { + //Credentials file needs to be in the same location of the executing assembly + if (!string.IsNullOrEmpty(credentialFileName)) + { + logger.LogDebug("Has credential file name"); + var strExeFilePath = Assembly.GetExecutingAssembly().Location; + var strWorkPath = Path.GetDirectoryName(strExeFilePath); + var strSettingsJsonFilePath = Path.Combine(strWorkPath ?? string.Empty, credentialFileName); + + using (var stream = new FileStream(strSettingsJsonFilePath, FileMode.Open)) + { + return GoogleCredential.FromStream(stream); + } + } + + logger.LogDebug("No credential file name"); + return GoogleCredential.GetApplicationDefaultAsync().Result; + } + public ServiceAccountKey CreateServiceAccountKey(string serviceAccountEmail) { GoogleCredential credential = GoogleCredential.GetApplicationDefault().CreateScoped(IamService.Scope.CloudPlatform); diff --git a/GcpCertManager/FlowLogger.cs b/GcpCertManager/FlowLogger.cs new file mode 100644 index 0000000..e67b7bf --- /dev/null +++ b/GcpCertManager/FlowLogger.cs @@ -0,0 +1,243 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.GcpCertManager +{ + public class FlowLogger : IDisposable + { + private readonly ILogger _logger; + private readonly string _flowName; + private readonly Stopwatch _overallStopwatch; + private readonly List _steps = new List(); + private readonly Stack _branchStack = new Stack(); + + public FlowLogger(ILogger logger, string flowName) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _flowName = flowName ?? throw new ArgumentNullException(nameof(flowName)); + _overallStopwatch = Stopwatch.StartNew(); + _logger.LogTrace("[FLOW:{FlowName}] === BEGIN ===", _flowName); + } + + public void Step(string name, string detail = null) + { + var step = new FlowStep { Name = name, Detail = detail, Status = StepStatus.Success }; + _steps.Add(step); + var prefix = GetPrefix(); + if (detail != null) + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} - {Detail}", _flowName, prefix, name, detail); + else + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName}", _flowName, prefix, name); + } + + public void Step(string name, Action action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public async Task StepAsync(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + await action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public T Step(string name, Func action, string detail = null) + { + var sw = Stopwatch.StartNew(); + var step = new FlowStep { Name = name, Detail = detail }; + try + { + var result = action(); + sw.Stop(); + step.Status = StepStatus.Success; + step.ElapsedMs = sw.ElapsedMilliseconds; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[OK] {StepName} ({Elapsed}ms){DetailSuffix}", + _flowName, prefix, name, sw.ElapsedMilliseconds, FormatDetail(detail)); + return result; + } + catch (Exception ex) + { + sw.Stop(); + step.Status = StepStatus.Failed; + step.ElapsedMs = sw.ElapsedMilliseconds; + step.ErrorMessage = ex.Message; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} ({Elapsed}ms) - {Error}", + _flowName, prefix, name, sw.ElapsedMilliseconds, ex.Message); + throw; + } + } + + public void Fail(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Failed, ErrorMessage = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[FAIL] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Skip(string name, string reason) + { + var step = new FlowStep { Name = name, Status = StepStatus.Skipped, Detail = reason }; + _steps.Add(step); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}[SKIP] {StepName} - {Reason}", _flowName, prefix, name, reason); + } + + public void Branch(string name) + { + _branchStack.Push(name); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}>> {BranchName}", _flowName, prefix, name); + } + + public void EndBranch() + { + if (_branchStack.Count > 0) + { + var name = _branchStack.Pop(); + var prefix = GetPrefix(); + _logger.LogTrace("[FLOW:{FlowName}] {Prefix}<< {BranchName}", _flowName, prefix, name); + } + } + + public bool HasFailures => _steps.Any(s => s.Status == StepStatus.Failed); + + public string GetSummary() + { + var hasFailures = HasFailures; + var overallStatus = hasFailures ? "FAILED" : "OK"; + var total = _steps.Count; + var succeeded = _steps.Count(s => s.Status == StepStatus.Success); + var failed = _steps.Count(s => s.Status == StepStatus.Failed); + var skipped = _steps.Count(s => s.Status == StepStatus.Skipped); + var elapsed = _overallStopwatch.ElapsedMilliseconds; + + var sb = new StringBuilder(); + sb.AppendLine($"Flow: {_flowName} [{overallStatus}] Total: {elapsed}ms"); + sb.AppendLine($"Steps: {total} total, {succeeded} ok, {failed} failed, {skipped} skipped"); + sb.AppendLine("----------------------------------------"); + foreach (var step in _steps) + { + var icon = step.Status == StepStatus.Success ? "[OK] " + : step.Status == StepStatus.Failed ? "[FAIL]" + : step.Status == StepStatus.Skipped ? "[SKIP]" + : "[...]"; + var time = step.ElapsedMs.HasValue ? $" ({step.ElapsedMs}ms)" : ""; + var detail = !string.IsNullOrEmpty(step.ErrorMessage) + ? $" - {step.ErrorMessage}" + : !string.IsNullOrEmpty(step.Detail) + ? $" - {step.Detail}" + : ""; + sb.AppendLine($" {icon} {step.Name}{time}{detail}"); + } + sb.Append("----------------------------------------"); + + return sb.ToString(); + } + + public void Dispose() + { + _overallStopwatch.Stop(); + var summary = GetSummary(); + _logger.LogTrace("[FLOW:{FlowName}] === END ===\n{Summary}", _flowName, summary); + } + + private string GetPrefix() + { + if (_branchStack.Count == 0) return ""; + return new string(' ', _branchStack.Count * 2) + "| "; + } + + private static string FormatDetail(string detail) + { + return string.IsNullOrEmpty(detail) ? "" : $" - {detail}"; + } + + private enum StepStatus + { + Success, + Failed, + Skipped, + InProgress + } + + private class FlowStep + { + public string Name { get; set; } + public string Detail { get; set; } + public StepStatus Status { get; set; } = StepStatus.InProgress; + public long? ElapsedMs { get; set; } + public string ErrorMessage { get; set; } + } + } +} diff --git a/GcpCertManager/GcpCertManager.csproj b/GcpCertManager/GcpCertManager.csproj index 6f4a636..1e0f43e 100644 --- a/GcpCertManager/GcpCertManager.csproj +++ b/GcpCertManager/GcpCertManager.csproj @@ -20,10 +20,11 @@ + - + diff --git a/GcpCertManager/Jobs/Discovery.cs b/GcpCertManager/Jobs/Discovery.cs new file mode 100644 index 0000000..e63c5c5 --- /dev/null +++ b/GcpCertManager/Jobs/Discovery.cs @@ -0,0 +1,234 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions +// and limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Google.Apis.CloudResourceManager.v3; +using Google.Apis.CloudResourceManager.v3.Data; +using Keyfactor.Extensions.Orchestrator.GcpCertManager.Client; +using Keyfactor.Logging; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.GcpCertManager.Jobs +{ + public class Discovery : JobBase, IDiscoveryJobExtension + { + // GCP Certificate Manager exposes a "global" location plus a handful of regional + // locations. Most certs live in "global" so default discovery to that. Operators + // can override via the Discovery job's "Directories to search" field with a + // comma-separated list (e.g. "global,us-central1,europe-west1"). + private static readonly HashSet DefaultLocations = + new HashSet(StringComparer.OrdinalIgnoreCase) { "global" }; + + // Keyfactor Command's Discovery form posts the comma-separated "Directories + // to search" value into JobProperties. Try the common key names since the + // exact casing has shifted across Command versions. + private static readonly string[] DirsToSearchKeys = { "dirs", "Dirs", "directories", "Directories", "DirsToSearch" }; + + // Optional override: path to a service account JSON file installed alongside + // the orchestrator extension. When omitted, GoogleCredential.ApplicationDefault + // is used - which is the recommended path when the orchestrator runs on a GCE + // VM / GKE pod with a workload-identity-bound service account. + private const string ServiceAccountKeyProperty = "ServiceAccountKey"; + + public Discovery(IPAMSecretResolver resolver) : base(resolver) + { + Logger = LogHandler.GetClassLogger(); + } + + public string ExtensionName => "GcpCertMgr"; + + public JobResult ProcessJob(DiscoveryJobConfiguration jobConfiguration, + SubmitDiscoveryUpdate submitDiscoveryUpdate) + { + if (jobConfiguration == null) + { + Logger.LogError("ProcessJob called with null jobConfiguration."); + return FailureResult(0, "DiscoveryJobConfiguration is null."); + } + + if (submitDiscoveryUpdate == null) + { + Logger.LogError("ProcessJob called with null submitDiscoveryUpdate."); + return FailureResult(jobConfiguration.JobHistoryId, "SubmitDiscoveryUpdate delegate is null."); + } + + using (var flow = new FlowLogger(Logger, "GcpCertMgr-Discovery")) + { + try + { + Logger.MethodEntry(LogLevel.Debug); + return PerformDiscovery(jobConfiguration, submitDiscoveryUpdate, flow); + } + catch (Exception e) + { + var msg = DescribeException(e); + flow.Fail("ProcessJob", msg); + Logger.LogError(e, "Error In Discovery.ProcessJob: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(jobConfiguration.JobHistoryId, + $"Unknown exception in Discovery: {msg}", flow); + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } + } + } + + private JobResult PerformDiscovery(DiscoveryJobConfiguration config, + SubmitDiscoveryUpdate submitDiscovery, FlowLogger flow) + { + // ClientMachine is interpreted as the GCP Organization ID for logging / + // labeling. The actual project set returned by Search() is controlled by the + // service account's IAM bindings - the customer scopes that at the org root. + var orgIdHint = (config.ClientMachine ?? string.Empty).Trim(); + flow.Step("ParseConfig", $"orgIdHint={(string.IsNullOrEmpty(orgIdHint) ? "" : orgIdHint)}"); + + string serviceAccountKey = null; + flow.Step("ResolveServiceAccountKey", () => + { + if (config.JobProperties != null && + config.JobProperties.TryGetValue(ServiceAccountKeyProperty, out var raw)) + { + var s = raw?.ToString(); + if (!string.IsNullOrWhiteSpace(s)) serviceAccountKey = s.Trim(); + } + }, $"source={(serviceAccountKey == null ? "ADC" : "JobProperties")}"); + + var (locations, locationSource) = ResolveLocations(config); + flow.Step("ResolveLocations", + $"source={locationSource}, locations=[{string.Join(",", locations)}]"); + + CloudResourceManagerService crm = null; + flow.Step("CreateApiClient", () => + { + crm = new GcpCertificateManagerClient().GetCloudResourceManager(serviceAccountKey); + }); + + List projects = null; + flow.Step("ListProjects", () => + { + projects = ListAccessibleProjects(crm, orgIdHint); + }, "filter=state=ACTIVE"); + + var activeProjects = projects ?? new List(); + flow.Step("ProjectsCount", $"count={activeProjects.Count}"); + + var discoveredLocations = new List(); + + if (activeProjects.Count == 0) + { + flow.Skip("EmitStorePaths", "no accessible projects returned"); + } + else + { + flow.Branch($"PerProject (projects={activeProjects.Count}, locations={locations.Count})"); + try + { + foreach (var project in activeProjects) + { + var projectId = project?.ProjectId; + if (string.IsNullOrWhiteSpace(projectId)) + { + flow.Skip("Project", "missing projectId"); + continue; + } + + foreach (var location in locations) + { + // Canonical GCP resource name. Operators approving the discovered + // store will need to set the store's ClientMachine to {projectId} + // and the Location custom property to {location} - documented in + // docsource/gcpcertmgr.md. + var storePath = $"projects/{projectId}/locations/{location}"; + discoveredLocations.Add(storePath); + flow.Step($"Discovered-{storePath}"); + } + } + } + finally + { + flow.EndBranch(); + } + } + + flow.Step("SubmitDiscovery", () => submitDiscovery.Invoke(discoveredLocations), + $"locationCount={discoveredLocations.Count}"); + + flow.Step("Result", $"SUCCESS - {discoveredLocations.Count} locations discovered"); + return SuccessResult(config.JobHistoryId, flow.GetSummary()); + } + + private static (HashSet Locations, string Source) ResolveLocations(DiscoveryJobConfiguration config) + { + if (config?.JobProperties != null) + { + foreach (var key in DirsToSearchKeys) + { + if (!config.JobProperties.TryGetValue(key, out var raw)) continue; + var s = raw?.ToString(); + if (string.IsNullOrWhiteSpace(s)) continue; + + var locations = new HashSet( + s.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries) + .Select(d => d.Trim().Trim('/')) + .Where(d => d.Length > 0), + StringComparer.OrdinalIgnoreCase); + + if (locations.Count > 0) + return (locations, $"user (key={key})"); + } + } + + return (DefaultLocations, "default"); + } + + private List ListAccessibleProjects(CloudResourceManagerService crm, string orgIdHint) + { + // Projects.Search() returns every active project the calling identity can + // see across the organization, including those nested in folders. The + // customer's service account is permissioned at the org root so this is the + // correct boundary - tightening (or loosening) is done in IAM, not here. + // + // orgIdHint is logged but not used as a query filter: the v3 query syntax + // `parent:organizations/{id}` only matches direct children, missing every + // project that lives under a folder. Filtering by parent ancestry from the + // client side requires N additional GetAncestry calls per project, which + // doesn't scale and isn't necessary when IAM already constrains the result. + var ids = new List(); + string nextPageToken = null; + do + { + var req = crm.Projects.Search(); + req.Query = "state:ACTIVE"; + req.PageSize = 100; + if (!string.IsNullOrEmpty(nextPageToken)) req.PageToken = nextPageToken; + + var resp = req.Execute(); + if (resp?.Projects != null) + { + ids.AddRange(resp.Projects.Where(p => + p != null && + !string.IsNullOrWhiteSpace(p.ProjectId) && + string.Equals(p.State, "ACTIVE", StringComparison.OrdinalIgnoreCase))); + } + nextPageToken = resp?.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); + + if (!string.IsNullOrEmpty(orgIdHint)) + { + Logger.LogTrace("Discovery returned {Count} accessible projects (orgIdHint={OrgIdHint}; not used as a query filter, see ListAccessibleProjects).", + ids.Count, orgIdHint); + } + + return ids; + } + } +} diff --git a/GcpCertManager/Jobs/Inventory.cs b/GcpCertManager/Jobs/Inventory.cs index b3d6165..7ed5252 100644 --- a/GcpCertManager/Jobs/Inventory.cs +++ b/GcpCertManager/Jobs/Inventory.cs @@ -8,152 +8,156 @@ using System.Collections.Generic; using System.Linq; using System.Text; -using Google; using Google.Apis.CertificateManager.v1; +using Google.Apis.CertificateManager.v1.Data; using Keyfactor.Extensions.Orchestrator.GcpCertManager.Client; using Keyfactor.Logging; using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace Keyfactor.Extensions.Orchestrator.GcpCertManager.Jobs { - public class Inventory : IInventoryJobExtension + public class Inventory : JobBase, IInventoryJobExtension { - private readonly ILogger _logger; - - public Inventory(ILogger logger) + public Inventory(IPAMSecretResolver resolver) : base(resolver) { - _logger = logger; + Logger = LogHandler.GetClassLogger(); } - public string ExtensionName => ""; + public string ExtensionName => "GcpCertMgr"; public JobResult ProcessJob(InventoryJobConfiguration jobConfiguration, SubmitInventoryUpdate submitInventoryUpdate) { - try + if (jobConfiguration == null) { - _logger.MethodEntry(); - return PerformInventory(jobConfiguration, submitInventoryUpdate); + Logger.LogError("ProcessJob called with null jobConfiguration."); + return FailureResult(0, "InventoryJobConfiguration is null."); } - catch (Exception e) + + if (submitInventoryUpdate == null) { - _logger.LogError($"Error occured in Inventory.ProcessJob: {LogHandler.FlattenException(e)}"); - throw; + Logger.LogError("ProcessJob called with null submitInventoryUpdate."); + return FailureResult(jobConfiguration.JobHistoryId, "SubmitInventoryUpdate delegate is null."); + } + + using (var flow = new FlowLogger(Logger, "GcpCertMgr-Inventory")) + { + try + { + Logger.MethodEntry(LogLevel.Debug); + return PerformInventory(jobConfiguration, submitInventoryUpdate, flow); + } + catch (Exception e) + { + var msg = DescribeException(e); + flow.Fail("ProcessJob", msg); + Logger.LogError(e, "Error in Inventory.ProcessJob: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(jobConfiguration.JobHistoryId, + $"Inventory failed: {msg}", flow); + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } } } - private JobResult PerformInventory(InventoryJobConfiguration config, SubmitInventoryUpdate submitInventory) + private JobResult PerformInventory(InventoryJobConfiguration config, + SubmitInventoryUpdate submitInventory, FlowLogger flow) { - try + StoreProperties storeProperties = null; + flow.Step("ParseStoreProperties", () => { - _logger.MethodEntry(LogLevel.Debug); - - StoreProperties storeProperties = JsonConvert.DeserializeObject(config.CertificateStoreDetails.Properties, - new JsonSerializerSettings {DefaultValueHandling = DefaultValueHandling.Populate}); + storeProperties = JsonConvert.DeserializeObject( + config.CertificateStoreDetails.Properties, + new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate }); storeProperties.ProjectId = config.CertificateStoreDetails.ClientMachine; + }, $"projectId={config.CertificateStoreDetails.ClientMachine}"); - _logger.LogTrace($"Store Properties:"); - _logger.LogTrace($" Location: {storeProperties.Location}"); - _logger.LogTrace($" Project Id: {storeProperties.ProjectId}"); - _logger.LogTrace($" Service Account Key Path: {storeProperties.ServiceAccountKey}"); + Logger.LogTrace("Store Properties:"); + Logger.LogTrace(" Location: {Location}", storeProperties.Location); + Logger.LogTrace(" Project Id: {ProjectId}", storeProperties.ProjectId); + // ServiceAccountKey is a file PATH, not a secret value, so it is fine to log. + Logger.LogTrace(" Service Account Key Path: {ServiceAccountKey}", storeProperties.ServiceAccountKey); - _logger.LogTrace("Getting Credentials from Google..."); - var svc = new GcpCertificateManagerClient().GetGoogleCredentials(storeProperties.ServiceAccountKey); - _logger.LogTrace("Got Credentials from Google"); + CertificateManagerService svc = null; + flow.Step("GetGoogleCredentials", () => + { + svc = new GcpCertificateManagerClient().GetGoogleCredentials(storeProperties.ServiceAccountKey); + }, $"source={(string.IsNullOrEmpty(storeProperties.ServiceAccountKey) ? "ADC" : "file")}"); - var warningFlag = false; - var sb = new StringBuilder(); - sb.Append(""); - var inventoryItems = new List(); - var nextPageToken = string.Empty; + var warningFlag = false; + var sb = new StringBuilder(); + var inventoryItems = new List(); + var nextPageToken = string.Empty; + var storePath = $"projects/{storeProperties.ProjectId}/locations/{storeProperties.Location}"; - //todo support labels - var storePath = $"projects/{storeProperties.ProjectId}/locations/{storeProperties.Location}"; + flow.Step("StorePathResolved", $"storePath={storePath}"); - do + var pageCount = 0; + do + { + pageCount++; + ListCertificatesResponse certificatesResponse = null; + var token = nextPageToken; + flow.Step($"ListCertificates-page{pageCount}", () => { - var certificatesRequest = - svc.Projects.Locations.Certificates.List(storePath); + var certificatesRequest = svc.Projects.Locations.Certificates.List(storePath); certificatesRequest.Filter = "pemCertificate!=\"\""; certificatesRequest.PageSize = 100; - if (nextPageToken?.Length > 0) certificatesRequest.PageToken = nextPageToken; - - var certificatesResponse = certificatesRequest.Execute(); - _logger.LogTrace( - $"certificatesResponse: {JsonConvert.SerializeObject(certificatesResponse)}"); - - nextPageToken = null; - //Debug Write Certificate List Response from Google Cert Manager - if (certificatesResponse?.Certificates != null) - inventoryItems.AddRange(certificatesResponse.Certificates.Select( - c => - { - try - { - _logger.LogTrace( - $"Building Cert List Inventory Item Alias: {c.Name} Pem: {c.PemCertificate} Private Key: dummy (from PA API)"); - return BuildInventoryItem(c.Name, c.PemCertificate, - true, storePath, svc); - } - catch - { - _logger.LogWarning( - $"Could not fetch the certificate: {c?.Name} associated with description {c?.Description}."); - sb.Append( - $"Could not fetch the certificate: {c?.Name} associated with issuer {c?.Description}.{Environment.NewLine}"); - warningFlag = true; - return new CurrentInventoryItem(); - } - }).Where(acsii => acsii?.Certificates != null).ToList()); - - nextPageToken = certificatesResponse.NextPageToken; - } while (nextPageToken?.Length > 0); - - _logger.LogTrace("Submitting Inventory To Keyfactor via submitInventory.Invoke"); - submitInventory.Invoke(inventoryItems); - _logger.LogTrace("Submitted Inventory To Keyfactor via submitInventory.Invoke"); - - _logger.MethodExit(LogLevel.Debug); - if (warningFlag) + if (!string.IsNullOrEmpty(token)) certificatesRequest.PageToken = token; + + certificatesResponse = certificatesRequest.Execute(); + }); + + Logger.LogTrace("certificatesResponse: {Response}", JsonConvert.SerializeObject(certificatesResponse)); + + nextPageToken = null; + if (certificatesResponse?.Certificates != null) { - _logger.LogTrace("Found Warning"); - return new JobResult + foreach (var c in certificatesResponse.Certificates) { - Result = OrchestratorJobStatusJobResult.Warning, - JobHistoryId = config.JobHistoryId, - FailureMessage = sb.ToString() - }; + try + { + Logger.LogTrace( + "Building Cert List Inventory Item Alias: {Name} Pem: {Pem} Private Key: dummy (from PA API)", + c.Name, c.PemCertificate); + var item = BuildInventoryItem(c.Name, c.PemCertificate, true, storePath, svc); + if (item?.Certificates != null) + inventoryItems.Add(item); + } + catch (Exception inner) + { + Logger.LogWarning("Could not fetch the certificate: {Name} associated with description {Description}. {Error}", + c?.Name, c?.Description, DescribeException(inner)); + sb.AppendLine($"Could not fetch the certificate: {c?.Name} associated with issuer {c?.Description}."); + warningFlag = true; + } + } } - _logger.LogTrace("Return Success"); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Success, - JobHistoryId = config.JobHistoryId, - FailureMessage = sb.ToString() - }; - } - catch (GoogleApiException e) - { - var googleError = e.Error?.ErrorResponseContent + " " + LogHandler.FlattenException(e); + nextPageToken = certificatesResponse?.NextPageToken; + } while (!string.IsNullOrEmpty(nextPageToken)); - _logger.LogError($"PerformInventory Error: {LogHandler.FlattenException(e)}"); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = - $"Management/Add {googleError}" - }; - } - catch (Exception e) + flow.Step("SubmitInventory", () => submitInventory.Invoke(inventoryItems), + $"itemCount={inventoryItems.Count}"); + + // Per the playbook: append the flow summary on BOTH success and warning paths + // so operators reading job history can see what happened either way. + var summary = flow.GetSummary(); + + if (warningFlag) { - _logger.LogError($"PerformInventory Error: {LogHandler.FlattenException(e)}"); - throw; + flow.Step("Result", "WARNING - some certificates could not be fetched"); + return WarningResult(config.JobHistoryId, $"{sb}\n\n{summary}"); } + + flow.Step("Result", $"SUCCESS - {inventoryItems.Count} certificates"); + return SuccessResult(config.JobHistoryId, summary); } protected virtual CurrentInventoryItem BuildInventoryItem(string alias, string certPem, bool privateKey, @@ -161,31 +165,29 @@ protected virtual CurrentInventoryItem BuildInventoryItem(string alias, string c { try { - _logger.MethodEntry(); - _logger.LogTrace($"Alias: {alias} Pem: {certPem} PrivateKey: {privateKey}"); + Logger.MethodEntry(); + Logger.LogTrace("Alias: {Alias} Pem: {Pem} PrivateKey: {PrivateKey}", alias, certPem, privateKey); - //1. Look up certificate map entries based on certificate name var certAttributes = GetCertificateAttributes(storePath); var modAlias = alias.Split('/')[5]; - - _logger.LogTrace($"Got modAlias: {modAlias}"); + Logger.LogTrace("Got modAlias: {ModAlias}", modAlias); var acsi = new CurrentInventoryItem { Alias = modAlias, - Certificates = new[] {certPem}, + Certificates = new[] { certPem }, ItemStatus = OrchestratorInventoryItemStatus.Unknown, PrivateKeyEntry = privateKey, UseChainLevel = false, Parameters = certAttributes }; - _logger.MethodExit(); + Logger.MethodExit(); return acsi; } catch (Exception e) { - _logger.LogError($"Error Occurred in Inventory.BuildInventoryItem: {LogHandler.FlattenException(e)}"); + Logger.LogError("Error Occurred in Inventory.BuildInventoryItem: {Error}", LogHandler.FlattenException(e)); throw; } } @@ -194,24 +196,23 @@ protected Dictionary GetCertificateAttributes(string storePath) { try { - _logger.MethodEntry(); - _logger.LogTrace($"Store Path: {storePath}"); + Logger.MethodEntry(); + Logger.LogTrace("Store Path: {StorePath}", storePath); var locationName = storePath.Split('/')[3]; var siteSettingsDict = new Dictionary { - {"Location", locationName} + { "Location", locationName } }; - _logger.MethodExit(); + Logger.MethodExit(); return siteSettingsDict; } catch (Exception e) { - _logger.LogError( - $"Error Occurred in Inventory.GetCertificateAttributes: {LogHandler.FlattenException(e)}"); + Logger.LogError("Error Occurred in Inventory.GetCertificateAttributes: {Error}", LogHandler.FlattenException(e)); throw; } } } -} \ No newline at end of file +} diff --git a/GcpCertManager/Jobs/JobBase.cs b/GcpCertManager/Jobs/JobBase.cs new file mode 100644 index 0000000..a0d0354 --- /dev/null +++ b/GcpCertManager/Jobs/JobBase.cs @@ -0,0 +1,143 @@ +// Copyright 2026 Keyfactor +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using Google; +using Keyfactor.Orchestrators.Common.Enums; +using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.Orchestrator.GcpCertManager.Jobs +{ + /// + /// Shared plumbing for GCP Certificate Manager orchestrator jobs. Provides PAM + /// resolution with warn-on-empty fallback, JobResult helpers that append the + /// summary, and exception unwrapping that surfaces + /// details (HTTP status + response body). + /// + public abstract class JobBase + { + protected ILogger Logger; + protected readonly IPAMSecretResolver Resolver; + + protected JobBase(IPAMSecretResolver resolver) + { + Resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + } + + /// + /// Resolves a PAM-eligible field. Returns the value as-is when it is null/empty + /// (with a warning), otherwise hands it to the PAM resolver. This avoids passing + /// empty strings into PAM providers which often misinterpret them as keys. + /// + protected string ResolvePamField(string name, string value) + { + Logger.LogTrace("Attempting to resolve PAM eligible field {FieldName}", name); + if (string.IsNullOrWhiteSpace(value)) + { + Logger.LogWarning("PAM field {FieldName} has a null/empty value, returning as-is.", name); + return value; + } + return Resolver.Resolve(value); + } + + protected static JobResult SuccessResult(long jobHistoryId, string message = "") + { + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Success, + JobHistoryId = jobHistoryId, + FailureMessage = message ?? "" + }; + } + + protected static JobResult WarningResult(long jobHistoryId, string message) + { + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Warning, + JobHistoryId = jobHistoryId, + FailureMessage = message ?? "" + }; + } + + protected static JobResult FailureResult(long jobHistoryId, string message, FlowLogger flow = null) + { + var combined = message ?? "Unknown error"; + if (flow != null) + { + combined = $"{combined}\n\n{flow.GetSummary()}"; + } + return new JobResult + { + Result = OrchestratorJobStatusJobResult.Failure, + JobHistoryId = jobHistoryId, + FailureMessage = combined + }; + } + + /// + /// Unwraps an exception chain and produces a human-readable description. When a + /// is anywhere in the chain (including inside an + /// ), prefer its HTTP status + error response + /// content over the generic .Message - operators need to see what GCP + /// actually returned (quota errors, IAM denials, malformed certs, etc). + /// + protected static string DescribeException(Exception ex) + { + if (ex == null) return "Unknown error"; + + var apiEx = FindGoogleApiException(ex); + if (apiEx != null) + { + var body = string.IsNullOrWhiteSpace(apiEx.Error?.ErrorResponseContent) + ? string.Empty + : $" - body: {Trim(apiEx.Error.ErrorResponseContent, 500)}"; + return $"GCP API error: HTTP {(int)apiEx.HttpStatusCode} {apiEx.HttpStatusCode}{body}"; + } + + if (ex is AggregateException agg && agg.InnerExceptions.Count > 0) + { + return agg.InnerExceptions[0].Message; + } + + return ex.InnerException?.Message ?? ex.Message; + } + + private static GoogleApiException FindGoogleApiException(Exception ex) + { + for (var cur = ex; cur != null; cur = cur.InnerException) + { + if (cur is GoogleApiException g) return g; + if (cur is AggregateException agg) + { + foreach (var inner in agg.InnerExceptions) + { + var found = FindGoogleApiException(inner); + if (found != null) return found; + } + } + } + return null; + } + + private static string Trim(string s, int maxLen) + { + if (string.IsNullOrEmpty(s)) return s; + if (s.Length <= maxLen) return s; + return s.Substring(0, maxLen) + "..."; + } + } +} diff --git a/GcpCertManager/Jobs/Management.cs b/GcpCertManager/Jobs/Management.cs index 29da573..79cf821 100644 --- a/GcpCertManager/Jobs/Management.cs +++ b/GcpCertManager/Jobs/Management.cs @@ -1,33 +1,30 @@ -// Copyright 2025 Keyfactor +// Copyright 2025 Keyfactor // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. // You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions // and limitations under the License. using System; -using System.Collections.Generic; -using System.Drawing; using System.IO; using System.Linq; using System.Text; -using Google; +using System.Threading; using Google.Apis.CertificateManager.v1; using Google.Apis.CertificateManager.v1.Data; using Keyfactor.Extensions.Orchestrator.GcpCertManager.Client; using Keyfactor.Logging; using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; +using Keyfactor.Orchestrators.Extensions.Interfaces; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Org.BouncyCastle.Crypto; using Org.BouncyCastle.OpenSsl; using Org.BouncyCastle.Pkcs; -using Org.BouncyCastle.X509; -using static Org.BouncyCastle.Math.EC.ECCurve; namespace Keyfactor.Extensions.Orchestrator.GcpCertManager.Jobs { - public class Management : IManagementJobExtension + public class Management : JobBase, IManagementJobExtension { private static readonly string certStart = "-----BEGIN CERTIFICATE-----\n"; private static readonly string certEnd = "\n-----END CERTIFICATE-----"; @@ -38,433 +35,288 @@ public class Management : IManagementJobExtension private static readonly Func Pemify = ss => ss.Length <= 64 ? ss : ss.Substring(0, 64) + "\n" + Pemify(ss.Substring(64)); - private readonly ILogger _logger; - - public Management(ILogger logger) - { - _logger = logger; - } - protected internal virtual AsymmetricKeyEntry KeyEntry { get; set; } - protected internal string CertificateName { get; set; } - public string ExtensionName => ""; + public Management(IPAMSecretResolver resolver) : base(resolver) + { + Logger = LogHandler.GetClassLogger(); + } + public string ExtensionName => "GcpCertMgr"; public JobResult ProcessJob(ManagementJobConfiguration jobConfiguration) { - try + if (jobConfiguration == null) { - _logger.MethodEntry(LogLevel.Debug); - - return PerformManagement(jobConfiguration); + Logger.LogError("ProcessJob called with null jobConfiguration."); + return FailureResult(0, "ManagementJobConfiguration is null."); } - catch (Exception e) + + using (var flow = new FlowLogger(Logger, "GcpCertMgr-Management")) { - _logger.LogError($"Error Occurred in Management.ProcessJob: {LogHandler.FlattenException(e)}"); - throw; + try + { + Logger.MethodEntry(LogLevel.Debug); + return PerformManagement(jobConfiguration, flow); + } + catch (Exception e) + { + var msg = DescribeException(e); + flow.Fail("ProcessJob", msg); + Logger.LogError(e, "Error in Management.ProcessJob: {ErrorMessage}", LogHandler.FlattenException(e)); + return FailureResult(jobConfiguration.JobHistoryId, + $"Management failed: {msg}", flow); + } + finally + { + Logger.MethodExit(LogLevel.Debug); + } } } - private JobResult PerformManagement(ManagementJobConfiguration config) + private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogger flow) { - try + StoreProperties storeProperties = null; + flow.Step("ParseStoreProperties", () => { - _logger.MethodEntry(); - - StoreProperties storeProperties = JsonConvert.DeserializeObject(config.CertificateStoreDetails.Properties, + storeProperties = JsonConvert.DeserializeObject( + config.CertificateStoreDetails.Properties, new JsonSerializerSettings { DefaultValueHandling = DefaultValueHandling.Populate }); storeProperties.ProjectId = config.CertificateStoreDetails.ClientMachine; + }, $"projectId={config.CertificateStoreDetails.ClientMachine}"); - _logger.LogTrace($"Store Properties:"); - _logger.LogTrace($" Location: {storeProperties.Location}"); - _logger.LogTrace($" Project Id: {storeProperties.ProjectId}"); - _logger.LogTrace($" Service Account Key Path: {storeProperties.ServiceAccountKey}"); + Logger.LogTrace("Store Properties:"); + Logger.LogTrace(" Location: {Location}", storeProperties.Location); + Logger.LogTrace(" Project Id: {ProjectId}", storeProperties.ProjectId); + Logger.LogTrace(" Service Account Key Path: {ServiceAccountKey}", storeProperties.ServiceAccountKey); - _logger.LogTrace("Getting Credentials from Google..."); - var svc = new GcpCertificateManagerClient().GetGoogleCredentials(storeProperties.ServiceAccountKey); - _logger.LogTrace("Got Credentials from Google"); - - var storePath = $"projects/{storeProperties.ProjectId}/locations/{storeProperties.Location}"; - CertificateName = config.JobCertificate.Alias; - - var complete = new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = - "Invalid Management Operation" - }; + CertificateManagerService svc = null; + flow.Step("GetGoogleCredentials", () => + { + svc = new GcpCertificateManagerClient().GetGoogleCredentials(storeProperties.ServiceAccountKey); + }, $"source={(string.IsNullOrEmpty(storeProperties.ServiceAccountKey) ? "ADC" : "file")}"); - switch (config.OperationType) - { - case CertStoreOperationType.Add: - _logger.LogTrace("Adding..."); - complete = PerformAddition(svc, config, storePath); - break; - case CertStoreOperationType.Remove: - _logger.LogTrace("Removing..."); - complete = PerformRemoval(svc, config, storePath); - break; - default: - return complete; - } + var storePath = $"projects/{storeProperties.ProjectId}/locations/{storeProperties.Location}"; + CertificateName = config.JobCertificate.Alias; + flow.Step("StorePathResolved", $"storePath={storePath}, alias={CertificateName}"); - _logger.MethodExit(); - return complete; - } - catch (GoogleApiException e) - { - var googleError = e.Error?.ErrorResponseContent + " " + LogHandler.FlattenException(e); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = - $"Management {googleError}" - }; - } - catch (Exception e) + switch (config.OperationType) { - _logger.LogError($"Error Occurred in Management.PerformManagement: {LogHandler.FlattenException(e)}"); - throw; + case CertStoreOperationType.Add: + flow.Branch("Add"); + try { return PerformAddition(svc, config, storePath, flow); } + finally { flow.EndBranch(); } + case CertStoreOperationType.Remove: + flow.Branch("Remove"); + try { return PerformRemoval(svc, config, storePath, flow); } + finally { flow.EndBranch(); } + default: + flow.Fail("OperationType", $"unsupported: {config.OperationType}"); + return FailureResult(config.JobHistoryId, "Invalid Management Operation", flow); } } + private JobResult PerformRemoval(CertificateManagerService svc, ManagementJobConfiguration config, + string storePath, FlowLogger flow) + { + flow.Step("DeleteCertificate", () => DeleteCertificate(CertificateName, svc, storePath, flow)); + flow.Step("Result", "SUCCESS - certificate removed"); + return SuccessResult(config.JobHistoryId, flow.GetSummary()); + } - private JobResult PerformRemoval(CertificateManagerService svc, ManagementJobConfiguration config, string storePath) + private JobResult PerformAddition(CertificateManagerService svc, ManagementJobConfiguration config, + string storePath, FlowLogger flow) { - try - { - _logger.MethodEntry(); + var duplicate = false; + flow.Step("CheckForDuplicate", () => duplicate = CheckForDuplicate(storePath, CertificateName, svc), + $"alias={CertificateName}"); + Logger.LogTrace("Duplicate? = {Duplicate}", duplicate); - DeleteCertificate(CertificateName, svc, storePath); + if (duplicate && !config.Overwrite) + { + flow.Fail("DuplicateGuard", + $"alias '{config.JobCertificate.Alias}' exists; overwrite flag was not set"); + return FailureResult(config.JobHistoryId, + $"Duplicate alias {config.JobCertificate.Alias} found in Google Certificate Manager. To overwrite use the overwrite flag.", + flow); + } - _logger.MethodExit(); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Success, - JobHistoryId = config.JobHistoryId, - FailureMessage = "" - }; + if (string.IsNullOrWhiteSpace(config.JobCertificate.PrivateKeyPassword)) + { + // Existing behaviour: this orchestrator only handles PFX entries with a + // private key password. Surface this clearly rather than silently no-op. + flow.Fail("PrivateKeyPassword", "missing - this orchestrator only supports PFX entries with a private key password"); + return FailureResult(config.JobHistoryId, + "Management/Add requires a PFX certificate with a private key password.", flow); } - catch (GoogleApiException e) + + if (string.IsNullOrWhiteSpace(config.JobCertificate.Alias)) { - var googleError = e.Error?.ErrorResponseContent + " " + LogHandler.FlattenException(e); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = - $"Management/Remove {googleError}" - }; + Logger.LogTrace("No Alias Found"); } - catch (Exception e) + + // Load PFX + Pkcs12Store p = null; + flow.Step("LoadPkcs12", () => { - return new JobResult + var pfxBytes = Convert.FromBase64String(config.JobCertificate.Contents); + using (var pfxBytesMemoryStream = new MemoryStream(pfxBytes)) { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = $"Management/Remove: {LogHandler.FlattenException(e)}" - }; - } - } + p = new Pkcs12Store(pfxBytesMemoryStream, + config.JobCertificate.PrivateKeyPassword.ToCharArray()); + } + }); + Logger.LogTrace("Created Pkcs12Store containing Alias {Alias} Contains Alias is {Contains}", + config.JobCertificate.Alias, p.ContainsAlias(config.JobCertificate.Alias)); - private JobResult PerformAddition(CertificateManagerService svc, ManagementJobConfiguration config, string storePath) - { - //Temporarily only performing additions - try + // Extract private key + string alias = null; + string privateKeyString = null; + flow.Step("ExtractPrivateKey", () => { - _logger.MethodEntry(); + using (var memoryStream = new MemoryStream()) + using (TextWriter streamWriter = new StreamWriter(memoryStream)) + { + var pemWriter = new PemWriter(streamWriter); + alias = p.Aliases.Cast().SingleOrDefault(a => p.IsKeyEntry(a)); + Logger.LogTrace("Alias = {Alias}", alias); + var publicKey = p.GetCertificate(alias).Certificate.GetPublicKey(); + KeyEntry = p.GetKey(alias); + if (KeyEntry == null) throw new Exception("Unable to retrieve private key"); + + var privateKey = KeyEntry.Key; + var keyPair = new AsymmetricCipherKeyPair(publicKey, privateKey); + + pemWriter.WriteObject(keyPair.Private); + streamWriter.Flush(); + privateKeyString = Encoding.ASCII.GetString(memoryStream.GetBuffer()).Trim() + .Replace("\r", "").Replace("\0", ""); + } + }); - var client = new GcpCertificateManagerClient(); + var pubCertPem = Pemify(Convert.ToBase64String(p.GetCertificate(alias).Certificate.GetEncoded())); + // Don't log private key material - only the public chain + alias. + Logger.LogTrace("Public cert PEM extracted for alias {Alias}", alias); - var duplicate = CheckForDuplicate(storePath, CertificateName, svc); - _logger.LogTrace($"Duplicate? = {duplicate}"); + // Note: certPem includes the (decrypted) private key. It is intentionally NOT + // logged. The variable is retained because the legacy code computed it inline; + // the actual upload below uses pubCertPem + privateKeyString separately. + var certPem = privateKeyString + certStart + pubCertPem + certEnd; + _ = certPem; - //Check for Duplicate already in Google Certificate Manager, if there, make sure the Overwrite flag is checked before replacing - if (duplicate && config.Overwrite || !duplicate) - { - _logger.LogTrace("Either not a duplicate or overwrite was chosen...."); - if (!string.IsNullOrWhiteSpace(config.JobCertificate.PrivateKeyPassword)) // This is a PFX Entry - { - - if (string.IsNullOrWhiteSpace(config.JobCertificate.Alias)) - _logger.LogTrace("No Alias Found"); - - // Load PFX - var pfxBytes = Convert.FromBase64String(config.JobCertificate.Contents); - Pkcs12Store p; - using (var pfxBytesMemoryStream = new MemoryStream(pfxBytes)) - { - p = new Pkcs12Store(pfxBytesMemoryStream, - config.JobCertificate.PrivateKeyPassword.ToCharArray()); - } - - _logger.LogTrace( - $"Created Pkcs12Store containing Alias {config.JobCertificate.Alias} Contains Alias is {p.ContainsAlias(config.JobCertificate.Alias)}"); - - // Extract private key - string alias; - string privateKeyString; - using (var memoryStream = new MemoryStream()) - { - using (TextWriter streamWriter = new StreamWriter(memoryStream)) - { - _logger.LogTrace("Extracting Private Key..."); - var pemWriter = new PemWriter(streamWriter); - _logger.LogTrace("Created pemWriter..."); - alias = p.Aliases.Cast().SingleOrDefault(a => p.IsKeyEntry(a)); - _logger.LogTrace($"Alias = {alias}"); - var publicKey = p.GetCertificate(alias).Certificate.GetPublicKey(); - _logger.LogTrace($"publicKey = {publicKey}"); - KeyEntry = p.GetKey(alias); - _logger.LogTrace($"KeyEntry = {KeyEntry}"); - if (KeyEntry == null) throw new Exception("Unable to retrieve private key"); - - var privateKey = KeyEntry.Key; - var keyPair = new AsymmetricCipherKeyPair(publicKey, privateKey); - - pemWriter.WriteObject(keyPair.Private); - streamWriter.Flush(); - privateKeyString = Encoding.ASCII.GetString(memoryStream.GetBuffer()).Trim() - .Replace("\r", "").Replace("\0", ""); - memoryStream.Close(); - streamWriter.Close(); - _logger.LogTrace("Finished Extracting Private Key..."); - } - } - - var pubCertPem = - Pemify(Convert.ToBase64String(p.GetCertificate(alias).Certificate.GetEncoded())); - _logger.LogTrace($"Public cert Pem {pubCertPem}"); - - var certPem = privateKeyString + certStart + pubCertPem + certEnd; - - _logger.LogTrace($"Got certPem {certPem}"); - - pubCertPem = $"-----BEGIN CERTIFICATE-----\r\n{pubCertPem}\r\n-----END CERTIFICATE-----"; - - _logger.LogTrace($"Public Cert Pem: {pubCertPem}"); - - //Create the certificate in Google - var gCertificate = new Certificate - { - SelfManaged = new SelfManagedCertificate - { PemCertificate = pubCertPem, PemPrivateKey = privateKeyString }, - Name = CertificateName, - Description = CertificateName, - Scope = "DEFAULT" //Scope does not come back in inventory so just hard code it for now - }; - - _logger.LogTrace( - $"Created Google Certificate Object: {JsonConvert.SerializeObject(gCertificate)}"); - - if (duplicate && config.Overwrite) - ReplaceCertificate(gCertificate, svc, storePath); - else - AddCertificate(gCertificate, svc, storePath); - - _logger.MethodExit(); - - //Return success from job - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Success, - JobHistoryId = config.JobHistoryId, - FailureMessage = "" - }; - } - } - _logger.MethodExit(); - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = - $"Duplicate alias {config.JobCertificate.Alias} found in Google Certificate Manager. To overwrite use the overwrite flag." - }; - } - catch (GoogleApiException e) - { - var googleError = e.Error?.ErrorResponseContent + " " + LogHandler.FlattenException(e); - _logger.LogError($"PerformManagement Error: {LogHandler.FlattenException(e)}"); + pubCertPem = $"-----BEGIN CERTIFICATE-----\r\n{pubCertPem}\r\n-----END CERTIFICATE-----"; - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = - $"Management/Add {googleError}" - }; + // Build the GCP certificate object. Don't serialize+log; that would leak the + // private key into trace logs. + var gCertificate = new Certificate + { + SelfManaged = new SelfManagedCertificate { PemCertificate = pubCertPem, PemPrivateKey = privateKeyString }, + Name = CertificateName, + Description = CertificateName, + // Scope does not come back in inventory, so hard-code it. Customers + // running edge-cache stores will need to override this in a future + // store-property if/when that scope becomes used. + Scope = "DEFAULT" + }; + + if (duplicate && config.Overwrite) + { + flow.Step("ReplaceCertificate", () => ReplaceCertificate(gCertificate, svc, storePath, flow)); } - catch (Exception e) + else { - _logger.LogError($"PerformManagement Error: {LogHandler.FlattenException(e)}"); - - return new JobResult - { - Result = OrchestratorJobStatusJobResult.Failure, - JobHistoryId = config.JobHistoryId, - FailureMessage = $"Management/Add {LogHandler.FlattenException(e)}" - }; + flow.Step("AddCertificate", () => AddCertificate(gCertificate, svc, storePath, flow)); } + + flow.Step("Result", duplicate ? "SUCCESS - certificate replaced" : "SUCCESS - certificate added"); + return SuccessResult(config.JobHistoryId, flow.GetSummary()); } - private void AddCertificate(Certificate gCertificate, CertificateManagerService svc, string storePath) + private void AddCertificate(Certificate gCertificate, CertificateManagerService svc, string storePath, FlowLogger flow) { var addCertificateRequest = svc.Projects.Locations.Certificates.Create(gCertificate, storePath); addCertificateRequest.CertificateId = gCertificate.Name; var addCertificateResponse = addCertificateRequest.Execute(); - WaitForOperation(svc, addCertificateResponse.Name); + flow.Step("WaitForOperation-Add", () => WaitForOperation(svc, addCertificateResponse.Name), + $"operation={addCertificateResponse.Name}"); - _logger.LogTrace($"Certificate Created in Google Cert Manager with Name {addCertificateResponse.Name}"); - - _logger.MethodExit(); + Logger.LogTrace("Certificate Created in Google Cert Manager with Name {Name}", addCertificateResponse.Name); } - private void ReplaceCertificate(Certificate gCertificate, CertificateManagerService svc, string storePath) + private void ReplaceCertificate(Certificate gCertificate, CertificateManagerService svc, string storePath, FlowLogger flow) { - _logger.MethodEntry(); - var replaceCertificateRequest = svc.Projects.Locations.Certificates.Patch(gCertificate, storePath + $"/certificates/{CertificateName}"); replaceCertificateRequest.UpdateMask = "SelfManaged"; var replaceCertificateResponse = replaceCertificateRequest.Execute(); - WaitForOperation(svc, replaceCertificateResponse.Name); - - _logger.LogTrace($"Certificate Replaced in Google Cert Manager with Name {replaceCertificateResponse.Name}"); + flow.Step("WaitForOperation-Replace", () => WaitForOperation(svc, replaceCertificateResponse.Name), + $"operation={replaceCertificateResponse.Name}"); - _logger.MethodExit(); + Logger.LogTrace("Certificate Replaced in Google Cert Manager with Name {Name}", replaceCertificateResponse.Name); } - private void DeleteCertificate(string certificateName, - CertificateManagerService svc, string storePath) + private void DeleteCertificate(string certificateName, CertificateManagerService svc, string storePath, FlowLogger flow) { - try - { - _logger.MethodEntry(); - - var certificatesRequest = svc.Projects.Locations.Certificates.List(storePath); - certificatesRequest.Filter = $"name=\"{storePath}/certificates/{certificateName}\""; + var certificatesRequest = svc.Projects.Locations.Certificates.List(storePath); + certificatesRequest.Filter = $"name=\"{storePath}/certificates/{certificateName}\""; - var certificatesResponse = certificatesRequest.Execute(); - _logger.LogTrace($"certificatesResponse Json {JsonConvert.SerializeObject(certificatesResponse)}"); + var certificatesResponse = certificatesRequest.Execute(); + Logger.LogTrace("certificatesResponse Json {Response}", JsonConvert.SerializeObject(certificatesResponse)); - if (certificatesResponse?.Certificates?.Count > 0) - { - var deleteCertificateRequest = - svc.Projects.Locations.Certificates.Delete(storePath + $"/certificates/{certificateName}"); + if (certificatesResponse?.Certificates?.Count > 0) + { + var deleteCertificateRequest = + svc.Projects.Locations.Certificates.Delete(storePath + $"/certificates/{certificateName}"); - var deleteCertificateResponse = deleteCertificateRequest.Execute(); - _logger.LogTrace( - $"deleteCertificateResponse Json {JsonConvert.SerializeObject(deleteCertificateResponse)}"); - WaitForOperation(svc, deleteCertificateResponse.Name); + var deleteCertificateResponse = deleteCertificateRequest.Execute(); + Logger.LogTrace("deleteCertificateResponse Json {Response}", JsonConvert.SerializeObject(deleteCertificateResponse)); + flow.Step("WaitForOperation-Delete", () => WaitForOperation(svc, deleteCertificateResponse.Name), + $"operation={deleteCertificateResponse.Name}"); - _logger.LogTrace($"Deleted {deleteCertificateResponse.Name} Certificate During Replace Procedure"); - } - else - { - string msg = $"Certificate {certificateName} not found for {storePath}."; - _logger.LogWarning(msg); - throw new Exception(msg); - } - - _logger.MethodExit(); + Logger.LogTrace("Deleted {Name} Certificate", deleteCertificateResponse.Name); } - catch (Exception e) + else { - _logger.LogError($"Error occured in Management.DeleteCertificate: {LogHandler.FlattenException(e)}"); - throw; + var msg = $"Certificate {certificateName} not found for {storePath}."; + Logger.LogWarning(msg); + throw new Exception(msg); } } private bool CheckForDuplicate(string path, string alias, CertificateManagerService client) { - try - { - _logger.MethodEntry(); - var certificatesRequest = - client.Projects.Locations.Certificates.List(path); - certificatesRequest.Filter = $"name=\"{path}/certificates/{alias}\""; - - var certificatesResponse = certificatesRequest.Execute(); - _logger.LogTrace($"certificatesResponse Json {JsonConvert.SerializeObject(certificatesResponse)}"); + var certificatesRequest = client.Projects.Locations.Certificates.List(path); + certificatesRequest.Filter = $"name=\"{path}/certificates/{alias}\""; - if (certificatesResponse?.Certificates?.Count == 1) - { - _logger.MethodExit(); - return true; - } + var certificatesResponse = certificatesRequest.Execute(); + Logger.LogTrace("certificatesResponse Json {Response}", JsonConvert.SerializeObject(certificatesResponse)); - _logger.MethodExit(); - return false; - } - catch (Exception e) - { - _logger.LogError( - $"Error Checking for Duplicate Cert in Management.CheckForDuplicate {LogHandler.FlattenException(e)}"); - throw; - } + return certificatesResponse?.Certificates?.Count == 1; } private void WaitForOperation(CertificateManagerService client, string operationName) { - _logger.MethodEntry(); - - DateTime endTime = DateTime.Now.AddMilliseconds(OPERATION_MAX_WAIT_MILLISECONDS); - Operation operation = new Operation(); - ProjectsResource.LocationsResource.OperationsResource.GetRequest getRequest = client.Projects.Locations.Operations.Get(operationName); + var endTime = DateTime.Now.AddMilliseconds(OPERATION_MAX_WAIT_MILLISECONDS); + var getRequest = client.Projects.Locations.Operations.Get(operationName); while (DateTime.Now < endTime) { - _logger.LogTrace($"Attempting WAIT for {operationName} at {DateTime.Now.ToString()}."); - operation = getRequest.Execute(); + Logger.LogTrace("Attempting WAIT for {OperationName} at {Now}.", operationName, DateTime.Now); + var operation = getRequest.Execute(); if (operation.Done == true) { - _logger.LogDebug($"End WAIT for {operationName}. Task DONE."); - _logger.MethodExit(); + Logger.LogDebug("End WAIT for {OperationName}. Task DONE.", operationName); return; } - System.Threading.Thread.Sleep(OPERATION_INTERVAL_WAIT_MILLISECONDS); + Thread.Sleep(OPERATION_INTERVAL_WAIT_MILLISECONDS); } - _logger.MethodExit(); - throw new Exception($"{operationName} was still processing after the {OPERATION_MAX_WAIT_MILLISECONDS.ToString()} millisecond maximum wait time."); - } - - private string GetCommonNameFromSubject(string subject) - { - try - { - _logger.MethodEntry(); - var array1 = subject.Split(','); - foreach (var x in array1) - { - var itemArray = x.Split('='); - - switch (itemArray[0].ToUpper()) - { - case "CN": - return itemArray[1]; - } - } - - _logger.LogTrace("Could not get subject returning empty string..."); - _logger.MethodExit(); - return ""; - } - catch (Exception e) - { - _logger.LogError( - $"Error Checking for Duplicate Cert in Management.GetCommonNameFromSubject {LogHandler.FlattenException(e)}"); - throw; - } + throw new Exception($"{operationName} was still processing after the {OPERATION_MAX_WAIT_MILLISECONDS} millisecond maximum wait time."); } } -} \ No newline at end of file +} diff --git a/GcpCertManager/manifest.json b/GcpCertManager/manifest.json index e728639..c97908c 100644 --- a/GcpCertManager/manifest.json +++ b/GcpCertManager/manifest.json @@ -8,6 +8,10 @@ "CertStores.GcpCertMgr.Management": { "assemblypath": "GcpCertManager.dll", "TypeFullName": "Keyfactor.Extensions.Orchestrator.GcpCertManager.Jobs.Management" + }, + "CertStores.GcpCertMgr.Discovery": { + "assemblypath": "GcpCertManager.dll", + "TypeFullName": "Keyfactor.Extensions.Orchestrator.GcpCertManager.Jobs.Discovery" } } } diff --git a/docsource/content.md b/docsource/content.md index b1e78e5..5663bdf 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -2,7 +2,9 @@ The GCP Certificate Manager Orchestrator Extension remotely manages certificates on the Google Cloud Platform Certificate Manager Product. -This orchestrator extension implements three job types - Inventory, Management Add, and Management Remove. Below are the steps necessary to configure this Orchestrator Extension. It supports adding certificates with private keys only. The GCP Certificate Manager Orchestrator Extension supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. +This orchestrator extension implements four job types - Inventory, Management Add, Management Remove, and Discovery. Below are the steps necessary to configure this Orchestrator Extension. It supports adding certificates with private keys only. The GCP Certificate Manager Orchestrator Extension supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. + +The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization or folder root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for the operator-facing details. ## Requirements diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index ed37e8e..c5be24b 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -1 +1,45 @@ ## Overview + +The `GcpCertMgr` store type represents a single (Project, Location) pair within Google Cloud Certificate Manager. The orchestrator manages self-managed certificates inside that container - listing them for inventory, uploading new PFX certificates, and deleting existing certificates by alias. + +### Discovery + +The Discovery job is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination in the canonical GCP form `projects/{projectId}/locations/{location}`. + +#### Configuring the discovery job + +| Field on the discovery-job form | What to put | +|---|---| +| **Client Machine** | The GCP Organization ID (e.g. `123456789012`). Recorded in logs only - actual project visibility is enforced by IAM. | +| **Server Username / Server Password** | Not used. Leave blank. The orchestrator authenticates via the configured GCP service account, not via username/password. | +| **Directories to search** | Comma-separated list of GCP locations (regions) to enumerate, e.g. `global,us-central1,europe-west1`. Leave blank to default to `global`. | + +#### Service account credentials + +Discovery resolves credentials in the same way the inventory and management jobs do: + +1. If the optional `ServiceAccountKey` job property is provided, the JSON key file with that name is read from the orchestrator extension directory. +2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. This is the recommended path when the orchestrator runs on a GCE VM or GKE pod with a workload-identity-bound service account. + +The service account needs at minimum the `resourcemanager.projects.list` permission at the organization root (or wherever you want discovery to be scoped). If you also want operators to be able to inventory the discovered stores immediately after approval, the same service account needs `certificatemanager.certificates.list` on those projects. + +#### Approving discovered stores + +Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. **Command does not auto-populate `ClientMachine` or the `Location` custom property from the discovered path** - the operator must edit each candidate before approving: + +1. Set **Client Machine** on the new store to the project ID (e.g. `my-pki-project`). +2. Set the **Location** custom property to the region (e.g. `global`). +3. Set the **Service Account Key File Path** custom property to the JSON key filename (or leave blank to use Application Default Credentials). +4. Approve. + +After approval the store is treated like any other manually-created `GcpCertMgr` store - the inventory job will run against it on its configured schedule. + +### Architecture and logging + +Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. + +### Vendor docs + +- [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs) +- [Cloud Resource Manager v3 - projects.search](https://cloud.google.com/resource-manager/reference/rest/v3/projects/search) +- [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) From 9e7248234005ef7cdbe67ab3d2e96c63379ab614 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Mon, 4 May 2026 19:40:37 +0000 Subject: [PATCH 02/31] Update generated docs --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6fd92f7..54c572f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ The GCP Certificate Manager Orchestrator Extension remotely manages certificates on the Google Cloud Platform Certificate Manager Product. -This orchestrator extension implements three job types - Inventory, Management Add, and Management Remove. Below are the steps necessary to configure this Orchestrator Extension. It supports adding certificates with private keys only. The GCP Certificate Manager Orchestrator Extension supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. +This orchestrator extension implements four job types - Inventory, Management Add, Management Remove, and Discovery. Below are the steps necessary to configure this Orchestrator Extension. It supports adding certificates with private keys only. The GCP Certificate Manager Orchestrator Extension supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. + +The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization or folder root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for the operator-facing details. @@ -71,7 +73,49 @@ To use the Google Cloud Provider Certificate Manager Universal Orchestrator exte +The `GcpCertMgr` store type represents a single (Project, Location) pair within Google Cloud Certificate Manager. The orchestrator manages self-managed certificates inside that container - listing them for inventory, uploading new PFX certificates, and deleting existing certificates by alias. + +#### Discovery + +The Discovery job is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination in the canonical GCP form `projects/{projectId}/locations/{location}`. + +##### Configuring the discovery job + +| Field on the discovery-job form | What to put | +|---|---| +| **Client Machine** | The GCP Organization ID (e.g. `123456789012`). Recorded in logs only - actual project visibility is enforced by IAM. | +| **Server Username / Server Password** | Not used. Leave blank. The orchestrator authenticates via the configured GCP service account, not via username/password. | +| **Directories to search** | Comma-separated list of GCP locations (regions) to enumerate, e.g. `global,us-central1,europe-west1`. Leave blank to default to `global`. | + +##### Service account credentials + +Discovery resolves credentials in the same way the inventory and management jobs do: + +1. If the optional `ServiceAccountKey` job property is provided, the JSON key file with that name is read from the orchestrator extension directory. +2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. This is the recommended path when the orchestrator runs on a GCE VM or GKE pod with a workload-identity-bound service account. + +The service account needs at minimum the `resourcemanager.projects.list` permission at the organization root (or wherever you want discovery to be scoped). If you also want operators to be able to inventory the discovered stores immediately after approval, the same service account needs `certificatemanager.certificates.list` on those projects. + +##### Approving discovered stores + +Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. **Command does not auto-populate `ClientMachine` or the `Location` custom property from the discovered path** - the operator must edit each candidate before approving: + +1. Set **Client Machine** on the new store to the project ID (e.g. `my-pki-project`). +2. Set the **Location** custom property to the region (e.g. `global`). +3. Set the **Service Account Key File Path** custom property to the JSON key filename (or leave blank to use Application Default Credentials). +4. Approve. + +After approval the store is treated like any other manually-created `GcpCertMgr` store - the inventory job will run against it on its configured schedule. + +#### Architecture and logging + +Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. + +#### Vendor docs +- [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs) +- [Cloud Resource Manager v3 - projects.search](https://cloud.google.com/resource-manager/reference/rest/v3/projects/search) +- [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) From e0da0ee251b9b788ead3817a064db6453acffeaa Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 11:23:53 -0400 Subject: [PATCH 03/31] fix: prefer canonical StorePath over ClientMachine for GCP resource path Inventory and Management built the GCP resource path from ClientMachine + the Location custom property, ignoring whatever was in the store's StorePath. Discovery-approved stores arrive in Command with StorePath="projects/{id}/locations/{loc}" but ClientMachine defaulting to whatever Command auto-fills (often the orchestrator hostname), so inventory failed with HTTP 403 CONSUMER_INVALID against "projects//locations/global". ResolveGcpResourcePath now trusts StorePath when it is in canonical "projects/X/locations/Y" form. Manually-created v1.1-shaped stores (StorePath="n/a") continue to build the path from ClientMachine + the Location custom property, so existing stores keep working without intervention. Discovery-approved stores no longer require operators to retype the project ID into ClientMachine after approval. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 20 +++++++++++++++----- GcpCertManager/Jobs/Inventory.cs | 5 ++++- GcpCertManager/Jobs/JobBase.cs | 29 +++++++++++++++++++++++++++++ GcpCertManager/Jobs/Management.cs | 5 ++++- docsource/gcpcertmgr.md | 12 ++++++------ 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b111262..9f95ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,14 +30,24 @@ v1.2.0 - unreleased enumeration that Discovery requires. ### Known limitations -- Discovery emits candidate store paths in canonical GCP form; on approval the - operator must set the new store's ClientMachine to the project ID and the - Location custom property to the region (the canonical path encodes both, - but Keyfactor Command does not auto-populate them). This is documented in - `docsource/gcpcertmgr.md`. - The discovery-job ClientMachine field (Organization ID) is informational; if the service account has visibility into multiple organizations, Discovery will emit projects from all of them. Constrain at IAM if that's not desired. +- Discovery does not probe each (project, location) candidate to confirm the + Certificate Manager API is enabled or that any certificates exist. Operators + can leave `Create Certificate Store If Missing` checked to auto-approve every + candidate and let dead-end stores fail their first inventory; or leave it + unchecked and approve only the candidates they want to track. + +### Changed +- Inventory and Management now derive the GCP resource path from the store's + `StorePath` when it is in canonical `projects/{p}/locations/{l}` form. + Discovery emits paths in this form, so Discovery-approved stores now work + without operators needing to edit `ClientMachine`/`Location` after approval - + whether `Create Certificate Store If Missing` was checked or not. Manually + created v1.1-shaped stores (where `StorePath` is `n/a`) keep building the + path from `ClientMachine` + the `Location` custom property, so existing + stores continue to work unchanged. See `JobBase.ResolveGcpResourcePath`. v1.1.0 - Implemented dual build for .net6/8 diff --git a/GcpCertManager/Jobs/Inventory.cs b/GcpCertManager/Jobs/Inventory.cs index 7ed5252..a87e368 100644 --- a/GcpCertManager/Jobs/Inventory.cs +++ b/GcpCertManager/Jobs/Inventory.cs @@ -94,7 +94,10 @@ private JobResult PerformInventory(InventoryJobConfiguration config, var sb = new StringBuilder(); var inventoryItems = new List(); var nextPageToken = string.Empty; - var storePath = $"projects/{storeProperties.ProjectId}/locations/{storeProperties.Location}"; + var storePath = ResolveGcpResourcePath( + config.CertificateStoreDetails.StorePath, + storeProperties.ProjectId, + storeProperties.Location); flow.Step("StorePathResolved", $"storePath={storePath}"); diff --git a/GcpCertManager/Jobs/JobBase.cs b/GcpCertManager/Jobs/JobBase.cs index a0d0354..d079f9c 100644 --- a/GcpCertManager/Jobs/JobBase.cs +++ b/GcpCertManager/Jobs/JobBase.cs @@ -139,5 +139,34 @@ private static string Trim(string s, int maxLen) if (s.Length <= maxLen) return s; return s.Substring(0, maxLen) + "..."; } + + /// + /// Resolve the GCP resource path (projects/{projectId}/locations/{location}) + /// for a certificate store. When Discovery-approved stores arrive in Command, + /// StorePath already carries the canonical GCP path - trust it. For + /// manually-created stores (v1.1 shape, where StorePath is "n/a") fall back to + /// composing the path from ClientMachine + the Location custom property. + /// + /// + /// This makes Discovery's auto-approval path produce working stores without + /// requiring an operator to edit ClientMachine after approval. ClientMachine + /// still matters for v1.1-shaped manually-created stores; both paths coexist. + /// + protected static string ResolveGcpResourcePath(string storePath, string projectId, string location) + { + if (!string.IsNullOrWhiteSpace(storePath)) + { + var trimmed = storePath.Trim(); + // Reject the historical "n/a" placeholder before pattern-matching. + if (!string.Equals(trimmed, "n/a", StringComparison.OrdinalIgnoreCase) && + trimmed.StartsWith("projects/", StringComparison.OrdinalIgnoreCase) && + trimmed.IndexOf("/locations/", StringComparison.OrdinalIgnoreCase) > 0) + { + return trimmed; + } + } + + return $"projects/{projectId}/locations/{location}"; + } } } diff --git a/GcpCertManager/Jobs/Management.cs b/GcpCertManager/Jobs/Management.cs index 79cf821..8f86bf2 100644 --- a/GcpCertManager/Jobs/Management.cs +++ b/GcpCertManager/Jobs/Management.cs @@ -97,7 +97,10 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge svc = new GcpCertificateManagerClient().GetGoogleCredentials(storeProperties.ServiceAccountKey); }, $"source={(string.IsNullOrEmpty(storeProperties.ServiceAccountKey) ? "ADC" : "file")}"); - var storePath = $"projects/{storeProperties.ProjectId}/locations/{storeProperties.Location}"; + var storePath = ResolveGcpResourcePath( + config.CertificateStoreDetails.StorePath, + storeProperties.ProjectId, + storeProperties.Location); CertificateName = config.JobCertificate.Alias; flow.Step("StorePathResolved", $"storePath={storePath}, alias={CertificateName}"); diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index c5be24b..e8f887c 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -25,14 +25,14 @@ The service account needs at minimum the `resourcemanager.projects.list` permiss #### Approving discovered stores -Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. **Command does not auto-populate `ClientMachine` or the `Location` custom property from the discovered path** - the operator must edit each candidate before approving: +Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. As of v1.2.0 the inventory and management jobs read the GCP resource path **from `StorePath` when it is in this canonical form**, so Discovery-approved stores work end-to-end without operators having to retype the project ID into `ClientMachine` after approval. The only field that still needs a value is the **Service Account Key File Path** custom property: -1. Set **Client Machine** on the new store to the project ID (e.g. `my-pki-project`). -2. Set the **Location** custom property to the region (e.g. `global`). -3. Set the **Service Account Key File Path** custom property to the JSON key filename (or leave blank to use Application Default Credentials). -4. Approve. +1. (Optional) Set the **Service Account Key File Path** to the JSON key filename in the orchestrator extension directory. Leave blank to use Application Default Credentials. +2. Approve. -After approval the store is treated like any other manually-created `GcpCertMgr` store - the inventory job will run against it on its configured schedule. +`ClientMachine` and the `Location` custom property are still respected for **manually-created** stores (where `StorePath` is left as `n/a`) - that's the v1.1 shape and continues to work unchanged. For Discovery-approved stores those fields are advisory only; the canonical `StorePath` wins. + +After approval the store is treated like any other `GcpCertMgr` store - the inventory job will run against it on its configured schedule. ### Architecture and logging From c1bf6c5128915be98b0d684eb9443a23b92c7634 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 15:25:27 +0000 Subject: [PATCH 04/31] Update generated docs --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 54c572f..f6482ac 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,14 @@ The service account needs at minimum the `resourcemanager.projects.list` permiss ##### Approving discovered stores -Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. **Command does not auto-populate `ClientMachine` or the `Location` custom property from the discovered path** - the operator must edit each candidate before approving: +Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. As of v1.2.0 the inventory and management jobs read the GCP resource path **from `StorePath` when it is in this canonical form**, so Discovery-approved stores work end-to-end without operators having to retype the project ID into `ClientMachine` after approval. The only field that still needs a value is the **Service Account Key File Path** custom property: -1. Set **Client Machine** on the new store to the project ID (e.g. `my-pki-project`). -2. Set the **Location** custom property to the region (e.g. `global`). -3. Set the **Service Account Key File Path** custom property to the JSON key filename (or leave blank to use Application Default Credentials). -4. Approve. +1. (Optional) Set the **Service Account Key File Path** to the JSON key filename in the orchestrator extension directory. Leave blank to use Application Default Credentials. +2. Approve. -After approval the store is treated like any other manually-created `GcpCertMgr` store - the inventory job will run against it on its configured schedule. +`ClientMachine` and the `Location` custom property are still respected for **manually-created** stores (where `StorePath` is left as `n/a`) - that's the v1.1 shape and continues to work unchanged. For Discovery-approved stores those fields are advisory only; the canonical `StorePath` wins. + +After approval the store is treated like any other `GcpCertMgr` store - the inventory job will run against it on its configured schedule. #### Architecture and logging From 42e87928f893ddce454e7606bcb9cee011919bd9 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 11:46:17 -0400 Subject: [PATCH 05/31] refactor: unify store-type schema around canonical Store Path Make Store Path the single source of truth for the GCP resource path on both manually-created and Discovery-approved stores. Repurpose Client Machine as a display label (recommended value: GCP Organization ID). Deprecate the standalone Location custom property; the location is now parsed out of Store Path. Existing v1.1 stores keep working via a deprecation-logged fallback in JobBase.ResolveGcpResourcePath - every inventory/management run against a v1.1-shape store emits one LogWarning naming the migration step. Fallback removal scheduled for v2.0. Updates integration-manifest.json: clears StorePathValue's "n/a" default, marks Location property Required:false with a deprecation description, rewrites Client Machine and Store Path descriptions. Doctool will regenerate the README's Manual Creation table from these. Updates docsource/gcpcertmgr.md and content.md with one unified configuration narrative covering both manual and Discovery-approved flows, plus an explicit v1.1 migration section. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 32 ++++++++++---- GcpCertManager/Jobs/JobBase.cs | 26 +++++++----- docsource/content.md | 14 ++++++- docsource/gcpcertmgr.md | 77 ++++++++++++++++++++++++++-------- integration-manifest.json | 16 +++---- 5 files changed, 119 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f95ec9..65f2e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,15 +39,29 @@ v1.2.0 - unreleased candidate and let dead-end stores fail their first inventory; or leave it unchecked and approve only the candidates they want to track. -### Changed -- Inventory and Management now derive the GCP resource path from the store's - `StorePath` when it is in canonical `projects/{p}/locations/{l}` form. - Discovery emits paths in this form, so Discovery-approved stores now work - without operators needing to edit `ClientMachine`/`Location` after approval - - whether `Create Certificate Store If Missing` was checked or not. Manually - created v1.1-shaped stores (where `StorePath` is `n/a`) keep building the - path from `ClientMachine` + the `Location` custom property, so existing - stores continue to work unchanged. See `JobBase.ResolveGcpResourcePath`. +### Changed (schema unification) +- Unified the store-type schema so manually-created and Discovery-approved + stores configure the same way. **Store Path** is now the single source of + truth for which Certificate Manager instance the store targets, in canonical + form `projects/{projectId}/locations/{location}`. Inventory and Management + read the GCP resource path from this field for both flows. +- **Client Machine** is repurposed as a display-only label. The recommended + value is the GCP Organization ID; the orchestrator does not parse a project + ID out of it. Documented in the updated store-type description. +- The **Location** custom property is deprecated. New stores leave it blank; + the value is parsed out of Store Path. The field remains in the manifest + with `Required: false` and a deprecation note so existing v1.1 stores keep + rendering correctly in Command's UI. + +### Backwards compatibility +- v1.1-shape stores (Store Path blank or `n/a`, Client Machine = Project ID, + Location custom property = region) continue to work via a deprecation-logged + fallback path in `JobBase.ResolveGcpResourcePath`. Every inventory or + management run against such a store emits a single `LogWarning` naming the + store and the migration step. The fallback is scheduled for removal in v2.0. +- Migration: edit each affected store, set Store Path to + `projects/{ClientMachine-value}/locations/{Location-value}`, optionally + change Client Machine to the GCP Organization ID, optionally clear Location. v1.1.0 - Implemented dual build for .net6/8 diff --git a/GcpCertManager/Jobs/JobBase.cs b/GcpCertManager/Jobs/JobBase.cs index d079f9c..99e9be1 100644 --- a/GcpCertManager/Jobs/JobBase.cs +++ b/GcpCertManager/Jobs/JobBase.cs @@ -142,17 +142,14 @@ private static string Trim(string s, int maxLen) /// /// Resolve the GCP resource path (projects/{projectId}/locations/{location}) - /// for a certificate store. When Discovery-approved stores arrive in Command, - /// StorePath already carries the canonical GCP path - trust it. For - /// manually-created stores (v1.1 shape, where StorePath is "n/a") fall back to - /// composing the path from ClientMachine + the Location custom property. + /// for a certificate store. As of v1.2 the canonical source is the store's + /// StorePath; both Discovery-approved and manually-created stores set it + /// to projects/{projectId}/locations/{location}. The fallback to + /// ClientMachine + the Location custom property exists only to keep v1.1-shape + /// stores (where StorePath is blank or n/a) working through an upgrade, + /// and it logs a deprecation warning when it fires. /// - /// - /// This makes Discovery's auto-approval path produce working stores without - /// requiring an operator to edit ClientMachine after approval. ClientMachine - /// still matters for v1.1-shaped manually-created stores; both paths coexist. - /// - protected static string ResolveGcpResourcePath(string storePath, string projectId, string location) + protected string ResolveGcpResourcePath(string storePath, string projectId, string location) { if (!string.IsNullOrWhiteSpace(storePath)) { @@ -166,6 +163,15 @@ protected static string ResolveGcpResourcePath(string storePath, string projectI } } + // v1.1 fallback. Log a deprecation warning so operators reading orchestrator + // logs (or running this in dev) know the store should be migrated to the v1.2 + // schema (set StorePath to projects/{projectId}/locations/{location}). + Logger?.LogWarning( + "Store is using v1.1-shape configuration (ClientMachine={ProjectId}, Location={Location}, StorePath blank or 'n/a'). " + + "This is deprecated as of v1.2 and the fallback will be removed in v2.0. " + + "Edit the store and set Store Path to 'projects/{ProjectId}/locations/{Location}' to migrate.", + projectId, location, projectId, location); + return $"projects/{projectId}/locations/{location}"; } } diff --git a/docsource/content.md b/docsource/content.md index 5663bdf..30bf02c 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -2,9 +2,19 @@ The GCP Certificate Manager Orchestrator Extension remotely manages certificates on the Google Cloud Platform Certificate Manager Product. -This orchestrator extension implements four job types - Inventory, Management Add, Management Remove, and Discovery. Below are the steps necessary to configure this Orchestrator Extension. It supports adding certificates with private keys only. The GCP Certificate Manager Orchestrator Extension supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. +This orchestrator extension implements four job types - Inventory, Management Add, Management Remove, and Discovery. It supports adding certificates with private keys only. The orchestrator supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. -The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization or folder root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for the operator-facing details. +### Configuration model + +Every `GcpCertMgr` store identifies its target Certificate Manager instance through the canonical GCP resource path in **Store Path**: + +``` +projects/{projectId}/locations/{location} +``` + +This applies equally to manually-created stores and Discovery-approved stores. The `Location` custom property is deprecated as of v1.2 and used only as a v1.1 backwards-compatibility fallback. **Client Machine** is a display label for grouping in Command's UI - the recommended value is the GCP Organization ID. + +The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair, with Store Path pre-populated in canonical form. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for full operator-facing details. ## Requirements diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index e8f887c..c332bbe 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -2,42 +2,85 @@ The `GcpCertMgr` store type represents a single (Project, Location) pair within Google Cloud Certificate Manager. The orchestrator manages self-managed certificates inside that container - listing them for inventory, uploading new PFX certificates, and deleting existing certificates by alias. -### Discovery +### Configuration model (v1.2+) -The Discovery job is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination in the canonical GCP form `projects/{projectId}/locations/{location}`. +Every `GcpCertMgr` store - whether Discovery-approved or manually created - identifies its target Certificate Manager instance through the **Store Path** field: -#### Configuring the discovery job +``` +projects/{projectId}/locations/{location} +``` + +That single value carries both the GCP project and the location (region or `global`). Inventory and Management read it directly; **Client Machine** is a display label for grouping in Command's UI and is not parsed by the orchestrator. + +#### Field semantics + +| Field | What it carries | Read by | +|---|---|---| +| **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}` | Inventory, Management, Discovery (emit) | +| **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | +| **Service Account Key File Path** (custom) | Filename of the JSON key in the orchestrator extension directory. Blank → Application Default Credentials. | Credential loader | +| **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | + +#### Manually creating a store + +Set: + +- **Client Machine**: GCP Organization ID +- **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` +- **Service Account Key File Path**: `kf-orchestrator.json` (or blank for ADC) +- **Location**: leave blank + +#### Approving a Discovery-discovered store + +Discovery emits one candidate per (project, location) pair in canonical form, so the only field you might want to set on approval is **Service Account Key File Path** (recommended: type the JSON filename for explicit control; leave blank to inherit ADC). Click SAVE without further edits. + +If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. + +### Discovery job configuration + +Discovery is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination. | Field on the discovery-job form | What to put | |---|---| -| **Client Machine** | The GCP Organization ID (e.g. `123456789012`). Recorded in logs only - actual project visibility is enforced by IAM. | -| **Server Username / Server Password** | Not used. Leave blank. The orchestrator authenticates via the configured GCP service account, not via username/password. | +| **Client Machine** | The GCP Organization ID (e.g. `1005564431893`). Logged for traceability; not used as a query filter. | +| **Server Username / Server Password** | Not used. Leave blank - GCP authentication uses a service account, not username/password. | | **Directories to search** | Comma-separated list of GCP locations (regions) to enumerate, e.g. `global,us-central1,europe-west1`. Leave blank to default to `global`. | -#### Service account credentials +The candidate count is `projects × locations`, so be deliberate about how many regions you list - listing 8 regions for an org with 100 projects yields 800 candidate stores, most of which will be empty. -Discovery resolves credentials in the same way the inventory and management jobs do: - -1. If the optional `ServiceAccountKey` job property is provided, the JSON key file with that name is read from the orchestrator extension directory. -2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. This is the recommended path when the orchestrator runs on a GCE VM or GKE pod with a workload-identity-bound service account. +#### Service account credentials -The service account needs at minimum the `resourcemanager.projects.list` permission at the organization root (or wherever you want discovery to be scoped). If you also want operators to be able to inventory the discovered stores immediately after approval, the same service account needs `certificatemanager.certificates.list` on those projects. +Both the discovery job and the inventory/management jobs resolve credentials in the same order: -#### Approving discovered stores +1. If a `ServiceAccountKey` value is configured (custom store property for inventory/management; not exposed in the discovery-job UI - see env-var fallback below), the JSON key file with that name is read from the orchestrator extension directory. +2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. On Windows hosts this means setting `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable to the absolute path of the JSON key, then restarting the Keyfactor Orchestrator service. On a GCE VM / GKE pod with workload identity, ADC works automatically. -Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. As of v1.2.0 the inventory and management jobs read the GCP resource path **from `StorePath` when it is in this canonical form**, so Discovery-approved stores work end-to-end without operators having to retype the project ID into `ClientMachine` after approval. The only field that still needs a value is the **Service Account Key File Path** custom property: +The service account needs at minimum: -1. (Optional) Set the **Service Account Key File Path** to the JSON key filename in the orchestrator extension directory. Leave blank to use Application Default Credentials. -2. Approve. +- `roles/browser` at the **organization** root - for `projects.search` to see projects nested in folders. +- `roles/certificatemanager.viewer` per project (or at the org root for inheritance) - for inventory to list certificates. +- `roles/certificatemanager.editor` - for management to add/remove certificates. -`ClientMachine` and the `Location` custom property are still respected for **manually-created** stores (where `StorePath` is left as `n/a`) - that's the v1.1 shape and continues to work unchanged. For Discovery-approved stores those fields are advisory only; the canonical `StorePath` wins. +Required APIs to enable in the **service account's home project**: -After approval the store is treated like any other `GcpCertMgr` store - the inventory job will run against it on its configured schedule. +- Cloud Resource Manager API +- Certificate Manager API (also needs to be enabled in every project you actually inventory) ### Architecture and logging Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. +### Migrating v1.1 stores + +A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the GCP Project ID, and the `Location` custom property set to the region. These continue to work in v1.2 through a fallback path, but every inventory/management run logs a deprecation warning naming the store. To migrate, edit each affected store: + +1. Set **Store Path** to `projects/{the-current-Client-Machine-value}/locations/{the-current-Location-value}`. +2. Optionally change **Client Machine** to the GCP Organization ID for cleaner UI grouping. +3. Optionally clear the **Location** field (no longer required). +4. Save. + +The deprecation warning will stop on the next job run once Store Path is populated. The fallback will be removed in v2.0. + ### Vendor docs - [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs) diff --git a/integration-manifest.json b/integration-manifest.json index da19f33..0c836a2 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -25,7 +25,7 @@ "PowerShell": false, "PrivateKeyAllowed": "Required", "StorePathType": "", - "StorePathValue": "n/a", + "StorePathValue": "", "SupportedOperations": { "Add": true, "Create": true, @@ -41,13 +41,13 @@ "Properties": [ { "Name": "Location", - "DisplayName": "Location", + "DisplayName": "Location (deprecated)", "Type": "String", "DependsOn": "", - "DefaultValue": "global", - "Required": true, + "DefaultValue": "", + "Required": false, "IsPAMEligible": false, - "Description": "The GCP region used for this Certificate Manager instance. **global** is the default but could be another region based on the project." + "Description": "**Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used." }, { "Name": "ServiceAccountKey", @@ -57,11 +57,11 @@ "DefaultValue": "", "Required": false, "IsPAMEligible": false, - "Description": "The file name of the Google Cloud Service Account Key File installed in the same folder as the orchestrator extension. Empty if the orchestrator server resides in GCP and you are not using a service account key." + "Description": "File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host)." } ], - "ClientMachineDescription": "GCP Project ID for your account.", - "StorePathDescription": "This is not used and should be defaulted to n/a per the certificate store type set up.", + "ClientMachineDescription": "Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path.", + "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", "EntryParameters": [] } ] From 38165748805f2debaffc21f349064f5b852d3579 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 15:47:53 +0000 Subject: [PATCH 06/31] Update generated docs --- README.md | 117 +++++++++++++----- .../bash/curl_create_store_types.sh | 12 +- .../restmethod_create_store_types.ps1 | 12 +- 3 files changed, 97 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f6482ac..64f48ac 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,19 @@ The GCP Certificate Manager Orchestrator Extension remotely manages certificates on the Google Cloud Platform Certificate Manager Product. -This orchestrator extension implements four job types - Inventory, Management Add, Management Remove, and Discovery. Below are the steps necessary to configure this Orchestrator Extension. It supports adding certificates with private keys only. The GCP Certificate Manager Orchestrator Extension supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. +This orchestrator extension implements four job types - Inventory, Management Add, Management Remove, and Discovery. It supports adding certificates with private keys only. The orchestrator supports the replacement of unbound certificates as well as certificates bound to existing map entries, but it does **not** support specifying map entry bindings when adding new certificates. -The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization or folder root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for the operator-facing details. +### Configuration model + +Every `GcpCertMgr` store identifies its target Certificate Manager instance through the canonical GCP resource path in **Store Path**: + +``` +projects/{projectId}/locations/{location} +``` + +This applies equally to manually-created stores and Discovery-approved stores. The `Location` custom property is deprecated as of v1.2 and used only as a v1.1 backwards-compatibility fallback. **Client Machine** is a display label for grouping in Command's UI - the recommended value is the GCP Organization ID. + +The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair, with Store Path pre-populated in canonical form. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for full operator-facing details. @@ -75,42 +85,85 @@ To use the Google Cloud Provider Certificate Manager Universal Orchestrator exte The `GcpCertMgr` store type represents a single (Project, Location) pair within Google Cloud Certificate Manager. The orchestrator manages self-managed certificates inside that container - listing them for inventory, uploading new PFX certificates, and deleting existing certificates by alias. -#### Discovery +#### Configuration model (v1.2+) + +Every `GcpCertMgr` store - whether Discovery-approved or manually created - identifies its target Certificate Manager instance through the **Store Path** field: + +``` +projects/{projectId}/locations/{location} +``` + +That single value carries both the GCP project and the location (region or `global`). Inventory and Management read it directly; **Client Machine** is a display label for grouping in Command's UI and is not parsed by the orchestrator. + +##### Field semantics + +| Field | What it carries | Read by | +|---|---|---| +| **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}` | Inventory, Management, Discovery (emit) | +| **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | +| **Service Account Key File Path** (custom) | Filename of the JSON key in the orchestrator extension directory. Blank → Application Default Credentials. | Credential loader | +| **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | + +##### Manually creating a store + +Set: -The Discovery job is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination in the canonical GCP form `projects/{projectId}/locations/{location}`. +- **Client Machine**: GCP Organization ID +- **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` +- **Service Account Key File Path**: `kf-orchestrator.json` (or blank for ADC) +- **Location**: leave blank -##### Configuring the discovery job +##### Approving a Discovery-discovered store + +Discovery emits one candidate per (project, location) pair in canonical form, so the only field you might want to set on approval is **Service Account Key File Path** (recommended: type the JSON filename for explicit control; leave blank to inherit ADC). Click SAVE without further edits. + +If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. + +#### Discovery job configuration + +Discovery is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination. | Field on the discovery-job form | What to put | |---|---| -| **Client Machine** | The GCP Organization ID (e.g. `123456789012`). Recorded in logs only - actual project visibility is enforced by IAM. | -| **Server Username / Server Password** | Not used. Leave blank. The orchestrator authenticates via the configured GCP service account, not via username/password. | +| **Client Machine** | The GCP Organization ID (e.g. `1005564431893`). Logged for traceability; not used as a query filter. | +| **Server Username / Server Password** | Not used. Leave blank - GCP authentication uses a service account, not username/password. | | **Directories to search** | Comma-separated list of GCP locations (regions) to enumerate, e.g. `global,us-central1,europe-west1`. Leave blank to default to `global`. | -##### Service account credentials - -Discovery resolves credentials in the same way the inventory and management jobs do: +The candidate count is `projects × locations`, so be deliberate about how many regions you list - listing 8 regions for an org with 100 projects yields 800 candidate stores, most of which will be empty. -1. If the optional `ServiceAccountKey` job property is provided, the JSON key file with that name is read from the orchestrator extension directory. -2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. This is the recommended path when the orchestrator runs on a GCE VM or GKE pod with a workload-identity-bound service account. +##### Service account credentials -The service account needs at minimum the `resourcemanager.projects.list` permission at the organization root (or wherever you want discovery to be scoped). If you also want operators to be able to inventory the discovered stores immediately after approval, the same service account needs `certificatemanager.certificates.list` on those projects. +Both the discovery job and the inventory/management jobs resolve credentials in the same order: -##### Approving discovered stores +1. If a `ServiceAccountKey` value is configured (custom store property for inventory/management; not exposed in the discovery-job UI - see env-var fallback below), the JSON key file with that name is read from the orchestrator extension directory. +2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. On Windows hosts this means setting `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable to the absolute path of the JSON key, then restarting the Keyfactor Orchestrator service. On a GCE VM / GKE pod with workload identity, ADC works automatically. -Discovered store paths arrive in Keyfactor Command in the form `projects/{projectId}/locations/{location}`. As of v1.2.0 the inventory and management jobs read the GCP resource path **from `StorePath` when it is in this canonical form**, so Discovery-approved stores work end-to-end without operators having to retype the project ID into `ClientMachine` after approval. The only field that still needs a value is the **Service Account Key File Path** custom property: +The service account needs at minimum: -1. (Optional) Set the **Service Account Key File Path** to the JSON key filename in the orchestrator extension directory. Leave blank to use Application Default Credentials. -2. Approve. +- `roles/browser` at the **organization** root - for `projects.search` to see projects nested in folders. +- `roles/certificatemanager.viewer` per project (or at the org root for inheritance) - for inventory to list certificates. +- `roles/certificatemanager.editor` - for management to add/remove certificates. -`ClientMachine` and the `Location` custom property are still respected for **manually-created** stores (where `StorePath` is left as `n/a`) - that's the v1.1 shape and continues to work unchanged. For Discovery-approved stores those fields are advisory only; the canonical `StorePath` wins. +Required APIs to enable in the **service account's home project**: -After approval the store is treated like any other `GcpCertMgr` store - the inventory job will run against it on its configured schedule. +- Cloud Resource Manager API +- Certificate Manager API (also needs to be enabled in every project you actually inventory) #### Architecture and logging Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. +#### Migrating v1.1 stores + +A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the GCP Project ID, and the `Location` custom property set to the region. These continue to work in v1.2 through a fallback path, but every inventory/management run logs a deprecation warning naming the store. To migrate, edit each affected store: + +1. Set **Store Path** to `projects/{the-current-Client-Machine-value}/locations/{the-current-Location-value}`. +2. Optionally change **Client Machine** to the GCP Organization ID for cleaner UI grouping. +3. Optionally clear the **Location** field (no longer required). +4. Save. + +The deprecation warning will stop on the next job run once Store Path is populated. The fallback will be removed in v2.0. + #### Vendor docs - [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs) @@ -200,16 +253,16 @@ the Keyfactor Command Portal | Name | Display Name | Description | Type | Default Value/Options | Required | | ---- | ------------ | ---- | --------------------- | -------- | ----------- | - | Location | Location | The GCP region used for this Certificate Manager instance. **global** is the default but could be another region based on the project. | String | global | ✅ Checked | - | ServiceAccountKey | Service Account Key File Path | The file name of the Google Cloud Service Account Key File installed in the same folder as the orchestrator extension. Empty if the orchestrator server resides in GCP and you are not using a service account key. | String | | 🔲 Unchecked | + | Location | Location (deprecated) | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | String | | 🔲 Unchecked | + | ServiceAccountKey | Service Account Key File Path | File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). | String | | 🔲 Unchecked | The Custom Fields tab should look like this: ![GcpCertMgr Custom Fields Tab](docsource/images/GcpCertMgr-custom-fields-store-type-dialog.png) - ###### Location - The GCP region used for this Certificate Manager instance. **global** is the default but could be another region based on the project. + ###### Location (deprecated) + **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. ![GcpCertMgr Custom Field - Location](docsource/images/GcpCertMgr-custom-field-Location-dialog.png) ![GcpCertMgr Custom Field - Location](docsource/images/GcpCertMgr-custom-field-Location-validation-options-dialog.png) @@ -217,7 +270,7 @@ the Keyfactor Command Portal ###### Service Account Key File Path - The file name of the Google Cloud Service Account Key File installed in the same folder as the orchestrator extension. Empty if the orchestrator server resides in GCP and you are not using a service account key. + File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.png) ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.png) @@ -289,11 +342,11 @@ the Keyfactor Command Portal | --------- |---------------------------------------------------------| | Category | Select "GCP Certificate Manager" or the customized certificate store name from the previous step. | | Container | Optional container to associate certificate store with. | - | Client Machine | GCP Project ID for your account. | - | Store Path | This is not used and should be defaulted to n/a per the certificate store type set up. | + | Client Machine | Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. | + | Store Path | Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly. | | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | - | Location | The GCP region used for this Certificate Manager instance. **global** is the default but could be another region based on the project. | - | ServiceAccountKey | The file name of the Google Cloud Service Account Key File installed in the same folder as the orchestrator extension. Empty if the orchestrator server resides in GCP and you are not using a service account key. | + | Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | + | ServiceAccountKey | File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). | @@ -316,11 +369,11 @@ the Keyfactor Command Portal | --------- | ----------- | | Category | Select "GCP Certificate Manager" or the customized certificate store name from the previous step. | | Container | Optional container to associate certificate store with. | - | Client Machine | GCP Project ID for your account. | - | Store Path | This is not used and should be defaulted to n/a per the certificate store type set up. | + | Client Machine | Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. | + | Store Path | Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly. | | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | - | Properties.Location | The GCP region used for this Certificate Manager instance. **global** is the default but could be another region based on the project. | - | Properties.ServiceAccountKey | The file name of the Google Cloud Service Account Key File installed in the same folder as the orchestrator extension. Empty if the orchestrator server resides in GCP and you are not using a service account key. | + | Properties.Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | + | Properties.ServiceAccountKey | File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). | 3. **Import the CSV file to create the certificate stores** diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 2c97865..5e1100f 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -70,7 +70,7 @@ create_store_type() { } # --------------------------------------------------------------------------- -# GcpCertMgr — GCP Project ID for your account. +# GcpCertMgr — Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. # --------------------------------------------------------------------------- create_store_type "GcpCertMgr" '{ "Name": "GCP Certificate Manager", @@ -82,7 +82,7 @@ create_store_type "GcpCertMgr" '{ "PowerShell": false, "PrivateKeyAllowed": "Required", "StorePathType": "", - "StorePathValue": "n/a", + "StorePathValue": "", "SupportedOperations": { "Add": true, "Create": true, @@ -98,11 +98,11 @@ create_store_type "GcpCertMgr" '{ "Properties": [ { "Name": "Location", - "DisplayName": "Location", + "DisplayName": "Location (deprecated)", "Type": "String", "DependsOn": "", - "DefaultValue": "global", - "Required": true, + "DefaultValue": "", + "Required": false, "IsPAMEligible": false }, { @@ -115,7 +115,7 @@ create_store_type "GcpCertMgr" '{ "IsPAMEligible": false } ], - "StorePathDescription": "This is not used and should be defaulted to n/a per the certificate store type set up.", + "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", "EntryParameters": [] }' diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index 8108a7a..6ab93db 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -62,7 +62,7 @@ function New-StoreType { } # --------------------------------------------------------------------------- -# GcpCertMgr — GCP Project ID for your account. +# GcpCertMgr — Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. # --------------------------------------------------------------------------- New-StoreType "GcpCertMgr" @' { @@ -75,7 +75,7 @@ New-StoreType "GcpCertMgr" @' "PowerShell": false, "PrivateKeyAllowed": "Required", "StorePathType": "", - "StorePathValue": "n/a", + "StorePathValue": "", "SupportedOperations": { "Add": true, "Create": true, @@ -91,11 +91,11 @@ New-StoreType "GcpCertMgr" @' "Properties": [ { "Name": "Location", - "DisplayName": "Location", + "DisplayName": "Location (deprecated)", "Type": "String", "DependsOn": "", - "DefaultValue": "global", - "Required": true, + "DefaultValue": "", + "Required": false, "IsPAMEligible": false }, { @@ -108,7 +108,7 @@ New-StoreType "GcpCertMgr" @' "IsPAMEligible": false } ], - "StorePathDescription": "This is not used and should be defaulted to n/a per the certificate store type set up.", + "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", "EntryParameters": [] } '@ From 91089d62171dfdb4cced7dfbc694445799003eb3 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 11:49:19 -0400 Subject: [PATCH 07/31] feat: pre-flight alias validation against GCP resource-ID rules Management/Add was doing CheckForDuplicate + LoadPkcs12 + ExtractPrivateKey before calling GCP, only to have GCP reject the request with HTTP 400 if the alias contained capital letters or other illegal characters. Now we validate against [a-z]([-a-z0-9]*[a-z0-9])? (max 63 chars) immediately on entry to PerformAddition and produce a clear flow-step failure with a suggested normalized alias (e.g. 'Cert1' -> 'cert1'). No API calls or PFX work happen on a non-conforming alias. Operator workflow when validation fails: rename the certificate in Keyfactor Command to the suggested form and re-run Management/Add. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 +++++ GcpCertManager/Jobs/JobBase.cs | 55 +++++++++++++++++++++++++++++++ GcpCertManager/Jobs/Management.cs | 6 ++++ docsource/gcpcertmgr.md | 12 +++++++ 4 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f2e84..8b6483a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,15 @@ v1.2.0 - unreleased with `Required: false` and a deprecation note so existing v1.1 stores keep rendering correctly in Command's UI. +### Added (validation) +- Pre-flight alias validation in Management/Add. The orchestrator now checks + the certificate alias against GCP Certificate Manager's resource-ID rule + (`[a-z]([-a-z0-9]*[a-z0-9])?`, max 63 chars) before doing any API work or + PFX parsing. A non-conforming alias produces a clear `[FAIL] ValidateAlias` + flow step with a suggested normalized form (e.g. `Cert1` → `cert1`), + replacing the previous behavior of failing 700ms later with a wall-of-JSON + HTTP 400 from GCP. See `JobBase.ValidateGcpCertificateId`. + ### Backwards compatibility - v1.1-shape stores (Store Path blank or `n/a`, Client Machine = Project ID, Location custom property = region) continue to work via a deprecation-logged diff --git a/GcpCertManager/Jobs/JobBase.cs b/GcpCertManager/Jobs/JobBase.cs index 99e9be1..0862b19 100644 --- a/GcpCertManager/Jobs/JobBase.cs +++ b/GcpCertManager/Jobs/JobBase.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Text.RegularExpressions; using Google; using Keyfactor.Orchestrators.Common.Enums; using Keyfactor.Orchestrators.Extensions; @@ -174,5 +175,59 @@ protected string ResolveGcpResourcePath(string storePath, string projectId, stri return $"projects/{projectId}/locations/{location}"; } + + // GCP Certificate Manager certificate IDs must match Google's resource-id rule: + // [a-z]([-a-z0-9]*[a-z0-9])? length 1..63 + // i.e. lowercase letter first, lowercase letters/digits/hyphens after, must not + // end with a hyphen. The API rejects anything else with HTTP 400 INVALID_ARGUMENT. + // Source: https://cloud.google.com/certificate-manager/docs/reference/rest/v1/projects.locations.certificates/create#path-parameters + private static readonly Regex GcpCertificateIdPattern = new Regex( + @"^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Throws when is not a + /// legal GCP Certificate Manager resource ID. Call this before doing any + /// expensive PFX parsing or API work so the operator sees a clear error in the + /// flow trace instead of a 400 wall-of-JSON from GCP. + /// + protected static void ValidateGcpCertificateId(string alias) + { + if (string.IsNullOrWhiteSpace(alias)) + throw new ArgumentException("Certificate alias is required.", nameof(alias)); + + if (alias.Length > 63) + throw new ArgumentException( + $"GCP Certificate Manager requires the alias to be 63 characters or fewer; got '{alias}' ({alias.Length} chars).", + nameof(alias)); + + if (!GcpCertificateIdPattern.IsMatch(alias)) + { + var suggestion = SuggestValidAlias(alias); + throw new ArgumentException( + $"GCP Certificate Manager rejects the alias '{alias}'. Aliases must match [a-z]([-a-z0-9]*[a-z0-9])? - " + + $"start with a lowercase letter, contain only lowercase letters/digits/hyphens, and not end with a hyphen. " + + $"Try renaming the certificate in Keyfactor Command to '{suggestion}' and retry.", + nameof(alias)); + } + } + + private static string SuggestValidAlias(string alias) + { + if (string.IsNullOrEmpty(alias)) return "cert"; + // Best-effort lowercase + replace illegal chars with '-' + trim leading non-letters and trailing hyphens. + var lowered = alias.ToLowerInvariant(); + var chars = new System.Text.StringBuilder(lowered.Length); + foreach (var c in lowered) + { + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') chars.Append(c); + else chars.Append('-'); + } + var s = chars.ToString().Trim('-'); + // Resource IDs must start with a letter. + while (s.Length > 0 && !(s[0] >= 'a' && s[0] <= 'z')) s = s.Substring(1); + if (s.Length == 0) return "cert"; + return s.Length > 63 ? s.Substring(0, 63).TrimEnd('-') : s; + } } } diff --git a/GcpCertManager/Jobs/Management.cs b/GcpCertManager/Jobs/Management.cs index 8f86bf2..c75b4b0 100644 --- a/GcpCertManager/Jobs/Management.cs +++ b/GcpCertManager/Jobs/Management.cs @@ -131,6 +131,12 @@ private JobResult PerformRemoval(CertificateManagerService svc, ManagementJobCon private JobResult PerformAddition(CertificateManagerService svc, ManagementJobConfiguration config, string storePath, FlowLogger flow) { + // Validate the alias before any API calls or PFX parsing - GCP rejects + // non-conforming IDs with HTTP 400 after we've already done the expensive + // work, so failing fast saves both time and a confusing error message. + flow.Step("ValidateAlias", () => ValidateGcpCertificateId(CertificateName), + $"alias={CertificateName}"); + var duplicate = false; flow.Step("CheckForDuplicate", () => duplicate = CheckForDuplicate(storePath, CertificateName, svc), $"alias={CertificateName}"); diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index c332bbe..31ee74c 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -66,6 +66,18 @@ Required APIs to enable in the **service account's home project**: - Cloud Resource Manager API - Certificate Manager API (also needs to be enabled in every project you actually inventory) +### Certificate alias rules + +GCP Certificate Manager constrains certificate resource IDs to a strict shape: + +- 1 to 63 characters +- Lowercase letters, digits, hyphens only +- Must start with a lowercase letter +- Must not end with a hyphen +- Regex: `[a-z]([-a-z0-9]*[a-z0-9])?` + +The orchestrator validates the alias against this rule **before** any API calls or PFX parsing during Management/Add. A non-conforming alias fails fast with a `[FAIL] ValidateAlias` step in the flow trace and a suggestion of a normalized alias (e.g. `Cert1` → `cert1`). Rename the certificate in Keyfactor Command to the suggested form and retry the Management/Add job. + ### Architecture and logging Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. From 062f3cad2791168762c85ae5df12cae8ba950d58 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 15:52:14 +0000 Subject: [PATCH 08/31] Update generated docs --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 64f48ac..aa24894 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,18 @@ Required APIs to enable in the **service account's home project**: - Cloud Resource Manager API - Certificate Manager API (also needs to be enabled in every project you actually inventory) +#### Certificate alias rules + +GCP Certificate Manager constrains certificate resource IDs to a strict shape: + +- 1 to 63 characters +- Lowercase letters, digits, hyphens only +- Must start with a lowercase letter +- Must not end with a hyphen +- Regex: `[a-z]([-a-z0-9]*[a-z0-9])?` + +The orchestrator validates the alias against this rule **before** any API calls or PFX parsing during Management/Add. A non-conforming alias fails fast with a `[FAIL] ValidateAlias` step in the flow trace and a suggestion of a normalized alias (e.g. `Cert1` → `cert1`). Rename the certificate in Keyfactor Command to the suggested form and retry the Management/Add job. + #### Architecture and logging Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. From 336c00a3928bdc4cc440288c5cffd25fc51ac30d Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 12:25:08 -0400 Subject: [PATCH 09/31] docs: explain why Store Path became the canonical source Adds a "Design rationale: why Store Path is the source of truth" section to docsource/gcpcertmgr.md covering: the IDiscoveryJobExtension contract constraint that forced the schema change, the alternatives considered (manual edits per approval, one job per project, posting to Command's REST API directly), and the trade-offs accepted (Client Machine repurposed as display label, Location property deprecated not removed). CHANGELOG entry now points readers at the docs section for the long form. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 ++++- docsource/gcpcertmgr.md | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b6483a..9761841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,10 @@ v1.2.0 - unreleased stores configure the same way. **Store Path** is now the single source of truth for which Certificate Manager instance the store targets, in canonical form `projects/{projectId}/locations/{location}`. Inventory and Management - read the GCP resource path from this field for both flows. + read the GCP resource path from this field for both flows. Full design + rationale (constraint, alternatives considered, trade-offs accepted) lives + in `docsource/gcpcertmgr.md` under "Design rationale: why Store Path is the + source of truth". - **Client Machine** is repurposed as a display-only label. The recommended value is the GCP Organization ID; the orchestrator does not parse a project ID out of it. Documented in the updated store-type description. diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index 31ee74c..7c4ab4b 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -93,6 +93,32 @@ A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the The deprecation warning will stop on the next job run once Store Path is populated. The fallback will be removed in v2.0. +### Design rationale: why Store Path is the source of truth + +In v1.1 the orchestrator built the GCP resource path from **Client Machine** (= GCP Project ID) + the **Location** custom property, with **Store Path** unused (defaulted to `n/a`). Adding Discovery in v1.2 forced this model to change. Here's why. + +The Keyfactor `IDiscoveryJobExtension` contract emits a plain `List` of discovered locations - there is no hook to set per-candidate Client Machine values. When an operator approves a discovered candidate (or auto-approval is enabled via the `Create Certificate Store If Missing` checkbox), Keyfactor Command creates the new store with: + +- Store Path = the discovered location string (e.g. `projects/edgecerts/locations/global`) +- Client Machine = whatever the discovery job's Client Machine was set to - one value shared across every candidate +- Custom properties = their store-type defaults + +Under the v1.1 model that meant every Discovery-approved store ended up with the *same* Client Machine across every project in the organization, which is wrong: each store needs its project ID to make GCP API calls. The first time inventory ran against a Discovery-approved store, that's exactly what produced an `HTTP 403 CONSUMER_INVALID` error against `projects//locations/global` - GCP correctly saying "that's not a valid project ID." + +#### Alternatives considered + +| Option | Why we didn't pick it | +|---|---| +| Force the operator to manually edit Client Machine after every Discovery approval | Friction. Discovery should produce working stores without an extra editing step per candidate, especially if the operator wants to use auto-approval. | +| One discovery job per project (so each job's Client Machine = that project's ID) | Impractical: an organization with 100 projects would need 100 discovery jobs, each independently configured and scheduled. | +| Have Discovery POST stores directly via Keyfactor Command's REST API instead of the standard `SubmitDiscoveryUpdate` callback | Non-standard pattern, much larger code surface, and diverges from how every other Keyfactor orchestrator works - making this orchestrator harder to maintain alongside the rest of the Keyfactor extension catalog. | +| **Make Store Path the canonical source for both manual and Discovery flows** | Picked. The discovered storepath already encodes both project and location, so reading it directly (instead of reconstructing from Client Machine + Location) means Discovery-approved stores work with zero operator edits, and manually-created stores configure the same way. Smallest code change for the cleanest user-facing schema. | + +#### Trade-offs we accepted + +- **Client Machine is now a display label**, not load-bearing. Some other Keyfactor orchestrators use Client Machine as a literal target host; for GCP that does not fit, because the orchestrator talks to a single GCP API endpoint regardless of which project a store targets - there is no per-store host to put there. The recommended value (GCP Organization ID) at least groups GCP stores together usefully in Command's UI. +- **The Location custom property is deprecated, not removed**. Keeping it in the manifest with `Required: false` preserves v1.1 stores' UI rendering during the transition. The fallback path in `JobBase.ResolveGcpResourcePath` reads it for v1.1-shaped stores (Store Path blank or `n/a`) and emits a `LogWarning` each time naming the migration step. Removal is scheduled for v2.0. + ### Vendor docs - [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs) From 69c2f5514d0d7ea0fb781eabed70d1c31844ae03 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 16:29:54 +0000 Subject: [PATCH 10/31] Update generated docs --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index aa24894..7299737 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,32 @@ A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the The deprecation warning will stop on the next job run once Store Path is populated. The fallback will be removed in v2.0. +#### Design rationale: why Store Path is the source of truth + +In v1.1 the orchestrator built the GCP resource path from **Client Machine** (= GCP Project ID) + the **Location** custom property, with **Store Path** unused (defaulted to `n/a`). Adding Discovery in v1.2 forced this model to change. Here's why. + +The Keyfactor `IDiscoveryJobExtension` contract emits a plain `List` of discovered locations - there is no hook to set per-candidate Client Machine values. When an operator approves a discovered candidate (or auto-approval is enabled via the `Create Certificate Store If Missing` checkbox), Keyfactor Command creates the new store with: + +- Store Path = the discovered location string (e.g. `projects/edgecerts/locations/global`) +- Client Machine = whatever the discovery job's Client Machine was set to - one value shared across every candidate +- Custom properties = their store-type defaults + +Under the v1.1 model that meant every Discovery-approved store ended up with the *same* Client Machine across every project in the organization, which is wrong: each store needs its project ID to make GCP API calls. The first time inventory ran against a Discovery-approved store, that's exactly what produced an `HTTP 403 CONSUMER_INVALID` error against `projects//locations/global` - GCP correctly saying "that's not a valid project ID." + +##### Alternatives considered + +| Option | Why we didn't pick it | +|---|---| +| Force the operator to manually edit Client Machine after every Discovery approval | Friction. Discovery should produce working stores without an extra editing step per candidate, especially if the operator wants to use auto-approval. | +| One discovery job per project (so each job's Client Machine = that project's ID) | Impractical: an organization with 100 projects would need 100 discovery jobs, each independently configured and scheduled. | +| Have Discovery POST stores directly via Keyfactor Command's REST API instead of the standard `SubmitDiscoveryUpdate` callback | Non-standard pattern, much larger code surface, and diverges from how every other Keyfactor orchestrator works - making this orchestrator harder to maintain alongside the rest of the Keyfactor extension catalog. | +| **Make Store Path the canonical source for both manual and Discovery flows** | Picked. The discovered storepath already encodes both project and location, so reading it directly (instead of reconstructing from Client Machine + Location) means Discovery-approved stores work with zero operator edits, and manually-created stores configure the same way. Smallest code change for the cleanest user-facing schema. | + +##### Trade-offs we accepted + +- **Client Machine is now a display label**, not load-bearing. Some other Keyfactor orchestrators use Client Machine as a literal target host; for GCP that does not fit, because the orchestrator talks to a single GCP API endpoint regardless of which project a store targets - there is no per-store host to put there. The recommended value (GCP Organization ID) at least groups GCP stores together usefully in Command's UI. +- **The Location custom property is deprecated, not removed**. Keeping it in the manifest with `Required: false` preserves v1.1 stores' UI rendering during the transition. The fallback path in `JobBase.ResolveGcpResourcePath` reads it for v1.1-shaped stores (Store Path blank or `n/a`) and emits a `LogWarning` each time naming the migration step. Removal is scheduled for v2.0. + #### Vendor docs - [Google Cloud Certificate Manager](https://cloud.google.com/certificate-manager/docs) From dc5890d3a07784bd00a1305ddd25be58108394eb Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 12:33:41 -0400 Subject: [PATCH 11/31] refactor: consolidate authentication around ADC, ditch Google screenshots Authentication now consolidates around Application Default Credentials. The Service Account Key File Path custom store property is deprecated because Keyfactor Command's discovery-job UI does not surface store-type custom properties, so file-based auth never worked uniformly across all four job types. ADC works via workload identity (GCE / GKE) or via the GOOGLE_APPLICATION_CREDENTIALS environment variable on the orchestrator host. v1.1 stores with the property populated continue to work via a deprecation-logged fallback in GcpCertificateManagerClient.LoadCredentials; the property is scheduled for removal in v2.0. Drops the dead ServiceAccountKey JobProperty handling from Discovery.cs since it was never reachable from Keyfactor Command's UI. Removes three Google Cloud Console screenshot GIFs from docsource/ (ServiceAccountSettings.gif, ApiAccessNeeded.gif, GoogleKeyJsonDownload.gif) and replaces them with verbal step-by-step instructions and gcloud commands in docsource/content.md. Google's Console UI changes regularly and screenshots go stale; gcloud commands and the underlying APIs are the stable interface. The Keyfactor Command store-type dialog screenshots are doctool-managed and remain. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 35 +++++++++-- .../Client/GcpCertificateManagerClient.cs | 19 +++++- GcpCertManager/Jobs/Discovery.cs | 24 ++------ docsource/content.md | 57 +++++++++++++++--- docsource/gcpcertmgr.md | 26 ++++---- docsource/images/ApiAccessNeeded.gif | Bin 21904 -> 0 bytes docsource/images/GoogleKeyJsonDownload.gif | Bin 44928 -> 0 bytes docsource/images/ServiceAccountSettings.gif | Bin 31039 -> 0 bytes integration-manifest.json | 4 +- 9 files changed, 117 insertions(+), 48 deletions(-) delete mode 100644 docsource/images/ApiAccessNeeded.gif delete mode 100644 docsource/images/GoogleKeyJsonDownload.gif delete mode 100644 docsource/images/ServiceAccountSettings.gif diff --git a/CHANGELOG.md b/CHANGELOG.md index 9761841..7472627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,10 @@ v1.2.0 - unreleased IAM bindings (the customer scopes that at the org root). - "Directories to search" is repurposed as a comma-separated list of GCP locations (regions); defaults to `global` when blank. - - Service account credentials default to Application Default Credentials, - matching the recommended deployment on a GCE VM / GKE pod with workload - identity. An optional `ServiceAccountKey` JobProperty (file name relative - to the orchestrator extension dir) is supported for parity with the - inventory/management job configuration. + - Service account credentials use Application Default Credentials, matching + the recommended deployment on a GCE VM / GKE pod with workload identity, + or the `GOOGLE_APPLICATION_CREDENTIALS` environment variable on the + orchestrator host when running outside GCP. - Added `FlowLogger` and `JobBase` infrastructure shared across all three jobs (Inventory, Management, Discovery). FlowLogger captures step-by-step traces with timing, and the summary is appended to `JobResult.FailureMessage` on @@ -56,6 +55,32 @@ v1.2.0 - unreleased with `Required: false` and a deprecation note so existing v1.1 stores keep rendering correctly in Command's UI. +### Changed (authentication) +- Authentication consolidates around Application Default Credentials (ADC). + This is the only credential mechanism that works uniformly across all four + job types - the previous `Service Account Key File Path` custom store + property was readable only by Inventory/Management because the Keyfactor + Command discovery-job UI does not surface store-type custom properties. + ADC works whether the orchestrator runs inside GCP (via workload identity + / GCE metadata server) or outside GCP (via the + `GOOGLE_APPLICATION_CREDENTIALS` environment variable on the orchestrator + host). +- The `Service Account Key File Path` (`ServiceAccountKey`) custom store + property is deprecated. New stores leave it blank. v1.1 stores that have + it populated continue to work via a deprecation-logged fallback in + `GcpCertificateManagerClient.LoadCredentials`. Removal is scheduled for + v2.0. + +### Removed (docs) +- Removed three Google Cloud Console screenshot GIFs from `docsource/` that + documented the service-account creation, API enablement, and JSON-key + download flows: `ServiceAccountSettings.gif`, `ApiAccessNeeded.gif`, + `GoogleKeyJsonDownload.gif`. Replaced with verbal step-by-step + instructions and `gcloud` commands in `docsource/content.md` so the docs + do not go stale when Google redesigns the Console UI. The Keyfactor + Command store-type dialog screenshots in `docsource/images/` are + doctool-managed and remain. + ### Added (validation) - Pre-flight alias validation in Management/Add. The orchestrator now checks the certificate alias against GCP Certificate Manager's resource-ID rule diff --git a/GcpCertManager/Client/GcpCertificateManagerClient.cs b/GcpCertManager/Client/GcpCertificateManagerClient.cs index 20679ec..bb3a242 100644 --- a/GcpCertManager/Client/GcpCertificateManagerClient.cs +++ b/GcpCertManager/Client/GcpCertificateManagerClient.cs @@ -58,10 +58,23 @@ public CloudResourceManagerService GetCloudResourceManager(string credentialFile private static GoogleCredential LoadCredentials(string credentialFileName, ILogger logger) { - //Credentials file needs to be in the same location of the executing assembly + // Credential resolution order: + // 1. Explicit ServiceAccountKey (file in the extension dir) - DEPRECATED in v1.2 + // 2. Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS env var, + // or GCE VM / GKE pod metadata server when running inside GCP) + // + // ADC is the canonical path because the Keyfactor discovery-job UI does not + // surface the per-store ServiceAccountKey custom property, so file-based auth + // can't be configured uniformly across all four job types. if (!string.IsNullOrEmpty(credentialFileName)) { - logger.LogDebug("Has credential file name"); + logger.LogWarning( + "The ServiceAccountKey store property ('{FileName}') is deprecated as of v1.2 and will be removed in v2.0. " + + "Switch to Application Default Credentials by setting GOOGLE_APPLICATION_CREDENTIALS as a machine-level " + + "environment variable on the orchestrator host (or by running the orchestrator on a GCE VM / GKE pod with " + + "workload identity), then clear this store property.", + credentialFileName); + var strExeFilePath = Assembly.GetExecutingAssembly().Location; var strWorkPath = Path.GetDirectoryName(strExeFilePath); var strSettingsJsonFilePath = Path.Combine(strWorkPath ?? string.Empty, credentialFileName); @@ -72,7 +85,7 @@ private static GoogleCredential LoadCredentials(string credentialFileName, ILogg } } - logger.LogDebug("No credential file name"); + logger.LogDebug("Using Application Default Credentials"); return GoogleCredential.GetApplicationDefaultAsync().Result; } diff --git a/GcpCertManager/Jobs/Discovery.cs b/GcpCertManager/Jobs/Discovery.cs index e63c5c5..966c92e 100644 --- a/GcpCertManager/Jobs/Discovery.cs +++ b/GcpCertManager/Jobs/Discovery.cs @@ -32,11 +32,6 @@ public class Discovery : JobBase, IDiscoveryJobExtension // exact casing has shifted across Command versions. private static readonly string[] DirsToSearchKeys = { "dirs", "Dirs", "directories", "Directories", "DirsToSearch" }; - // Optional override: path to a service account JSON file installed alongside - // the orchestrator extension. When omitted, GoogleCredential.ApplicationDefault - // is used - which is the recommended path when the orchestrator runs on a GCE - // VM / GKE pod with a workload-identity-bound service account. - private const string ServiceAccountKeyProperty = "ServiceAccountKey"; public Discovery(IPAMSecretResolver resolver) : base(resolver) { @@ -91,26 +86,19 @@ private JobResult PerformDiscovery(DiscoveryJobConfiguration config, var orgIdHint = (config.ClientMachine ?? string.Empty).Trim(); flow.Step("ParseConfig", $"orgIdHint={(string.IsNullOrEmpty(orgIdHint) ? "" : orgIdHint)}"); - string serviceAccountKey = null; - flow.Step("ResolveServiceAccountKey", () => - { - if (config.JobProperties != null && - config.JobProperties.TryGetValue(ServiceAccountKeyProperty, out var raw)) - { - var s = raw?.ToString(); - if (!string.IsNullOrWhiteSpace(s)) serviceAccountKey = s.Trim(); - } - }, $"source={(serviceAccountKey == null ? "ADC" : "JobProperties")}"); - var (locations, locationSource) = ResolveLocations(config); flow.Step("ResolveLocations", $"source={locationSource}, locations=[{string.Join(",", locations)}]"); + // Authentication uses Application Default Credentials. The Keyfactor Command + // discovery-job UI does not surface store-type custom properties, so the + // file-based ServiceAccountKey custom property used by Inventory/Management + // (deprecated as of v1.2) was never reachable here in the first place. CloudResourceManagerService crm = null; flow.Step("CreateApiClient", () => { - crm = new GcpCertificateManagerClient().GetCloudResourceManager(serviceAccountKey); - }); + crm = new GcpCertificateManagerClient().GetCloudResourceManager(null); + }, "source=ADC"); List projects = null; flow.Step("ListProjects", () => diff --git a/docsource/content.md b/docsource/content.md index 30bf02c..920542b 100644 --- a/docsource/content.md +++ b/docsource/content.md @@ -17,17 +17,56 @@ This applies equally to manually-created stores and Discovery-approved stores. T The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair, with Store Path pre-populated in canonical form. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for full operator-facing details. -## Requirements +## GCP setup prerequisites -**Google Cloud Configuration** +Before configuring the orchestrator, make sure your Google Cloud project is ready. Read the official [Google Certificate Manager](https://cloud.google.com/certificate-manager/docs) documentation for product background. The steps below are intentionally text-only; Google's Cloud Console UI changes regularly and the underlying APIs and `gcloud` commands are the stable interface. -1. Read up on [Google Certificate Manager](https://cloud.google.com/certificate-manager/docs) and how it works. +### 1. Enable the required Google Cloud APIs -2. Either a Google Service Account is needed with the following permissions (Note: Workload Identity Management Should be used but at the time of the writing it was not available in the .net library yet), or the virtual machine running the Keyfactor Orchestrator Service must reside within Google Cloud. -![](docsource/images/ServiceAccountSettings.gif) +In the project that will host the orchestrator's service account ("the SA project"), enable both: -3. The following Api Access is needed: -![](docsource/images/ApiAccessNeeded.gif) +- **Cloud Resource Manager API** - lets the Discovery job enumerate projects via `projects.search`. Required even if you only use Inventory/Management today, because the API enablement check runs against the SA project regardless of what target project the call reads. +- **Certificate Manager API** - read/write access to certificate resources. This must additionally be enabled in **every project** you intend to inventory or manage certs in. -4. If authenticating via service account, download the Json Credential file as shown below: -![](docsource/images/GoogleKeyJsonDownload.gif) \ No newline at end of file +`gcloud` (one-shot for both APIs in the SA project): + +``` +gcloud services enable cloudresourcemanager.googleapis.com certificatemanager.googleapis.com --project= +``` + +### 2. Create a service account and grant organization-level roles + +Service account credentials are *identity*, not authorization - the IAM bindings determine what the SA can see and do. Bind these roles **at the organization** so the SA inherits visibility into every folder and project: + +| Role | Why | +|---|---| +| `roles/browser` | So `projects.search` returns projects nested in folders, not just top-level projects | +| `roles/certificatemanager.viewer` | Inventory: list certificates in each store | +| `roles/certificatemanager.editor` | Management/Add and Management/Remove | + +``` +gcloud iam service-accounts create kf-orchestrator \ + --project= \ + --display-name="Keyfactor Universal Orchestrator" + +ORG= +SA=kf-orchestrator@.iam.gserviceaccount.com + +gcloud organizations add-iam-policy-binding $ORG --member="serviceAccount:$SA" --role="roles/browser" +gcloud organizations add-iam-policy-binding $ORG --member="serviceAccount:$SA" --role="roles/certificatemanager.viewer" +gcloud organizations add-iam-policy-binding $ORG --member="serviceAccount:$SA" --role="roles/certificatemanager.editor" +``` + +### 3. Provide credentials to the orchestrator host (Application Default Credentials) + +The orchestrator authenticates exclusively via [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) (ADC). There are two supported deployment modes: + +**Orchestrator runs inside GCP (recommended)** - on a GCE VM or GKE pod with the service account attached via workload identity. ADC discovers the service account from the metadata server automatically. No further configuration on the host. + +**Orchestrator runs outside GCP** - on a Windows host, on-prem Linux, etc.: + +1. Create a JSON key for the service account: `gcloud iam service-accounts keys create kf-orchestrator.json --iam-account=$SA`. Google never re-displays this key, so save it somewhere safe. +2. Copy the JSON key to a secured location on the orchestrator host. Lock down filesystem permissions so only the account that runs the Keyfactor Orchestrator service can read it. +3. Set the `GOOGLE_APPLICATION_CREDENTIALS` machine-level environment variable to the absolute path of the JSON key. Restart the Keyfactor Orchestrator service so it picks up the variable. + +> **Note on the deprecated `Service Account Key File Path` store property.** Earlier versions of the orchestrator accepted a JSON filename in a per-store custom property and read the file from the orchestrator extension directory. That mechanism is deprecated in v1.2 because the Discovery job has no way to surface custom store properties in Keyfactor Command's discovery-job UI - so file-based auth can't be configured uniformly across all four job types. Existing v1.1 stores with the property populated continue to work, but every job run logs a deprecation warning. The field is scheduled for removal in v2.0. diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index 7c4ab4b..a297ec0 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -18,7 +18,7 @@ That single value carries both the GCP project and the location (region or `glob |---|---|---| | **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}` | Inventory, Management, Discovery (emit) | | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | -| **Service Account Key File Path** (custom) | Filename of the JSON key in the orchestrator extension directory. Blank → Application Default Credentials. | Credential loader | +| **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | #### Manually creating a store @@ -27,14 +27,14 @@ Set: - **Client Machine**: GCP Organization ID - **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` -- **Service Account Key File Path**: `kf-orchestrator.json` (or blank for ADC) +- **Service Account Key File Path**: leave blank (deprecated; ADC is used) - **Location**: leave blank -#### Approving a Discovery-discovered store +Authentication uses Application Default Credentials - see "Service account credentials" below. -Discovery emits one candidate per (project, location) pair in canonical form, so the only field you might want to set on approval is **Service Account Key File Path** (recommended: type the JSON filename for explicit control; leave blank to inherit ADC). Click SAVE without further edits. +#### Approving a Discovery-discovered store -If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. +Discovery emits one candidate per (project, location) pair in canonical form, so no edits are required on approval - just click SAVE. If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. ### Discovery job configuration @@ -50,10 +50,12 @@ The candidate count is `projects × locations`, so be deliberate about how many #### Service account credentials -Both the discovery job and the inventory/management jobs resolve credentials in the same order: +The orchestrator authenticates exclusively via Application Default Credentials. Two supported deployment modes: + +- **Inside GCP** - on a GCE VM or GKE pod with the service account attached via workload identity. ADC discovers the service account from the metadata server automatically. No host configuration needed. +- **Outside GCP** - on a Windows host or on-prem Linux. Set the `GOOGLE_APPLICATION_CREDENTIALS` machine-level environment variable to the absolute path of the service account's JSON key, then restart the Keyfactor Orchestrator service so it picks up the variable. The account that runs the orchestrator service must have read access to the JSON key file. -1. If a `ServiceAccountKey` value is configured (custom store property for inventory/management; not exposed in the discovery-job UI - see env-var fallback below), the JSON key file with that name is read from the orchestrator extension directory. -2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. On Windows hosts this means setting `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable to the absolute path of the JSON key, then restarting the Keyfactor Orchestrator service. On a GCE VM / GKE pod with workload identity, ADC works automatically. +The legacy `Service Account Key File Path` custom store property (a JSON filename relative to the orchestrator extension directory) is **deprecated as of v1.2** because the Discovery job has no way to surface custom store properties in Keyfactor Command's discovery-job UI - so file-based auth can't be configured uniformly across all four job types. v1.1 stores with the property populated continue to work, but every job run logs a deprecation warning. The field is scheduled for removal in v2.0; new stores should leave it blank. The service account needs at minimum: @@ -84,14 +86,15 @@ Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to recor ### Migrating v1.1 stores -A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the GCP Project ID, and the `Location` custom property set to the region. These continue to work in v1.2 through a fallback path, but every inventory/management run logs a deprecation warning naming the store. To migrate, edit each affected store: +A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the GCP Project ID, the `Location` custom property set to the region, and possibly the `Service Account Key File Path` custom property pointing at a JSON key in the orchestrator extension directory. These continue to work in v1.2 through fallback paths, but every inventory/management run logs deprecation warnings naming the store. To migrate, edit each affected store: 1. Set **Store Path** to `projects/{the-current-Client-Machine-value}/locations/{the-current-Location-value}`. 2. Optionally change **Client Machine** to the GCP Organization ID for cleaner UI grouping. 3. Optionally clear the **Location** field (no longer required). -4. Save. +4. Configure ADC on the orchestrator host (see "Service account credentials") and clear the **Service Account Key File Path** field. +5. Save. -The deprecation warning will stop on the next job run once Store Path is populated. The fallback will be removed in v2.0. +The deprecation warnings will stop on the next job run once the store is fully migrated. Both fallbacks will be removed in v2.0. ### Design rationale: why Store Path is the source of truth @@ -118,6 +121,7 @@ Under the v1.1 model that meant every Discovery-approved store ended up with the - **Client Machine is now a display label**, not load-bearing. Some other Keyfactor orchestrators use Client Machine as a literal target host; for GCP that does not fit, because the orchestrator talks to a single GCP API endpoint regardless of which project a store targets - there is no per-store host to put there. The recommended value (GCP Organization ID) at least groups GCP stores together usefully in Command's UI. - **The Location custom property is deprecated, not removed**. Keeping it in the manifest with `Required: false` preserves v1.1 stores' UI rendering during the transition. The fallback path in `JobBase.ResolveGcpResourcePath` reads it for v1.1-shaped stores (Store Path blank or `n/a`) and emits a `LogWarning` each time naming the migration step. Removal is scheduled for v2.0. +- **The Service Account Key File Path custom property is deprecated, not removed**, for the same backwards-compatibility reason. Authentication consolidates around Application Default Credentials, the GCP-recommended pattern, which works uniformly across all four job types - the deprecated property only ever worked for Inventory/Management because Discovery's UI doesn't expose store-type custom properties. Removal is scheduled for v2.0. ### Vendor docs diff --git a/docsource/images/ApiAccessNeeded.gif b/docsource/images/ApiAccessNeeded.gif deleted file mode 100644 index 8a139eb00e095487c0f4b4ee3ead5a3ba09719b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21904 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

Iy1ADTH^`yR@oE|u7)SQ>EbdV93A&K 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 U^UO5Y3Vf5wHs`D}wgCYEJL@n!(*OVf diff --git a/docsource/images/ServiceAccountSettings.gif b/docsource/images/ServiceAccountSettings.gif deleted file mode 100644 index b61d1bda6378ac92eeae79e4dff8022a3393cb0d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31039 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 K=bX!c0027)MhbHP diff --git a/integration-manifest.json b/integration-manifest.json index 0c836a2..b19f008 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -51,13 +51,13 @@ }, { "Name": "ServiceAccountKey", - "DisplayName": "Service Account Key File Path", + "DisplayName": "Service Account Key File Path (deprecated)", "Type": "String", "DependsOn": "", "DefaultValue": "", "Required": false, "IsPAMEligible": false, - "Description": "File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host)." + "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." } ], "ClientMachineDescription": "Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path.", From c21185e7dee2c079b54703d4c61e410911f42c3f Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 16:38:28 +0000 Subject: [PATCH 12/31] Update generated docs --- README.md | 103 +++++++++++++----- .../bash/curl_create_store_types.sh | 2 +- .../restmethod_create_store_types.ps1 | 2 +- 3 files changed, 76 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 7299737..3b79a56 100644 --- a/README.md +++ b/README.md @@ -63,19 +63,6 @@ The Google Cloud Provider Certificate Manager Universal Orchestrator extension i Before installing the Google Cloud Provider Certificate Manager Universal Orchestrator extension, we recommend that you install [kfutil](https://github.com/Keyfactor/kfutil). Kfutil is a command-line tool that simplifies the process of creating store types, installing extensions, and instantiating certificate stores in Keyfactor Command. -**Google Cloud Configuration** - -1. Read up on [Google Certificate Manager](https://cloud.google.com/certificate-manager/docs) and how it works. - -2. Either a Google Service Account is needed with the following permissions (Note: Workload Identity Management Should be used but at the time of the writing it was not available in the .net library yet), or the virtual machine running the Keyfactor Orchestrator Service must reside within Google Cloud. -![](docsource/images/ServiceAccountSettings.gif) - -3. The following Api Access is needed: -![](docsource/images/ApiAccessNeeded.gif) - -4. If authenticating via service account, download the Json Credential file as shown below: -![](docsource/images/GoogleKeyJsonDownload.gif) - ## GcpCertMgr Certificate Store Type @@ -101,7 +88,7 @@ That single value carries both the GCP project and the location (region or `glob |---|---|---| | **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}` | Inventory, Management, Discovery (emit) | | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | -| **Service Account Key File Path** (custom) | Filename of the JSON key in the orchestrator extension directory. Blank → Application Default Credentials. | Credential loader | +| **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | ##### Manually creating a store @@ -110,14 +97,14 @@ Set: - **Client Machine**: GCP Organization ID - **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` -- **Service Account Key File Path**: `kf-orchestrator.json` (or blank for ADC) +- **Service Account Key File Path**: leave blank (deprecated; ADC is used) - **Location**: leave blank -##### Approving a Discovery-discovered store +Authentication uses Application Default Credentials - see "Service account credentials" below. -Discovery emits one candidate per (project, location) pair in canonical form, so the only field you might want to set on approval is **Service Account Key File Path** (recommended: type the JSON filename for explicit control; leave blank to inherit ADC). Click SAVE without further edits. +##### Approving a Discovery-discovered store -If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. +Discovery emits one candidate per (project, location) pair in canonical form, so no edits are required on approval - just click SAVE. If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. #### Discovery job configuration @@ -133,10 +120,12 @@ The candidate count is `projects × locations`, so be deliberate about how many ##### Service account credentials -Both the discovery job and the inventory/management jobs resolve credentials in the same order: +The orchestrator authenticates exclusively via Application Default Credentials. Two supported deployment modes: + +- **Inside GCP** - on a GCE VM or GKE pod with the service account attached via workload identity. ADC discovers the service account from the metadata server automatically. No host configuration needed. +- **Outside GCP** - on a Windows host or on-prem Linux. Set the `GOOGLE_APPLICATION_CREDENTIALS` machine-level environment variable to the absolute path of the service account's JSON key, then restart the Keyfactor Orchestrator service so it picks up the variable. The account that runs the orchestrator service must have read access to the JSON key file. -1. If a `ServiceAccountKey` value is configured (custom store property for inventory/management; not exposed in the discovery-job UI - see env-var fallback below), the JSON key file with that name is read from the orchestrator extension directory. -2. Otherwise, `GoogleCredential.GetApplicationDefault()` is used. On Windows hosts this means setting `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable to the absolute path of the JSON key, then restarting the Keyfactor Orchestrator service. On a GCE VM / GKE pod with workload identity, ADC works automatically. +The legacy `Service Account Key File Path` custom store property (a JSON filename relative to the orchestrator extension directory) is **deprecated as of v1.2** because the Discovery job has no way to surface custom store properties in Keyfactor Command's discovery-job UI - so file-based auth can't be configured uniformly across all four job types. v1.1 stores with the property populated continue to work, but every job run logs a deprecation warning. The field is scheduled for removal in v2.0; new stores should leave it blank. The service account needs at minimum: @@ -167,14 +156,15 @@ Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to recor #### Migrating v1.1 stores -A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the GCP Project ID, and the `Location` custom property set to the region. These continue to work in v1.2 through a fallback path, but every inventory/management run logs a deprecation warning naming the store. To migrate, edit each affected store: +A v1.1-shape store has `Store Path` empty or `n/a`, `Client Machine` set to the GCP Project ID, the `Location` custom property set to the region, and possibly the `Service Account Key File Path` custom property pointing at a JSON key in the orchestrator extension directory. These continue to work in v1.2 through fallback paths, but every inventory/management run logs deprecation warnings naming the store. To migrate, edit each affected store: 1. Set **Store Path** to `projects/{the-current-Client-Machine-value}/locations/{the-current-Location-value}`. 2. Optionally change **Client Machine** to the GCP Organization ID for cleaner UI grouping. 3. Optionally clear the **Location** field (no longer required). -4. Save. +4. Configure ADC on the orchestrator host (see "Service account credentials") and clear the **Service Account Key File Path** field. +5. Save. -The deprecation warning will stop on the next job run once Store Path is populated. The fallback will be removed in v2.0. +The deprecation warnings will stop on the next job run once the store is fully migrated. Both fallbacks will be removed in v2.0. #### Design rationale: why Store Path is the source of truth @@ -201,6 +191,7 @@ Under the v1.1 model that meant every Discovery-approved store ended up with the - **Client Machine is now a display label**, not load-bearing. Some other Keyfactor orchestrators use Client Machine as a literal target host; for GCP that does not fit, because the orchestrator talks to a single GCP API endpoint regardless of which project a store targets - there is no per-store host to put there. The recommended value (GCP Organization ID) at least groups GCP stores together usefully in Command's UI. - **The Location custom property is deprecated, not removed**. Keeping it in the manifest with `Required: false` preserves v1.1 stores' UI rendering during the transition. The fallback path in `JobBase.ResolveGcpResourcePath` reads it for v1.1-shaped stores (Store Path blank or `n/a`) and emits a `LogWarning` each time naming the migration step. Removal is scheduled for v2.0. +- **The Service Account Key File Path custom property is deprecated, not removed**, for the same backwards-compatibility reason. Authentication consolidates around Application Default Credentials, the GCP-recommended pattern, which works uniformly across all four job types - the deprecated property only ever worked for Inventory/Management because Discovery's UI doesn't expose store-type custom properties. Removal is scheduled for v2.0. #### Vendor docs @@ -292,7 +283,7 @@ the Keyfactor Command Portal | Name | Display Name | Description | Type | Default Value/Options | Required | | ---- | ------------ | ---- | --------------------- | -------- | ----------- | | Location | Location (deprecated) | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | String | | 🔲 Unchecked | - | ServiceAccountKey | Service Account Key File Path | File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). | String | | 🔲 Unchecked | + | ServiceAccountKey | Service Account Key File Path (deprecated) | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | String | | 🔲 Unchecked | The Custom Fields tab should look like this: @@ -307,8 +298,8 @@ the Keyfactor Command Portal - ###### Service Account Key File Path - File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). + ###### Service Account Key File Path (deprecated) + **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.png) ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.png) @@ -384,7 +375,7 @@ the Keyfactor Command Portal | Store Path | Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly. | | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | | Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | - | ServiceAccountKey | File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). | + | ServiceAccountKey | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | @@ -411,7 +402,7 @@ the Keyfactor Command Portal | Store Path | Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly. | | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | | Properties.Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | - | Properties.ServiceAccountKey | File name of the Google Cloud service account key (JSON) installed in the same folder as the orchestrator extension (e.g. `kf-orchestrator.json`). Leave blank to fall back to Application Default Credentials (typical when the orchestrator runs on a GCE VM / GKE pod with workload identity, or when `GOOGLE_APPLICATION_CREDENTIALS` is set as an environment variable on the orchestrator host). | + | Properties.ServiceAccountKey | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | 3. **Import the CSV file to create the certificate stores** @@ -428,6 +419,60 @@ the Keyfactor Command Portal +## GCP setup prerequisites + +Before configuring the orchestrator, make sure your Google Cloud project is ready. Read the official [Google Certificate Manager](https://cloud.google.com/certificate-manager/docs) documentation for product background. The steps below are intentionally text-only; Google's Cloud Console UI changes regularly and the underlying APIs and `gcloud` commands are the stable interface. + +### 1. Enable the required Google Cloud APIs + +In the project that will host the orchestrator's service account ("the SA project"), enable both: + +- **Cloud Resource Manager API** - lets the Discovery job enumerate projects via `projects.search`. Required even if you only use Inventory/Management today, because the API enablement check runs against the SA project regardless of what target project the call reads. +- **Certificate Manager API** - read/write access to certificate resources. This must additionally be enabled in **every project** you intend to inventory or manage certs in. + +`gcloud` (one-shot for both APIs in the SA project): + +``` +gcloud services enable cloudresourcemanager.googleapis.com certificatemanager.googleapis.com --project= +``` + +### 2. Create a service account and grant organization-level roles + +Service account credentials are *identity*, not authorization - the IAM bindings determine what the SA can see and do. Bind these roles **at the organization** so the SA inherits visibility into every folder and project: + +| Role | Why | +|---|---| +| `roles/browser` | So `projects.search` returns projects nested in folders, not just top-level projects | +| `roles/certificatemanager.viewer` | Inventory: list certificates in each store | +| `roles/certificatemanager.editor` | Management/Add and Management/Remove | + +``` +gcloud iam service-accounts create kf-orchestrator \ + --project= \ + --display-name="Keyfactor Universal Orchestrator" + +ORG= +SA=kf-orchestrator@.iam.gserviceaccount.com + +gcloud organizations add-iam-policy-binding $ORG --member="serviceAccount:$SA" --role="roles/browser" +gcloud organizations add-iam-policy-binding $ORG --member="serviceAccount:$SA" --role="roles/certificatemanager.viewer" +gcloud organizations add-iam-policy-binding $ORG --member="serviceAccount:$SA" --role="roles/certificatemanager.editor" +``` + +### 3. Provide credentials to the orchestrator host (Application Default Credentials) + +The orchestrator authenticates exclusively via [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) (ADC). There are two supported deployment modes: + +**Orchestrator runs inside GCP (recommended)** - on a GCE VM or GKE pod with the service account attached via workload identity. ADC discovers the service account from the metadata server automatically. No further configuration on the host. + +**Orchestrator runs outside GCP** - on a Windows host, on-prem Linux, etc.: + +1. Create a JSON key for the service account: `gcloud iam service-accounts keys create kf-orchestrator.json --iam-account=$SA`. Google never re-displays this key, so save it somewhere safe. +2. Copy the JSON key to a secured location on the orchestrator host. Lock down filesystem permissions so only the account that runs the Keyfactor Orchestrator service can read it. +3. Set the `GOOGLE_APPLICATION_CREDENTIALS` machine-level environment variable to the absolute path of the JSON key. Restart the Keyfactor Orchestrator service so it picks up the variable. + +> **Note on the deprecated `Service Account Key File Path` store property.** Earlier versions of the orchestrator accepted a JSON filename in a per-store custom property and read the file from the orchestrator extension directory. That mechanism is deprecated in v1.2 because the Discovery job has no way to surface custom store properties in Keyfactor Command's discovery-job UI - so file-based auth can't be configured uniformly across all four job types. Existing v1.1 stores with the property populated continue to work, but every job run logs a deprecation warning. The field is scheduled for removal in v2.0. + ## License diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 5e1100f..d46e1c2 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -107,7 +107,7 @@ create_store_type "GcpCertMgr" '{ }, { "Name": "ServiceAccountKey", - "DisplayName": "Service Account Key File Path", + "DisplayName": "Service Account Key File Path (deprecated)", "Type": "String", "DependsOn": "", "DefaultValue": "", diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index 6ab93db..e39714d 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -100,7 +100,7 @@ New-StoreType "GcpCertMgr" @' }, { "Name": "ServiceAccountKey", - "DisplayName": "Service Account Key File Path", + "DisplayName": "Service Account Key File Path (deprecated)", "Type": "String", "DependsOn": "", "DefaultValue": "", From b2c1aaac3eb51dacd25e82a8d729edecfb049999 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 12:38:39 -0400 Subject: [PATCH 13/31] docs: clarify Location semantics across the three places it appears GCP region names appear in three distinct places across the orchestrator: the {location} segment of Store Path (load-bearing for new stores), the deprecated Location custom property (v1.1 fallback only), and Discovery's "Directories to search" form field (operator input to Discovery, does not propagate to resulting stores). The relationships were not obvious from the field semantics table - this adds a "Location semantics: where the GCP region lives" subsection that walks through each place, what reads it, and how they relate across the discovery and creation flows. Includes a quick-reference that maps common operator questions to the right field. Co-Authored-By: Claude Opus 4.7 (1M context) --- docsource/gcpcertmgr.md | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index a297ec0..417693b 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -16,11 +16,47 @@ That single value carries both the GCP project and the location (region or `glob | Field | What it carries | Read by | |---|---|---| -| **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}` | Inventory, Management, Discovery (emit) | +| **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}`. The `{location}` segment is the GCP region (or `global`) the store targets - this is the only place the orchestrator actually reads the location from for new stores. | Inventory, Management, Discovery (emit) | | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | | **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | +#### Location semantics: where the GCP region lives + +GCP region names (`global`, `us-central1`, `europe-west1`, ...) appear in three distinct places across the orchestrator. They look related but they are **not interchangeable**, and only one of them is load-bearing for new stores. Operators who only skim the field semantics table often miss this and end up confused about which Location field to set where. + +| # | Where it appears | What it is | What reads it | +|---|---|---|---| +| 1 | **The `{location}` segment of `Store Path`** (e.g. the `global` in `projects/edgecerts/locations/global`) | The actual GCP region the store targets. Source of truth. | Inventory and Management both call `JobBase.ResolveGcpResourcePath` which returns Store Path verbatim when it matches the canonical form. The location segment is parsed back out (string split) when the Inventory job needs to populate the `Location` parameter on each returned certificate. | +| 2 | **The `Location` custom store property** | A v1.1-shape field. New stores leave it blank. | Only the v1.1 fallback path inside `JobBase.ResolveGcpResourcePath` - it builds `projects/{ClientMachine}/locations/{Location}` when Store Path is blank or `n/a`, and emits a `LogWarning` each time naming the migration step. Removal scheduled for v2.0. | +| 3 | **The Discovery job's "Directories to search" form field** | Operator INPUT to the discovery job. A comma-separated list of regions to enumerate, e.g. `global,us-central1,europe-west1`. | `Discovery.ResolveLocations` parses the list. Discovery then emits one candidate store path per `(project × location)` combination, where the location segment of each emitted Store Path comes from this list. The list itself does **not** propagate to the resulting stores - it has no afterlife once Discovery has emitted candidates. | + +##### How the three relate + +For a **v1.2 store created via Discovery**: + +1. Operator types `global,us-central1` into the discovery job's "Directories to search" - this is place #3. +2. Discovery emits `projects/edgecerts/locations/global` and `projects/edgecerts/locations/us-central1`. Each emitted string is consumed as place #1 (Store Path) when the candidate is approved. +3. The auto-created store has Store Path populated; the **Location custom property is blank** and unused. Place #2 has no role. +4. Inventory reads Store Path (place #1) for the GCP API path, and parses the location segment back out for the `Location` parameter on each cert item. +5. Discovery's "Directories to search" value (place #3) is gone - it never landed on the store. + +For a **v1.2 store created manually**: + +- Type the full canonical path into Store Path (place #1) - e.g. `projects/edgecerts/locations/global`. +- Leave the Location custom property (place #2) blank. + +For a **v1.1 store mid-migration**: + +- Store Path is blank or `n/a`; the orchestrator falls back to building the GCP path from `Client Machine` + `Location` (place #2). A deprecation warning is logged every job run. Migrate by populating Store Path (place #1) and clearing place #2. + +##### Quick reference + +- "Where do I tell the orchestrator which GCP region to use for *this store*?" → **the `{location}` segment of Store Path** (place #1). +- "Where do I tell Discovery which regions to enumerate across *the whole org*?" → **Directories to search** on the discovery job (place #3). +- "What's the Location custom field for?" → Nothing, unless you're maintaining a v1.1 store and haven't migrated yet (place #2). +- "Why does the inventory result still have a `Location` parameter on each certificate?" → That's parsed out of Store Path's location segment for downstream filtering in Command. It mirrors place #1, not place #2. + #### Manually creating a store Set: From 5e6276a64c3833cff4c9ed02f23c3942daa07e0e Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 12:40:38 -0400 Subject: [PATCH 14/31] fix: stop claiming Create is supported in SupportedOperations The store type's SupportedOperations advertised Create: true since v1.0, but Management.cs has only ever switched on Add and Remove - a Create job from Keyfactor Command's UI fell through to the default branch and returned 'Invalid Management Operation'. There is no meaningful semantic for Create on a GCP Certificate Manager store anyway: the (project, location) container is implicit in the GCP project, so there is nothing the orchestrator can usefully provision. Flip the manifest claim to match the code rather than implement a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++++++++++ integration-manifest.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7472627..0e16d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,18 @@ v1.2.0 - unreleased `GcpCertificateManagerClient.LoadCredentials`. Removal is scheduled for v2.0. +### Fixed +- `integration-manifest.json` previously advertised `Create: true` under + `SupportedOperations`, but `Management.cs` has only ever handled `Add` and + `Remove` - a Create job from Keyfactor Command's UI fell through to the + default case and returned `Invalid Management Operation`. The manifest now + correctly reports `Create: false`. There is no meaningful semantic for + Create on a GCP Certificate Manager store anyway: the (project, location) + container is implicit in the GCP project, so there is no per-store + "creation" the orchestrator can usefully perform. Operators who relied on + scheduling Create jobs (and getting failures) should remove those job + definitions; everyone else is unaffected. + ### Removed (docs) - Removed three Google Cloud Console screenshot GIFs from `docsource/` that documented the service-account creation, API enablement, and JSON-key diff --git a/integration-manifest.json b/integration-manifest.json index b19f008..a24be59 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -28,7 +28,7 @@ "StorePathValue": "", "SupportedOperations": { "Add": true, - "Create": true, + "Create": false, "Discovery": true, "Enrollment": false, "Remove": true From d80bdafbb1caf54f75adfb0af66fde4655bf4f3e Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 16:42:33 +0000 Subject: [PATCH 15/31] Update generated docs --- README.md | 42 +++++++++++++++++-- .../bash/curl_create_store_types.sh | 2 +- .../restmethod_create_store_types.ps1 | 2 +- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3b79a56..344d8fa 100644 --- a/README.md +++ b/README.md @@ -86,11 +86,47 @@ That single value carries both the GCP project and the location (region or `glob | Field | What it carries | Read by | |---|---|---| -| **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}` | Inventory, Management, Discovery (emit) | +| **Store Path** | Canonical GCP resource path: `projects/{projectId}/locations/{location}`. The `{location}` segment is the GCP region (or `global`) the store targets - this is the only place the orchestrator actually reads the location from for new stores. | Inventory, Management, Discovery (emit) | | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | | **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | +##### Location semantics: where the GCP region lives + +GCP region names (`global`, `us-central1`, `europe-west1`, ...) appear in three distinct places across the orchestrator. They look related but they are **not interchangeable**, and only one of them is load-bearing for new stores. Operators who only skim the field semantics table often miss this and end up confused about which Location field to set where. + +| # | Where it appears | What it is | What reads it | +|---|---|---|---| +| 1 | **The `{location}` segment of `Store Path`** (e.g. the `global` in `projects/edgecerts/locations/global`) | The actual GCP region the store targets. Source of truth. | Inventory and Management both call `JobBase.ResolveGcpResourcePath` which returns Store Path verbatim when it matches the canonical form. The location segment is parsed back out (string split) when the Inventory job needs to populate the `Location` parameter on each returned certificate. | +| 2 | **The `Location` custom store property** | A v1.1-shape field. New stores leave it blank. | Only the v1.1 fallback path inside `JobBase.ResolveGcpResourcePath` - it builds `projects/{ClientMachine}/locations/{Location}` when Store Path is blank or `n/a`, and emits a `LogWarning` each time naming the migration step. Removal scheduled for v2.0. | +| 3 | **The Discovery job's "Directories to search" form field** | Operator INPUT to the discovery job. A comma-separated list of regions to enumerate, e.g. `global,us-central1,europe-west1`. | `Discovery.ResolveLocations` parses the list. Discovery then emits one candidate store path per `(project × location)` combination, where the location segment of each emitted Store Path comes from this list. The list itself does **not** propagate to the resulting stores - it has no afterlife once Discovery has emitted candidates. | + +###### How the three relate + +For a **v1.2 store created via Discovery**: + +1. Operator types `global,us-central1` into the discovery job's "Directories to search" - this is place #3. +2. Discovery emits `projects/edgecerts/locations/global` and `projects/edgecerts/locations/us-central1`. Each emitted string is consumed as place #1 (Store Path) when the candidate is approved. +3. The auto-created store has Store Path populated; the **Location custom property is blank** and unused. Place #2 has no role. +4. Inventory reads Store Path (place #1) for the GCP API path, and parses the location segment back out for the `Location` parameter on each cert item. +5. Discovery's "Directories to search" value (place #3) is gone - it never landed on the store. + +For a **v1.2 store created manually**: + +- Type the full canonical path into Store Path (place #1) - e.g. `projects/edgecerts/locations/global`. +- Leave the Location custom property (place #2) blank. + +For a **v1.1 store mid-migration**: + +- Store Path is blank or `n/a`; the orchestrator falls back to building the GCP path from `Client Machine` + `Location` (place #2). A deprecation warning is logged every job run. Migrate by populating Store Path (place #1) and clearing place #2. + +###### Quick reference + +- "Where do I tell the orchestrator which GCP region to use for *this store*?" → **the `{location}` segment of Store Path** (place #1). +- "Where do I tell Discovery which regions to enumerate across *the whole org*?" → **Directories to search** on the discovery job (place #3). +- "What's the Location custom field for?" → Nothing, unless you're maintaining a v1.1 store and haven't migrated yet (place #2). +- "Why does the inventory result still have a `Location` parameter on each certificate?" → That's parsed out of Store Path's location segment for downstream filtering in Command. It mirrors place #1, not place #2. + ##### Manually creating a store Set: @@ -210,7 +246,7 @@ Under the v1.1 model that meant every Discovery-approved store ended up with the | Remove | ✅ Checked | | Discovery | ✅ Checked | | Reenrollment | 🔲 Unchecked | -| Create | ✅ Checked | +| Create | 🔲 Unchecked | #### Store Type Creation @@ -253,7 +289,7 @@ the Keyfactor Command Portal | Supports Remove | ✅ Checked | Check the box. Indicates that the Store Type supports Management Remove | | Supports Discovery | ✅ Checked | Check the box. Indicates that the Store Type supports Discovery | | Supports Reenrollment | 🔲 Unchecked | Indicates that the Store Type supports Reenrollment | - | Supports Create | ✅ Checked | Check the box. Indicates that the Store Type supports store creation | + | Supports Create | 🔲 Unchecked | Indicates that the Store Type supports store creation | | Needs Server | 🔲 Unchecked | Determines if a target server name is required when creating store | | Blueprint Allowed | 🔲 Unchecked | Determines if store type may be included in an Orchestrator blueprint | | Uses PowerShell | 🔲 Unchecked | Determines if underlying implementation is PowerShell | diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index d46e1c2..1e6ceb8 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -85,7 +85,7 @@ create_store_type "GcpCertMgr" '{ "StorePathValue": "", "SupportedOperations": { "Add": true, - "Create": true, + "Create": false, "Discovery": true, "Enrollment": false, "Remove": true diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index e39714d..fac43f4 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -78,7 +78,7 @@ New-StoreType "GcpCertMgr" @' "StorePathValue": "", "SupportedOperations": { "Add": true, - "Create": true, + "Create": false, "Discovery": true, "Enrollment": false, "Remove": true From 84115f7a007737a939f3841c3cf741e505edef5b Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 12:45:49 -0400 Subject: [PATCH 16/31] docs: correct Directories to search guidance - field is required Keyfactor Command's Discovery UI requires "Directories to search" to be non-empty, so prior advice to "leave blank to default to global" doesn't actually work for operators - the form rejects submission. The recommended value is to type 'global' explicitly. The orchestrator still defaults to 'global' defensively if it ever receives a blank value via a non-UI submission path, but that should be treated as a safety net rather than the documented flow. Also expanded the surrounding guidance to explain why the label says "Directories" (it's a Command UI convention inherited from filesystem store types), why 'global' is the right answer for ~95 percent of deployments, and the rare cases where adding regional locations makes sense (regional external Application Load Balancers, data-residency constraints). Server Username / Server Password guidance updated similarly: although the orchestrator does not use them, the form may require non-empty values, so the recommendation is to type any placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 5 ++++- docsource/gcpcertmgr.md | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e16d56..a67a3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,10 @@ v1.2.0 - unreleased logging only; the actual project set is bounded by the service account's IAM bindings (the customer scopes that at the org root). - "Directories to search" is repurposed as a comma-separated list of GCP - locations (regions); defaults to `global` when blank. + locations (regions). The Keyfactor Command UI requires this field be + non-empty; the recommended value is just `global`. The orchestrator also + falls back to `global` defensively if it ever receives a blank value via + a non-UI submission path. - Service account credentials use Application Default Credentials, matching the recommended deployment on a GCE VM / GKE pod with workload identity, or the `GOOGLE_APPLICATION_CREDENTIALS` environment variable on the diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index 417693b..773f921 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -79,10 +79,22 @@ Discovery is configured against the GCP Certificate Manager store type and enume | Field on the discovery-job form | What to put | |---|---| | **Client Machine** | The GCP Organization ID (e.g. `1005564431893`). Logged for traceability; not used as a query filter. | -| **Server Username / Server Password** | Not used. Leave blank - GCP authentication uses a service account, not username/password. | -| **Directories to search** | Comma-separated list of GCP locations (regions) to enumerate, e.g. `global,us-central1,europe-west1`. Leave blank to default to `global`. | +| **Server Username / Server Password** | Not used, but the form may require non-empty values. Type any placeholder (e.g. `unused` / `unused`). The orchestrator never reads them - GCP authentication uses Application Default Credentials, not username/password. | +| **Directories to search** | Required by the Command UI. **Type `global`** for the default behavior of searching only GCP's global Certificate Manager location, which is what almost every operator wants. See "Should I ever put something other than `global`?" below for the rare exceptions. | -The candidate count is `projects × locations`, so be deliberate about how many regions you list - listing 8 regions for an org with 100 projects yields 800 candidate stores, most of which will be empty. +> **Why is the field labeled "Directories to search" if it accepts GCP regions?** Keyfactor Command's standard Discovery UI was designed for filesystem-based store types (Java keystores, PEM files in directories) where a comma-separated directory list is the natural input. The field label is hard-coded by Command and not something the orchestrator can change. For our purposes, treat it as "GCP locations to enumerate" - the orchestrator parses the value as a list of GCP region names. + +#### Should I ever put something other than `global`? + +Almost never. Concrete guidance: + +- **Just type `global` → searches the `global` GCP location only.** This is the right answer for the vast majority of GCP Certificate Manager deployments, because certificates attached to GCP's *global* external Application Load Balancer (the most common load balancer in GCP) are stored in the `global` Certificate Manager location. +- **Add specific regions** (e.g. `global,us-central1,europe-west1`) only if your organization runs **regional** external Application Load Balancers, or has data-residency requirements that pin certificates to specific regions. If you're not sure whether that describes your environment, the answer is "you don't need this" and you should just type `global`. +- **Don't list every GCP region** (`us-central1,us-east1,...`). Discovery does not probe candidates - it emits one (project × location) pair regardless of whether that combination has any certs. Listing 40 regions for a 100-project org produces 4,000 candidate stores, most empty, all cluttering Command's certificate store list. + +The format is a comma-separated list of GCP location names exactly as GCP names them. `global` is the universal location; regional names follow GCP's standard `-` form (e.g. `us-central1`, `europe-west1`, `asia-southeast1`). See the [Certificate Manager supported locations list](https://cloud.google.com/certificate-manager/docs/locations) for the canonical set. + +The candidate count is always `projects × locations`, so each region you add multiplies the size of the discovery result by the number of accessible projects. #### Service account credentials From 755f34653b9e8945a473b2e55196baffb4779498 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Tue, 5 May 2026 17:56:43 -0400 Subject: [PATCH 17/31] docs: correct false claims about the Schedule Discovery form fields Empirical observation of the Schedule Discovery dialog in Keyfactor Command shows the form does NOT include Client Machine, Server Username, Server Password, or a Create Certificate Store If Missing checkbox - those were claims I made without verifying. The form has the standard Command Discovery layout (Category, Orchestrator, Schedule, Directories to search, Directories to ignore, Extensions, File name patterns to match, Follow SymLinks, Include PKCS12 Files) and most fields don't apply to GCP at all. Corrections in this commit: - Replace the discovery-job form table with the actual fields visible on the Schedule Discovery dialog, marking each filesystem-store field as not-applicable-to-GCP. - Move Create Certificate Store If Missing to its actual location - the per-candidate edit dialog when MANAGEing a discovered store - and document what to do with the other fields on that approval dialog (Store Path comes pre-filled from Discovery; deprecated custom properties get left blank). - CHANGELOG: clarify that the discovery-job ClientMachine value is auto-populated by Command (typically the orchestrator hostname) rather than something the operator types into the form. The orchestrator still logs it for traceability but it isn't load-bearing. - Design rationale section: drop the misattribution of "auto-approval enabled via Create Certificate Store If Missing checkbox" - that checkbox is on the per-candidate approval dialog, not the discovery job. Keep the underlying observation that Client Machine is shared across all candidates and not per-candidate-settable, which is the actual constraint that drove the StorePath-as-canonical-source decision. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 17 +++++++++++------ docsource/gcpcertmgr.md | 40 ++++++++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67a3e7..44d5d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ v1.2.0 - unreleased GCP projects accessible to the orchestrator's service account and emits one candidate store path per (project, location) pair in canonical `projects/{projectId}/locations/{location}` form. - - Discovery-job ClientMachine is interpreted as the GCP Organization ID for - logging only; the actual project set is bounded by the service account's - IAM bindings (the customer scopes that at the org root). + - The Schedule Discovery form in Keyfactor Command does not expose a + Client Machine field for GCP - Command auto-populates it (typically the + orchestrator hostname) and the orchestrator logs it for traceability + only. The actual project set is bounded by the service account's IAM + bindings, not by anything the operator types into the discovery form. - "Directories to search" is repurposed as a comma-separated list of GCP locations (regions). The Keyfactor Command UI requires this field be non-empty; the recommended value is just `global`. The orchestrator also @@ -32,9 +34,12 @@ v1.2.0 - unreleased enumeration that Discovery requires. ### Known limitations -- The discovery-job ClientMachine field (Organization ID) is informational; if - the service account has visibility into multiple organizations, Discovery - will emit projects from all of them. Constrain at IAM if that's not desired. +- The discovery-job ClientMachine value is auto-populated by Keyfactor Command + (typically the orchestrator hostname) and is not operator-configurable from + the Schedule Discovery dialog for GCP. It is logged for traceability but + never used by the orchestrator for filtering. If the service account has + visibility into multiple GCP organizations, Discovery emits projects from + all of them - constrain at IAM if that is not desired. - Discovery does not probe each (project, location) candidate to confirm the Certificate Manager API is enabled or that any certificates exist. Operators can leave `Create Certificate Store If Missing` checked to auto-approve every diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index 773f921..0952c4d 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -70,19 +70,39 @@ Authentication uses Application Default Credentials - see "Service account crede #### Approving a Discovery-discovered store -Discovery emits one candidate per (project, location) pair in canonical form, so no edits are required on approval - just click SAVE. If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. +After the discovery job runs, candidates appear in **Locations → Certificate Stores → Discover** (a new tab next to "Certificate Stores"). Tick the candidates you want to track, click **MANAGE**, and Command opens the per-candidate edit dialog. The relevant fields: + +| Field | Action | +|---|---| +| **Client Machine** | Pre-filled by Command (often the orchestrator hostname). Display label only - the orchestrator does not parse it. Leave as-is, or change to the GCP Organization ID for cleaner UI grouping. | +| **Store Path** | Pre-filled with the canonical GCP path Discovery emitted (e.g. `projects/edgecerts/locations/global`). **Don't edit** - this is what Inventory and Management read. | +| **Application** | Optional, free-form. | +| **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. | +| **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. | +| **Create Certificate Store If Missing** | **Check this.** Tells Command to create a new certificate store record from this candidate. Without it, the candidate sits in the discover tab with no store backing it. | +| **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. | + +Click **SAVE** and the store is created. The next inventory run on its schedule will populate it with whatever certificates exist in that (project, location). ### Discovery job configuration Discovery is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination. -| Field on the discovery-job form | What to put | -|---|---| -| **Client Machine** | The GCP Organization ID (e.g. `1005564431893`). Logged for traceability; not used as a query filter. | -| **Server Username / Server Password** | Not used, but the form may require non-empty values. Type any placeholder (e.g. `unused` / `unused`). The orchestrator never reads them - GCP authentication uses Application Default Credentials, not username/password. | -| **Directories to search** | Required by the Command UI. **Type `global`** for the default behavior of searching only GCP's global Certificate Manager location, which is what almost every operator wants. See "Should I ever put something other than `global`?" below for the rare exceptions. | +The "Schedule Discovery" dialog inherits its layout from Keyfactor Command's generic Discovery UI, which was designed for filesystem-based store types (Java keystores, PEM files). Most fields don't apply to GCP. Here is what each field is and what to do with it: -> **Why is the field labeled "Directories to search" if it accepts GCP regions?** Keyfactor Command's standard Discovery UI was designed for filesystem-based store types (Java keystores, PEM files in directories) where a comma-separated directory list is the natural input. The field label is hard-coded by Command and not something the orchestrator can change. For our purposes, treat it as "GCP locations to enumerate" - the orchestrator parses the value as a list of GCP region names. +| Field on Schedule Discovery | What to put | +|---|---| +| **Category** | `GCP Certificate Manager` - already populated when reaching this dialog from the GCP store type. | +| **Orchestrator** | Select an approved orchestrator with the `GcpCertMgr` capability. | +| **Schedule** | When discovery should run. `Immediate` runs once on save; pick a recurring schedule for periodic re-enumeration. | +| **Directories to search** | **Required.** Type `global` for the default behavior of searching only GCP's global Certificate Manager location, which is what almost every operator wants. See "Should I ever put something other than `global`?" below for the rare exceptions. | +| **Directories to ignore** | Leave blank. Filesystem-store concept; not used by GCP discovery. | +| **Extensions** | Leave blank. Filesystem-store concept; not used. | +| **File name patterns to match** | Leave blank. Filesystem-store concept; not used. | +| **Follow SymLinks** | Leave unchecked. Filesystem-store concept; not used. | +| **Include PKCS12 Files?** | Leave unchecked. Filesystem-store concept; not used. | + +> **Why are most fields irrelevant to GCP?** Command's Discovery UI is one form template shared across every store type. For filesystem-based store types like Java keystores or PEM files, fields like *Directories to ignore*, *Extensions*, *File name patterns to match*, *Follow SymLinks*, and *Include PKCS12 Files?* are useful - they let the orchestrator narrow which files on disk it should treat as candidate stores. GCP Certificate Manager isn't a filesystem; the orchestrator uses Cloud Resource Manager + Certificate Manager APIs, so these fields are not consulted. The orchestrator does not raise an error if you fill them in; it just ignores them. #### Should I ever put something other than `global`? @@ -148,10 +168,10 @@ The deprecation warnings will stop on the next job run once the store is fully m In v1.1 the orchestrator built the GCP resource path from **Client Machine** (= GCP Project ID) + the **Location** custom property, with **Store Path** unused (defaulted to `n/a`). Adding Discovery in v1.2 forced this model to change. Here's why. -The Keyfactor `IDiscoveryJobExtension` contract emits a plain `List` of discovered locations - there is no hook to set per-candidate Client Machine values. When an operator approves a discovered candidate (or auto-approval is enabled via the `Create Certificate Store If Missing` checkbox), Keyfactor Command creates the new store with: +The Keyfactor `IDiscoveryJobExtension` contract emits a plain `List` of discovered locations - there is no hook to set per-candidate Client Machine values. When an operator approves a discovered candidate (in the per-candidate edit dialog with `Create Certificate Store If Missing` checked), Keyfactor Command creates the new store with: - Store Path = the discovered location string (e.g. `projects/edgecerts/locations/global`) -- Client Machine = whatever the discovery job's Client Machine was set to - one value shared across every candidate +- Client Machine = whatever Command auto-populated on the discovery job (typically the orchestrator hostname) - one value shared across every candidate, *not* something the operator can set per-candidate - Custom properties = their store-type defaults Under the v1.1 model that meant every Discovery-approved store ended up with the *same* Client Machine across every project in the organization, which is wrong: each store needs its project ID to make GCP API calls. The first time inventory ran against a Discovery-approved store, that's exactly what produced an `HTTP 403 CONSUMER_INVALID` error against `projects//locations/global` - GCP correctly saying "that's not a valid project ID." @@ -160,7 +180,7 @@ Under the v1.1 model that meant every Discovery-approved store ended up with the | Option | Why we didn't pick it | |---|---| -| Force the operator to manually edit Client Machine after every Discovery approval | Friction. Discovery should produce working stores without an extra editing step per candidate, especially if the operator wants to use auto-approval. | +| Force the operator to manually edit Client Machine after every Discovery approval | Friction. Discovery should produce working stores without an extra editing step per candidate. | | One discovery job per project (so each job's Client Machine = that project's ID) | Impractical: an organization with 100 projects would need 100 discovery jobs, each independently configured and scheduled. | | Have Discovery POST stores directly via Keyfactor Command's REST API instead of the standard `SubmitDiscoveryUpdate` callback | Non-standard pattern, much larger code surface, and diverges from how every other Keyfactor orchestrator works - making this orchestrator harder to maintain alongside the rest of the Keyfactor extension catalog. | | **Make Store Path the canonical source for both manual and Discovery flows** | Picked. The discovered storepath already encodes both project and location, so reading it directly (instead of reconstructing from Client Machine + Location) means Discovery-approved stores work with zero operator edits, and manually-created stores configure the same way. Smallest code change for the cleanest user-facing schema. | From ef3df6421f3885cec44a0047fc020b1fcfbaf92f Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 5 May 2026 21:58:04 +0000 Subject: [PATCH 18/31] Update generated docs --- README.md | 50 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 344d8fa..c410fa1 100644 --- a/README.md +++ b/README.md @@ -140,19 +140,51 @@ Authentication uses Application Default Credentials - see "Service account crede ##### Approving a Discovery-discovered store -Discovery emits one candidate per (project, location) pair in canonical form, so no edits are required on approval - just click SAVE. If `Create Certificate Store If Missing` is checked on the discovery job, every candidate auto-approves with no operator review. Discovery sets Store Path correctly on each, so all auto-created stores are immediately usable. +After the discovery job runs, candidates appear in **Locations → Certificate Stores → Discover** (a new tab next to "Certificate Stores"). Tick the candidates you want to track, click **MANAGE**, and Command opens the per-candidate edit dialog. The relevant fields: + +| Field | Action | +|---|---| +| **Client Machine** | Pre-filled by Command (often the orchestrator hostname). Display label only - the orchestrator does not parse it. Leave as-is, or change to the GCP Organization ID for cleaner UI grouping. | +| **Store Path** | Pre-filled with the canonical GCP path Discovery emitted (e.g. `projects/edgecerts/locations/global`). **Don't edit** - this is what Inventory and Management read. | +| **Application** | Optional, free-form. | +| **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. | +| **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. | +| **Create Certificate Store If Missing** | **Check this.** Tells Command to create a new certificate store record from this candidate. Without it, the candidate sits in the discover tab with no store backing it. | +| **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. | + +Click **SAVE** and the store is created. The next inventory run on its schedule will populate it with whatever certificates exist in that (project, location). #### Discovery job configuration Discovery is configured against the GCP Certificate Manager store type and enumerates candidate stores across an entire GCP organization. It uses the Cloud Resource Manager v3 API (`projects.search`) to list every active project the orchestrator's service account can see, then emits one candidate store path per (project, location) combination. -| Field on the discovery-job form | What to put | +The "Schedule Discovery" dialog inherits its layout from Keyfactor Command's generic Discovery UI, which was designed for filesystem-based store types (Java keystores, PEM files). Most fields don't apply to GCP. Here is what each field is and what to do with it: + +| Field on Schedule Discovery | What to put | |---|---| -| **Client Machine** | The GCP Organization ID (e.g. `1005564431893`). Logged for traceability; not used as a query filter. | -| **Server Username / Server Password** | Not used. Leave blank - GCP authentication uses a service account, not username/password. | -| **Directories to search** | Comma-separated list of GCP locations (regions) to enumerate, e.g. `global,us-central1,europe-west1`. Leave blank to default to `global`. | +| **Category** | `GCP Certificate Manager` - already populated when reaching this dialog from the GCP store type. | +| **Orchestrator** | Select an approved orchestrator with the `GcpCertMgr` capability. | +| **Schedule** | When discovery should run. `Immediate` runs once on save; pick a recurring schedule for periodic re-enumeration. | +| **Directories to search** | **Required.** Type `global` for the default behavior of searching only GCP's global Certificate Manager location, which is what almost every operator wants. See "Should I ever put something other than `global`?" below for the rare exceptions. | +| **Directories to ignore** | Leave blank. Filesystem-store concept; not used by GCP discovery. | +| **Extensions** | Leave blank. Filesystem-store concept; not used. | +| **File name patterns to match** | Leave blank. Filesystem-store concept; not used. | +| **Follow SymLinks** | Leave unchecked. Filesystem-store concept; not used. | +| **Include PKCS12 Files?** | Leave unchecked. Filesystem-store concept; not used. | + +> **Why are most fields irrelevant to GCP?** Command's Discovery UI is one form template shared across every store type. For filesystem-based store types like Java keystores or PEM files, fields like *Directories to ignore*, *Extensions*, *File name patterns to match*, *Follow SymLinks*, and *Include PKCS12 Files?* are useful - they let the orchestrator narrow which files on disk it should treat as candidate stores. GCP Certificate Manager isn't a filesystem; the orchestrator uses Cloud Resource Manager + Certificate Manager APIs, so these fields are not consulted. The orchestrator does not raise an error if you fill them in; it just ignores them. + +##### Should I ever put something other than `global`? + +Almost never. Concrete guidance: + +- **Just type `global` → searches the `global` GCP location only.** This is the right answer for the vast majority of GCP Certificate Manager deployments, because certificates attached to GCP's *global* external Application Load Balancer (the most common load balancer in GCP) are stored in the `global` Certificate Manager location. +- **Add specific regions** (e.g. `global,us-central1,europe-west1`) only if your organization runs **regional** external Application Load Balancers, or has data-residency requirements that pin certificates to specific regions. If you're not sure whether that describes your environment, the answer is "you don't need this" and you should just type `global`. +- **Don't list every GCP region** (`us-central1,us-east1,...`). Discovery does not probe candidates - it emits one (project × location) pair regardless of whether that combination has any certs. Listing 40 regions for a 100-project org produces 4,000 candidate stores, most empty, all cluttering Command's certificate store list. + +The format is a comma-separated list of GCP location names exactly as GCP names them. `global` is the universal location; regional names follow GCP's standard `-` form (e.g. `us-central1`, `europe-west1`, `asia-southeast1`). See the [Certificate Manager supported locations list](https://cloud.google.com/certificate-manager/docs/locations) for the canonical set. -The candidate count is `projects × locations`, so be deliberate about how many regions you list - listing 8 regions for an org with 100 projects yields 800 candidate stores, most of which will be empty. +The candidate count is always `projects × locations`, so each region you add multiplies the size of the discovery result by the number of accessible projects. ##### Service account credentials @@ -206,10 +238,10 @@ The deprecation warnings will stop on the next job run once the store is fully m In v1.1 the orchestrator built the GCP resource path from **Client Machine** (= GCP Project ID) + the **Location** custom property, with **Store Path** unused (defaulted to `n/a`). Adding Discovery in v1.2 forced this model to change. Here's why. -The Keyfactor `IDiscoveryJobExtension` contract emits a plain `List` of discovered locations - there is no hook to set per-candidate Client Machine values. When an operator approves a discovered candidate (or auto-approval is enabled via the `Create Certificate Store If Missing` checkbox), Keyfactor Command creates the new store with: +The Keyfactor `IDiscoveryJobExtension` contract emits a plain `List` of discovered locations - there is no hook to set per-candidate Client Machine values. When an operator approves a discovered candidate (in the per-candidate edit dialog with `Create Certificate Store If Missing` checked), Keyfactor Command creates the new store with: - Store Path = the discovered location string (e.g. `projects/edgecerts/locations/global`) -- Client Machine = whatever the discovery job's Client Machine was set to - one value shared across every candidate +- Client Machine = whatever Command auto-populated on the discovery job (typically the orchestrator hostname) - one value shared across every candidate, *not* something the operator can set per-candidate - Custom properties = their store-type defaults Under the v1.1 model that meant every Discovery-approved store ended up with the *same* Client Machine across every project in the organization, which is wrong: each store needs its project ID to make GCP API calls. The first time inventory ran against a Discovery-approved store, that's exactly what produced an `HTTP 403 CONSUMER_INVALID` error against `projects//locations/global` - GCP correctly saying "that's not a valid project ID." @@ -218,7 +250,7 @@ Under the v1.1 model that meant every Discovery-approved store ended up with the | Option | Why we didn't pick it | |---|---| -| Force the operator to manually edit Client Machine after every Discovery approval | Friction. Discovery should produce working stores without an extra editing step per candidate, especially if the operator wants to use auto-approval. | +| Force the operator to manually edit Client Machine after every Discovery approval | Friction. Discovery should produce working stores without an extra editing step per candidate. | | One discovery job per project (so each job's Client Machine = that project's ID) | Impractical: an organization with 100 projects would need 100 discovery jobs, each independently configured and scheduled. | | Have Discovery POST stores directly via Keyfactor Command's REST API instead of the standard `SubmitDiscoveryUpdate` callback | Non-standard pattern, much larger code surface, and diverges from how every other Keyfactor orchestrator works - making this orchestrator harder to maintain alongside the rest of the Keyfactor extension catalog. | | **Make Store Path the canonical source for both manual and Discovery flows** | Picked. The discovered storepath already encodes both project and location, so reading it directly (instead of reconstructing from Client Machine + Location) means Discovery-approved stores work with zero operator edits, and manually-created stores configure the same way. Smallest code change for the cleanest user-facing schema. | From da8d8a6688625b3fdc779a93ba29490a8e731808 Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Thu, 7 May 2026 11:12:45 -0400 Subject: [PATCH 19/31] Update keyfactor-starter-workflow.yml --- .github/workflows/keyfactor-starter-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml index a4649f2..c571ebe 100644 --- a/.github/workflows/keyfactor-starter-workflow.yml +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -11,7 +11,7 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@3.1.2 + uses: keyfactor/actions/.github/workflows/starter.yml@5 secrets: token: ${{ secrets.V2BUILDTOKEN}} APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} From 2a8470f5ded99c637cf843c558dad5d6b7397a2a Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Thu, 7 May 2026 11:18:27 -0400 Subject: [PATCH 20/31] Update keyfactor-starter-workflow.yml --- .github/workflows/keyfactor-starter-workflow.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml index c571ebe..0f3d3ae 100644 --- a/.github/workflows/keyfactor-starter-workflow.yml +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -11,10 +11,17 @@ on: jobs: call-starter-workflow: - uses: keyfactor/actions/.github/workflows/starter.yml@5 + uses: keyfactor/actions/.github/workflows/starter.yml@v5 + with: + command_token_url: ${{ vars.COMMAND_TOKEN_URL }} + command_hostname: ${{ vars.COMMAND_HOSTNAME }} + command_base_api_path: ${{ vars.COMMAND_API_PATH }} secrets: token: ${{ secrets.V2BUILDTOKEN}} - APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} scan_token: ${{ secrets.SAST_TOKEN }} + entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} + entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} + command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} + command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} From 0389f91590b54f5e3be0e225bbd03fb6ec324ccf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 15:19:10 +0000 Subject: [PATCH 21/31] docs: auto-generate README and documentation [skip ci] --- README.md | 93 +++++++----------- .../GcpCertMgr-advanced-store-type-dialog.svg | 67 +++++++++++++ .../GcpCertMgr-basic-store-type-dialog.svg | 83 ++++++++++++++++ ...ertMgr-custom-fields-store-type-dialog.svg | 62 ++++++++++++ .../bash/curl_create_store_types.sh | 95 ++++--------------- .../bash/kfutil_create_store_types.sh | 31 ++---- .../powershell/kfutil_create_store_types.ps1 | 31 +----- .../restmethod_create_store_types.ps1 | 84 ++++------------ 8 files changed, 290 insertions(+), 256 deletions(-) create mode 100644 docsource/images/GcpCertMgr-advanced-store-type-dialog.svg create mode 100644 docsource/images/GcpCertMgr-basic-store-type-dialog.svg create mode 100644 docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg diff --git a/README.md b/README.md index c410fa1..6b75139 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@

Integration Status: production -Release -Issues -GitHub Downloads (all assets, all releases) +Release +Issues +GitHub Downloads (all assets, all releases)

@@ -47,13 +47,12 @@ This applies equally to manually-created stores and Discovery-approved stores. T The Discovery job enumerates every GCP project that the orchestrator's service account can see and proposes one candidate store per (project, location) pair, with Store Path pre-populated in canonical form. The actual scope of discovery is bounded by IAM - grant the service account the appropriate role at the organization root and Discovery will return everything underneath. See the GCP Certificate Manager store-type page for full operator-facing details. - - ## Compatibility This integration is compatible with Keyfactor Universal Orchestrator version 10.4.1 and later. ## Support + The Google Cloud Provider Certificate Manager Universal Orchestrator extension is supported by Keyfactor. If you require support for any issues or have feature request, please open a support ticket by either contacting your Keyfactor representative or via the Keyfactor Support Portal at https://support.keyfactor.com. > If you want to contribute bug fixes or additional enhancements, use the **[Pull requests](../../pulls)** tab. @@ -62,14 +61,10 @@ The Google Cloud Provider Certificate Manager Universal Orchestrator extension i Before installing the Google Cloud Provider Certificate Manager Universal Orchestrator extension, we recommend that you install [kfutil](https://github.com/Keyfactor/kfutil). Kfutil is a command-line tool that simplifies the process of creating store types, installing extensions, and instantiating certificate stores in Keyfactor Command. - - ## GcpCertMgr Certificate Store Type To use the Google Cloud Provider Certificate Manager Universal Orchestrator extension, you **must** create the GcpCertMgr Certificate Store Type. This only needs to happen _once_ per Keyfactor Command instance. - - The `GcpCertMgr` store type represents a single (Project, Location) pair within Google Cloud Certificate Manager. The orchestrator manages self-managed certificates inside that container - listing them for inventory, uploading new PFX certificates, and deleting existing certificates by alias. #### Configuration model (v1.2+) @@ -267,24 +262,22 @@ Under the v1.1 model that meant every Discovery-approved store ended up with the - [Cloud Resource Manager v3 - projects.search](https://cloud.google.com/resource-manager/reference/rest/v3/projects/search) - [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) - - - #### Supported Operations -| Operation | Is Supported | -|--------------|------------------------------------------------------------------------------------------------------------------------| -| Add | ✅ Checked | -| Remove | ✅ Checked | -| Discovery | ✅ Checked | +| Operation | Is Supported | +|--------------|--------------| +| Add | ✅ Checked | +| Remove | ✅ Checked | +| Discovery | ✅ Checked | | Reenrollment | 🔲 Unchecked | -| Create | 🔲 Unchecked | +| Create | 🔲 Unchecked | #### Store Type Creation ##### Using kfutil: `kfutil` is a custom CLI for the Keyfactor Command API and can be used to create certificate store types. For more information on [kfutil](https://github.com/Keyfactor/kfutil) check out the [docs](https://github.com/Keyfactor/kfutil?tab=readme-ov-file#quickstart) +

Click to expand GcpCertMgr kfutil details ##### Using online definition from GitHub: @@ -303,10 +296,10 @@ For more information on [kfutil](https://github.com/Keyfactor/kfutil) check out ```
- #### Manual Creation Below are instructions on how to create the GcpCertMgr store type manually in the Keyfactor Command Portal +
Click to expand manual GcpCertMgr details Create a store type called `GcpCertMgr` with the attributes in the tables below: @@ -317,11 +310,11 @@ the Keyfactor Command Portal | Name | GCP Certificate Manager | Display name for the store type (may be customized) | | Short Name | GcpCertMgr | Short display name for the store type | | Capability | GcpCertMgr | Store type name orchestrator will register with. Check the box to allow entry of value | - | Supports Add | ✅ Checked | Check the box. Indicates that the Store Type supports Management Add | - | Supports Remove | ✅ Checked | Check the box. Indicates that the Store Type supports Management Remove | - | Supports Discovery | ✅ Checked | Check the box. Indicates that the Store Type supports Discovery | - | Supports Reenrollment | 🔲 Unchecked | Indicates that the Store Type supports Reenrollment | - | Supports Create | 🔲 Unchecked | Indicates that the Store Type supports store creation | + | Supports Add | ✅ Checked | Indicates that the Store Type supports Management Add | + | Supports Remove | ✅ Checked | Indicates that the Store Type supports Management Remove | + | Supports Discovery | ✅ Checked | Indicates that the Store Type supports Discovery | + | Supports Reenrollment | 🔲 Unchecked | Indicates that the Store Type supports Reenrollment | + | Supports Create | 🔲 Unchecked | Indicates that the Store Type supports store creation | | Needs Server | 🔲 Unchecked | Determines if a target server name is required when creating store | | Blueprint Allowed | 🔲 Unchecked | Determines if store type may be included in an Orchestrator blueprint | | Uses PowerShell | 🔲 Unchecked | Determines if underlying implementation is PowerShell | @@ -330,18 +323,18 @@ the Keyfactor Command Portal The Basic tab should look like this: - ![GcpCertMgr Basic Tab](docsource/images/GcpCertMgr-basic-store-type-dialog.png) + ![GcpCertMgr Basic Tab](docsource/images/GcpCertMgr-basic-store-type-dialog.svg) ##### Advanced Tab | Attribute | Value | Description | | --------- | ----- | ----- | | Supports Custom Alias | Required | Determines if an individual entry within a store can have a custom Alias. | - | Private Key Handling | Required | This determines if Keyfactor can send the private key associated with a certificate to the store. Required because IIS certificates without private keys would be invalid. | + | Private Key Handling | Required | This determines if Keyfactor can send the private key associated with a certificate to the store. | | PFX Password Style | Default | 'Default' - PFX password is randomly generated, 'Custom' - PFX password may be specified when the enrollment job is created (Requires the Allow Custom Password application setting to be enabled.) | The Advanced tab should look like this: - ![GcpCertMgr Advanced Tab](docsource/images/GcpCertMgr-advanced-store-type-dialog.png) + ![GcpCertMgr Advanced Tab](docsource/images/GcpCertMgr-advanced-store-type-dialog.svg) > For Keyfactor **Command versions 24.4 and later**, a Certificate Format dropdown is available with PFX and PEM options. Ensure that **PFX** is selected, as this determines the format of new and renewed certificates sent to the Orchestrator during a Management job. Currently, all Keyfactor-supported Orchestrator extensions support only PFX. @@ -355,41 +348,34 @@ the Keyfactor Command Portal The Custom Fields tab should look like this: - ![GcpCertMgr Custom Fields Tab](docsource/images/GcpCertMgr-custom-fields-store-type-dialog.png) - + ![GcpCertMgr Custom Fields Tab](docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg) ###### Location (deprecated) **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. - ![GcpCertMgr Custom Field - Location](docsource/images/GcpCertMgr-custom-field-Location-dialog.png) - ![GcpCertMgr Custom Field - Location](docsource/images/GcpCertMgr-custom-field-Location-validation-options-dialog.png) - + ![GcpCertMgr Custom Field - Location](docsource/images/GcpCertMgr-custom-field-Location-dialog.svg) ###### Service Account Key File Path (deprecated) **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. - ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.png) - ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.png) - - - + ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg)
- ## Installation 1. **Download the latest Google Cloud Provider Certificate Manager Universal Orchestrator extension from GitHub.** - Navigate to the [Google Cloud Provider Certificate Manager Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/gcp-certmanager-orchestrator/releases/latest). Refer to the compatibility matrix below to determine the asset should be downloaded. Then, click the corresponding asset to download the zip archive. + Navigate to the [Google Cloud Provider Certificate Manager Universal Orchestrator extension GitHub version page](https://github.com/Keyfactor/Google Cloud Provider Certificate Manager/releases/latest). Refer to the compatibility matrix below to determine which 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` | `gcp-certmanager-orchestrator` .NET version to download | + | Universal Orchestrator Version | Latest .NET version installed on the Universal Orchestrator server | `rollForward` condition in `Orchestrator.runtimeconfig.json` | `Google Cloud Provider Certificate Manager` .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` | + | `11.6` _and_ newer | `net8.0` | | `net8.0` | Unzip the archive containing extension assemblies to a known location. @@ -402,25 +388,19 @@ the Keyfactor Command Portal 3. **Create a new directory for the Google Cloud Provider Certificate Manager Universal Orchestrator extension inside the extensions directory.** - Create a new directory called `gcp-certmanager-orchestrator`. + Create a new directory called `Google Cloud Provider Certificate Manager`. > The directory name does not need to match any names used elsewhere; it just has to be unique within the extensions directory. -4. **Copy the contents of the downloaded and unzipped assemblies from __step 2__ to the `gcp-certmanager-orchestrator` directory.** +4. **Copy the contents of the downloaded and unzipped assemblies from __step 2__ to the `Google Cloud Provider Certificate Manager` directory.** 5. **Restart the Universal Orchestrator service.** Refer to [Starting/Restarting the Universal Orchestrator service](https://software.keyfactor.com/Core-OnPrem/Current/Content/InstallingAgents/NetCoreOrchestrator/StarttheService.htm). - - > The above installation steps can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/InstallingAgents/NetCoreOrchestrator/CustomExtensions.htm?Highlight=extensions). - - ## Defining Certificate Stores - - ### Store Creation #### Manually with the Command UI @@ -435,8 +415,8 @@ the Keyfactor Command Portal Click the Add button to add a new Certificate Store. Use the table below to populate the **Attributes** in the **Add** form. - | Attribute | Description | - | --------- |---------------------------------------------------------| + | Attribute | Description | + | --------- | ----------- | | Category | Select "GCP Certificate Manager" or the customized certificate store name from the previous step. | | Container | Optional container to associate certificate store with. | | Client Machine | Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. | @@ -447,8 +427,6 @@ the Keyfactor Command Portal - - #### Using kfutil CLI
Click to expand details @@ -480,13 +458,9 @@ the Keyfactor Command Portal
- - > The content in this section can be supplemented by the [official Command documentation](https://software.keyfactor.com/Core-OnPrem/Current/Content/ReferenceGuide/Certificate%20Stores.htm?Highlight=certificate%20store). - - ## GCP setup prerequisites Before configuring the orchestrator, make sure your Google Cloud project is ready. Read the official [Google Certificate Manager](https://cloud.google.com/certificate-manager/docs) documentation for product background. The steps below are intentionally text-only; Google's Cloud Console UI changes regularly and the underlying APIs and `gcloud` commands are the stable interface. @@ -541,11 +515,10 @@ The orchestrator authenticates exclusively via [Application Default Credentials] > **Note on the deprecated `Service Account Key File Path` store property.** Earlier versions of the orchestrator accepted a JSON filename in a per-store custom property and read the file from the orchestrator extension directory. That mechanism is deprecated in v1.2 because the Discovery job has no way to surface custom store properties in Keyfactor Command's discovery-job UI - so file-based auth can't be configured uniformly across all four job types. Existing v1.1 stores with the property populated continue to work, but every job run logs a deprecation warning. The field is scheduled for removal in v2.0. - ## License Apache License 2.0, see [LICENSE](LICENSE). ## Related Integrations -See all [Keyfactor Universal Orchestrator extensions](https://github.com/orgs/Keyfactor/repositories?q=orchestrator). \ No newline at end of file +See all [Keyfactor Universal Orchestrator extensions](https://github.com/orgs/Keyfactor/repositories?q=orchestrator). diff --git a/docsource/images/GcpCertMgr-advanced-store-type-dialog.svg b/docsource/images/GcpCertMgr-advanced-store-type-dialog.svg new file mode 100644 index 0000000..123a979 --- /dev/null +++ b/docsource/images/GcpCertMgr-advanced-store-type-dialog.svg @@ -0,0 +1,67 @@ + + + + + + + + + Edit Certificate Store Type + + + + Basic + Advanced + + Custom Fields + Entry Parameters + + + + + Store Path Type + + + + Freeform + + Fixed + + Multiple Choice + + + + + Other Settings + + Supports Custom Alias + + Forbidden + + Optional + + + Required + Private Key Handling + + Forbidden + + Optional + + + Required + PFX Password Style + + + Default + + Custom + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-basic-store-type-dialog.svg b/docsource/images/GcpCertMgr-basic-store-type-dialog.svg new file mode 100644 index 0000000..a69470d --- /dev/null +++ b/docsource/images/GcpCertMgr-basic-store-type-dialog.svg @@ -0,0 +1,83 @@ + + + + + + + + + Edit Certificate Store Type + + + + Basic + + Advanced + Custom Fields + Entry Parameters + + + + + Details + + Name + + GCP Certificate Manager + Short Name + + GcpCertMgr + Custom Capability + + + Custom Capability + + + + Supported Job Types + + + + Inventory + + + Add + + + Remove + + Create + + + Discovery + + ODKG + + + + General Settings + + + Needs Server + + Blueprint Allowed + + Uses PowerShell + + + + Password Settings + + + Requires Store Password + + Supports Entry Password + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg new file mode 100644 index 0000000..cc4b462 --- /dev/null +++ b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg @@ -0,0 +1,62 @@ + + + + + + + + + Edit Certificate Store Type + + + + Basic + Advanced + Custom Fields + + Entry Parameters + + + + + + ADD + + EDIT + + DELETE + Total: 2 + + + Display Name + Type + Default Value / Options + + + + + + + + + + + Location (deprecated) + String + + + + + + + Service Account Key File Path (deprecated) + String + \ No newline at end of file diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 1e6ceb8..53ddd2d 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -1,78 +1,20 @@ -#!/usr/bin/env bash +#!/bin/bash +# Store Type creation script using curl +# Generated by Doctool -# Creates all 1 store types via the Keyfactor Command REST API using curl. -# -# Authentication (first matching method is used): -# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN -# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET -# + KEYFACTOR_AUTH_TOKEN_URL -# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN -# -# Always required: -# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) -# -# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. +set -e -if [ -z "${KEYFACTOR_HOSTNAME}" ]; then - echo "ERROR: KEYFACTOR_HOSTNAME is required" - exit 1 -fi +# Configuration - set these variables before running +KEYFACTOR_HOSTNAME="${KEYFACTOR_HOSTNAME}" +KEYFACTOR_API_PATH="${KEYFACTOR_API_PATH:-KeyfactorAPI}" +KEYFACTOR_AUTH_TOKEN="${KEYFACTOR_AUTH_TOKEN}" -BASE_URL="https://${KEYFACTOR_HOSTNAME}/keyfactorapi" - -# --------------------------------------------------------------------------- -# Resolve auth -# --------------------------------------------------------------------------- -if [ -n "${KEYFACTOR_AUTH_ACCESS_TOKEN}" ]; then - BEARER_TOKEN="${KEYFACTOR_AUTH_ACCESS_TOKEN}" -elif [ -n "${KEYFACTOR_AUTH_CLIENT_ID}" ] && [ -n "${KEYFACTOR_AUTH_CLIENT_SECRET}" ] && [ -n "${KEYFACTOR_AUTH_TOKEN_URL}" ]; then - echo "Fetching OAuth token..." - BEARER_TOKEN=$(curl -s -X POST "${KEYFACTOR_AUTH_TOKEN_URL}" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "grant_type=client_credentials" \ - --data-urlencode "client_id=${KEYFACTOR_AUTH_CLIENT_ID}" \ - --data-urlencode "client_secret=${KEYFACTOR_AUTH_CLIENT_SECRET}" | jq -r '.access_token') - if [ -z "${BEARER_TOKEN}" ] || [ "${BEARER_TOKEN}" = "null" ]; then - echo "ERROR: Failed to fetch OAuth token from ${KEYFACTOR_AUTH_TOKEN_URL}" - exit 1 - fi -elif [ -n "${KEYFACTOR_USERNAME}" ] && [ -n "${KEYFACTOR_PASSWORD}" ] && [ -n "${KEYFACTOR_DOMAIN}" ]; then - BEARER_TOKEN="" -else - echo "ERROR: Authentication required. Set one of:" - echo " KEYFACTOR_AUTH_ACCESS_TOKEN" - echo " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL" - echo " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN" - exit 1 -fi - -if [ -n "${BEARER_TOKEN}" ]; then - CURL_AUTH=("-H" "Authorization: Bearer ${BEARER_TOKEN}") -else - CURL_AUTH=("-u" "${KEYFACTOR_USERNAME}@${KEYFACTOR_DOMAIN}:${KEYFACTOR_PASSWORD}") -fi - -create_store_type() { - local name="$1" - local body="$2" - echo "Creating ${name} store type..." - response=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST "${BASE_URL}/certificatestoretypes" \ - -H "Content-Type: application/json" \ - -H "x-keyfactor-requested-with: APIClient" \ - "${CURL_AUTH[@]}" \ - -d "${body}") - if [ "$response" = "200" ] || [ "$response" = "201" ]; then - echo " OK (HTTP ${response})" - else - echo " FAILED (HTTP ${response})" - fi -} - -# --------------------------------------------------------------------------- -# GcpCertMgr — Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. -# --------------------------------------------------------------------------- -create_store_type "GcpCertMgr" '{ +echo "Creating store type: GcpCertMgr" +curl -s -X POST "https://${KEYFACTOR_HOSTNAME}/${KEYFACTOR_API_PATH}/CertificateStoreTypes" \ + -H "Authorization: Bearer ${KEYFACTOR_AUTH_TOKEN}" \ + -H "Content-Type: application/json" \ + -H "x-keyfactor-requested-with: APIClient" \ + -d '{ "Name": "GCP Certificate Manager", "ShortName": "GcpCertMgr", "Capability": "GcpCertMgr", @@ -103,7 +45,8 @@ create_store_type "GcpCertMgr" '{ "DependsOn": "", "DefaultValue": "", "Required": false, - "IsPAMEligible": false + "IsPAMEligible": false, + "Description": "**Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used." }, { "Name": "ServiceAccountKey", @@ -112,12 +55,10 @@ create_store_type "GcpCertMgr" '{ "DependsOn": "", "DefaultValue": "", "Required": false, - "IsPAMEligible": false + "IsPAMEligible": false, + "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." } ], - "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", "EntryParameters": [] }' - -echo "Completed." diff --git a/scripts/store_types/bash/kfutil_create_store_types.sh b/scripts/store_types/bash/kfutil_create_store_types.sh index 06c596c..e6dced1 100755 --- a/scripts/store_types/bash/kfutil_create_store_types.sh +++ b/scripts/store_types/bash/kfutil_create_store_types.sh @@ -1,28 +1,9 @@ -#!/usr/bin/env bash +#!/bin/bash +# Store Type creation script using kfutil +# Generated by Doctool -# Creates all 1 store types using kfutil. -# kfutil reads definitions from the Keyfactor integration catalog. -# -# Auth environment variables (first matching method is used): -# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN -# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET -# + KEYFACTOR_AUTH_TOKEN_URL -# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD -# + KEYFACTOR_DOMAIN -# -# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. +set -e -if ! command -v kfutil &> /dev/null; then - echo "kfutil could not be found. Please install kfutil" - echo "See https://github.com/Keyfactor/kfutil#quickstart" - exit 1 -fi +echo "Creating store type: GcpCertMgr" +kfutil store-types create GcpCertMgr -if [ -z "$KEYFACTOR_HOSTNAME" ]; then - echo "KEYFACTOR_HOSTNAME not set — launching kfutil login" - kfutil login -fi - -kfutil store-types create --name "GcpCertMgr" - -echo "Done. All store types created." diff --git a/scripts/store_types/powershell/kfutil_create_store_types.ps1 b/scripts/store_types/powershell/kfutil_create_store_types.ps1 index 9043b32..67144f8 100644 --- a/scripts/store_types/powershell/kfutil_create_store_types.ps1 +++ b/scripts/store_types/powershell/kfutil_create_store_types.ps1 @@ -1,29 +1,6 @@ -# Creates all 1 store types using kfutil. -# kfutil reads definitions from the Keyfactor integration catalog. -# -# Auth environment variables (first matching method is used): -# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN -# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET -# + KEYFACTOR_AUTH_TOKEN_URL -# Basic auth (AD): KEYFACTOR_HOSTNAME + KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD -# + KEYFACTOR_DOMAIN -# -# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. +# Store Type creation script using kfutil +# Generated by Doctool -# Uncomment if kfutil is not in your PATH -# Set-Alias -Name kfutil -Value 'C:\Program Files\Keyfactor\kfutil\kfutil.exe' +Write-Host "Creating store type: GcpCertMgr" +kfutil store-types create GcpCertMgr -if ($null -eq (Get-Command "kfutil" -ErrorAction SilentlyContinue)) { - Write-Host "kfutil could not be found. Please install kfutil" - Write-Host "See https://github.com/Keyfactor/kfutil#quickstart" - exit 1 -} - -if (-not $env:KEYFACTOR_HOSTNAME) { - Write-Host "KEYFACTOR_HOSTNAME not set — launching kfutil login" - & kfutil login -} - -& kfutil store-types create --name "GcpCertMgr" - -Write-Host "Done. All store types created." diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index fac43f4..e479e09 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -1,70 +1,19 @@ -# Creates all 1 store types via the Keyfactor Command REST API -# using PowerShell Invoke-RestMethod. -# -# Authentication (first matching method is used): -# OAuth access token: KEYFACTOR_AUTH_ACCESS_TOKEN -# OAuth client creds: KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET -# + KEYFACTOR_AUTH_TOKEN_URL -# Basic auth (AD): KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN -# -# Always required: -# KEYFACTOR_HOSTNAME Command hostname (e.g. my-command.example.com) -# -# Auto-generated by doctool generate-store-type-scripts — do not edit by hand. +# Store Type creation script using Invoke-RestMethod +# Generated by Doctool -if (-not $env:KEYFACTOR_HOSTNAME) { - Write-Error "KEYFACTOR_HOSTNAME is required" - exit 1 -} - -$uri = "https://$($env:KEYFACTOR_HOSTNAME)/keyfactorapi/certificatestoretypes" -$headers = @{ - 'Content-Type' = "application/json" - 'x-keyfactor-requested-with' = "APIClient" -} +# Configuration - set these variables before running +$KeyfactorHostname = $env:KEYFACTOR_HOSTNAME +$KeyfactorApiPath = if ($env:KEYFACTOR_API_PATH) { $env:KEYFACTOR_API_PATH } else { "KeyfactorAPI" } +$KeyfactorAuthToken = $env:KEYFACTOR_AUTH_TOKEN -# --------------------------------------------------------------------------- -# Resolve auth -# --------------------------------------------------------------------------- -if ($env:KEYFACTOR_AUTH_ACCESS_TOKEN) { - $headers['Authorization'] = "Bearer $($env:KEYFACTOR_AUTH_ACCESS_TOKEN)" -} elseif ($env:KEYFACTOR_AUTH_CLIENT_ID -and $env:KEYFACTOR_AUTH_CLIENT_SECRET -and $env:KEYFACTOR_AUTH_TOKEN_URL) { - Write-Host "Fetching OAuth token..." - $tokenBody = @{ - grant_type = 'client_credentials' - client_id = $env:KEYFACTOR_AUTH_CLIENT_ID - client_secret = $env:KEYFACTOR_AUTH_CLIENT_SECRET - } - $tokenResp = Invoke-RestMethod -Method Post -Uri $env:KEYFACTOR_AUTH_TOKEN_URL -Body $tokenBody - $headers['Authorization'] = "Bearer $($tokenResp.access_token)" -} elseif ($env:KEYFACTOR_USERNAME -and $env:KEYFACTOR_PASSWORD -and $env:KEYFACTOR_DOMAIN) { - $cred = [System.Convert]::ToBase64String( - [System.Text.Encoding]::ASCII.GetBytes( - "$($env:KEYFACTOR_USERNAME)@$($env:KEYFACTOR_DOMAIN):$($env:KEYFACTOR_PASSWORD)")) - $headers['Authorization'] = "Basic $cred" -} else { - Write-Error ("Authentication required. Set one of:`n" + - " KEYFACTOR_AUTH_ACCESS_TOKEN`n" + - " KEYFACTOR_AUTH_CLIENT_ID + KEYFACTOR_AUTH_CLIENT_SECRET + KEYFACTOR_AUTH_TOKEN_URL`n" + - " KEYFACTOR_USERNAME + KEYFACTOR_PASSWORD + KEYFACTOR_DOMAIN") - exit 1 -} - -function New-StoreType { - param([string]$Name, [string]$Body) - Write-Host "Creating $Name store type..." - try { - Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -Body $Body -ContentType "application/json" | Out-Null - Write-Host " OK" - } catch { - Write-Warning " FAILED: $($_.Exception.Message)" - } +$Headers = @{ + "Authorization" = "Bearer $KeyfactorAuthToken" + "Content-Type" = "application/json" + "x-keyfactor-requested-with" = "APIClient" } -# --------------------------------------------------------------------------- -# GcpCertMgr — Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path. -# --------------------------------------------------------------------------- -New-StoreType "GcpCertMgr" @' +Write-Host "Creating store type: GcpCertMgr" +$Body = @' { "Name": "GCP Certificate Manager", "ShortName": "GcpCertMgr", @@ -96,7 +45,8 @@ New-StoreType "GcpCertMgr" @' "DependsOn": "", "DefaultValue": "", "Required": false, - "IsPAMEligible": false + "IsPAMEligible": false, + "Description": "**Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used." }, { "Name": "ServiceAccountKey", @@ -105,13 +55,13 @@ New-StoreType "GcpCertMgr" @' "DependsOn": "", "DefaultValue": "", "Required": false, - "IsPAMEligible": false + "IsPAMEligible": false, + "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." } ], - "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", "EntryParameters": [] } '@ +Invoke-RestMethod -Uri "https://$KeyfactorHostname/$KeyfactorApiPath/CertificateStoreTypes" -Method POST -Headers $Headers -Body $Body -Write-Host "Completed." From 387cba5e51112d3b10c1d166602b651126cde07d Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Thu, 7 May 2026 15:37:43 -0400 Subject: [PATCH 22/31] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d5d58..5af55af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,4 +125,4 @@ v1.1.0 - Converted README to use doctool v1.0.2 -- Initial Public Version +- Initial Public Version For Release From 22cea0abf2fd9ed19bd0419bab68df4d25d7000b Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 11 May 2026 10:29:59 -0400 Subject: [PATCH 23/31] feat: add Scope custom store property for GCP Certificate Manager GCP Certificate Manager's `scope` field is create-only and immutable. Previous releases hard-coded `Scope = "DEFAULT"` on every certificate created, which made the orchestrator unusable for environments running cross-region internal Application Load Balancers (`ALL_REGIONS`), Media CDN (`EDGE_CACHE`), or mTLS trust configs (`CLIENT_AUTH`). Affected customers had to pre-create empty placeholder certificate resources in GCP via Terraform with the right scope, then point Keyfactor at the shell - breaking the single-pane-of-glass workflow. Adds a `Scope` custom store property honored by Management/Add. Values are case-normalized and validated against the four-value enum GCP accepts; an unsupported value fails the `ResolveScope` flow step before any API call. Blank resolves to `DEFAULT` so existing stores keep working with no operator action. Replace (overwrite) intentionally does not propagate scope: the patch UpdateMask is "SelfManaged", and GCP would reject a scope change anyway. The recommended pattern is one store per (project, location, scope) tuple. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 23 ++++++++++++++++++++ GcpCertManager/Jobs/JobBase.cs | 35 +++++++++++++++++++++++++++++++ GcpCertManager/Jobs/Management.cs | 21 +++++++++++++------ GcpCertManager/StoreProperties.cs | 8 +++++++ docsource/gcpcertmgr.md | 34 ++++++++++++++++++++++++++++++ integration-manifest.json | 10 +++++++++ 6 files changed, 125 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af55af..484f21a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,29 @@ v1.2.0 - unreleased replacing the previous behavior of failing 700ms later with a wall-of-JSON HTTP 400 from GCP. See `JobBase.ValidateGcpCertificateId`. +### Added (Scope custom property) +- Added a new `Scope` custom store property that is honored by Management/Add. + Previous releases hard-coded `Scope = "DEFAULT"` on every certificate created + in GCP, which made the orchestrator unusable for environments that depend on + cross-region internal Application Load Balancers (`ALL_REGIONS`), Media CDN + (`EDGE_CACHE`), or mTLS trust-config / authorized-client server certs + (`CLIENT_AUTH`). Those customers had to pre-create empty placeholder + certificates in GCP via Terraform and then attach Keyfactor to the existing + shell. The new property lets a single store create certificates at any of + the four allowed scopes natively. + - Allowed values: `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. + Values are case-normalized (uppercased and trimmed) before validation. + Anything else fails the `ResolveScope` flow step before any API call. + - Default is `DEFAULT`. Blank also resolves to `DEFAULT`, so existing v1.1 + and v1.2-pre-Scope stores keep working with no operator action. + - GCP's Scope field is **create-only and immutable**. Replace (overwrite) + paths do not change scope: the `Patch` call's `UpdateMask` is `SelfManaged`, + so GCP only updates the cert/key bytes. To change a certificate's scope, + delete the certificate and re-add it. + - Recommended deployment pattern is one store per (project, location, scope) + tuple. Mixing scopes inside a single store is awkward because the property + is store-wide, not per-cert. + ### Backwards compatibility - v1.1-shape stores (Store Path blank or `n/a`, Client Machine = Project ID, Location custom property = region) continue to work via a deprecation-logged diff --git a/GcpCertManager/Jobs/JobBase.cs b/GcpCertManager/Jobs/JobBase.cs index 0862b19..94686f0 100644 --- a/GcpCertManager/Jobs/JobBase.cs +++ b/GcpCertManager/Jobs/JobBase.cs @@ -212,6 +212,41 @@ protected static void ValidateGcpCertificateId(string alias) } } + // GCP Certificate Manager's create-only Scope values. Anything else produces an + // HTTP 400 INVALID_ARGUMENT from the create call, so validate up front and reject + // typos with a clear message instead of letting them reach the API. + // Source: https://cloud.google.com/certificate-manager/docs/reference/rest/v1/projects.locations.certificates#Certificate.Scope + private static readonly System.Collections.Generic.HashSet AllowedScopes = + new System.Collections.Generic.HashSet(StringComparer.Ordinal) + { + "DEFAULT", + "EDGE_CACHE", + "ALL_REGIONS", + "CLIENT_AUTH" + }; + + /// + /// Normalize the per-store Scope custom property to a value GCP will + /// accept. Blank → DEFAULT (matches pre-v1.2 behavior, so unmigrated + /// stores keep working). Other values are uppercased and validated against the + /// set GCP allows; an unknown value throws so + /// the operator sees a clear failure before any API call. + /// + protected static string ResolveScope(string configuredScope) + { + if (string.IsNullOrWhiteSpace(configuredScope)) return "DEFAULT"; + + var normalized = configuredScope.Trim().ToUpperInvariant(); + if (!AllowedScopes.Contains(normalized)) + { + throw new ArgumentException( + $"Unsupported Scope '{configuredScope}'. GCP Certificate Manager accepts only " + + "DEFAULT, EDGE_CACHE, ALL_REGIONS, or CLIENT_AUTH. Edit the store's Scope custom property and retry.", + nameof(configuredScope)); + } + return normalized; + } + private static string SuggestValidAlias(string alias) { if (string.IsNullOrEmpty(alias)) return "cert"; diff --git a/GcpCertManager/Jobs/Management.cs b/GcpCertManager/Jobs/Management.cs index c75b4b0..c735cd0 100644 --- a/GcpCertManager/Jobs/Management.cs +++ b/GcpCertManager/Jobs/Management.cs @@ -90,6 +90,7 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge Logger.LogTrace(" Location: {Location}", storeProperties.Location); Logger.LogTrace(" Project Id: {ProjectId}", storeProperties.ProjectId); Logger.LogTrace(" Service Account Key Path: {ServiceAccountKey}", storeProperties.ServiceAccountKey); + Logger.LogTrace(" Scope: {Scope}", storeProperties.Scope); CertificateManagerService svc = null; flow.Step("GetGoogleCredentials", () => @@ -108,7 +109,7 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge { case CertStoreOperationType.Add: flow.Branch("Add"); - try { return PerformAddition(svc, config, storePath, flow); } + try { return PerformAddition(svc, config, storeProperties, storePath, flow); } finally { flow.EndBranch(); } case CertStoreOperationType.Remove: flow.Branch("Remove"); @@ -129,7 +130,7 @@ private JobResult PerformRemoval(CertificateManagerService svc, ManagementJobCon } private JobResult PerformAddition(CertificateManagerService svc, ManagementJobConfiguration config, - string storePath, FlowLogger flow) + StoreProperties storeProperties, string storePath, FlowLogger flow) { // Validate the alias before any API calls or PFX parsing - GCP rejects // non-conforming IDs with HTTP 400 after we've already done the expensive @@ -137,6 +138,12 @@ private JobResult PerformAddition(CertificateManagerService svc, ManagementJobCo flow.Step("ValidateAlias", () => ValidateGcpCertificateId(CertificateName), $"alias={CertificateName}"); + // Resolve the per-store Scope custom property up front. GCP's Scope field is + // create-only, so the value has to be correct before we touch the API. + string resolvedScope = null; + flow.Step("ResolveScope", () => resolvedScope = ResolveScope(storeProperties.Scope), + $"configured={storeProperties.Scope ?? ""}"); + var duplicate = false; flow.Step("CheckForDuplicate", () => duplicate = CheckForDuplicate(storePath, CertificateName, svc), $"alias={CertificateName}"); @@ -219,15 +226,17 @@ private JobResult PerformAddition(CertificateManagerService svc, ManagementJobCo // Build the GCP certificate object. Don't serialize+log; that would leak the // private key into trace logs. + // + // Scope is sourced from the per-store custom property and is honored only on + // Add. On Replace the patch's UpdateMask is "SelfManaged", so GCP ignores + // every other field on the body (including Scope) - which is correct, since + // GCP rejects scope changes anyway. var gCertificate = new Certificate { SelfManaged = new SelfManagedCertificate { PemCertificate = pubCertPem, PemPrivateKey = privateKeyString }, Name = CertificateName, Description = CertificateName, - // Scope does not come back in inventory, so hard-code it. Customers - // running edge-cache stores will need to override this in a future - // store-property if/when that scope becomes used. - Scope = "DEFAULT" + Scope = resolvedScope }; if (duplicate && config.Overwrite) diff --git a/GcpCertManager/StoreProperties.cs b/GcpCertManager/StoreProperties.cs index 95384f8..a401a25 100644 --- a/GcpCertManager/StoreProperties.cs +++ b/GcpCertManager/StoreProperties.cs @@ -17,5 +17,13 @@ internal class StoreProperties public string ProjectId { get; set; } public string ServiceAccountKey { get; set; } + + // GCP Certificate Manager's Scope field is create-only and immutable. Blank + // means "let JobBase.ResolveScope pick DEFAULT" so existing stores upgrade + // without operator intervention. Non-default scopes (ALL_REGIONS for + // cross-region internal ALBs, EDGE_CACHE for Media CDN, CLIENT_AUTH for + // mTLS trust configs) must be set per-store before the first Add. + [DefaultValue("")] + public string Scope { get; set; } } } \ No newline at end of file diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index 0952c4d..0f30173 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -20,6 +20,7 @@ That single value carries both the GCP project and the location (region or `glob | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | | **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | +| **Certificate Scope** (custom) | GCP `scope` to apply to every new certificate created in this store. One of `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. Blank → `DEFAULT`. Immutable on each cert once set in GCP - use one store per scope. See "Certificate scope" below. | Management/Add | #### Location semantics: where the GCP region lives @@ -65,6 +66,7 @@ Set: - **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` - **Service Account Key File Path**: leave blank (deprecated; ADC is used) - **Location**: leave blank +- **Certificate Scope**: `DEFAULT` for global external Application Load Balancers (the common case). Set to `ALL_REGIONS` if this store provisions certs for cross-region internal Application Load Balancers; `EDGE_CACHE` for Media CDN; `CLIENT_AUTH` for mTLS trust configs. See "Certificate scope" below. Authentication uses Application Default Credentials - see "Service account credentials" below. @@ -79,6 +81,7 @@ After the discovery job runs, candidates appear in **Locations → Certificate S | **Application** | Optional, free-form. | | **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. | | **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. | +| **Certificate Scope** (custom) | Defaults to `DEFAULT`. Change only if this discovered store is going to back a non-default scope (`ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`) - Discovery does not know which scope your downstream load balancers need, so the operator sets it at approval time. Discovery emits one candidate per (project, location); if you need both `DEFAULT` and `ALL_REGIONS` certs in the same (project, location), reject this candidate and create two stores manually instead. See "Certificate scope" below. | | **Create Certificate Store If Missing** | **Check this.** Tells Command to create a new certificate store record from this candidate. Without it, the candidate sits in the discover tab with no store backing it. | | **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. | @@ -148,6 +151,37 @@ GCP Certificate Manager constrains certificate resource IDs to a strict shape: The orchestrator validates the alias against this rule **before** any API calls or PFX parsing during Management/Add. A non-conforming alias fails fast with a `[FAIL] ValidateAlias` step in the flow trace and a suggestion of a normalized alias (e.g. `Cert1` → `cert1`). Rename the certificate in Keyfactor Command to the suggested form and retry the Management/Add job. +### Certificate scope + +GCP Certificate Manager attaches a `scope` to every certificate that determines which load balancer / service families can consume it. The `Scope` custom store property tells the orchestrator which value to pass to GCP on create. + +| Value | What it is for | +|---|---| +| `DEFAULT` | Global external Application Load Balancers - the standard GCP load balancer for internet-facing traffic. This is the GCP default and the right answer for most stores. | +| `ALL_REGIONS` | Cross-region **internal** Application Load Balancers. Use this when the consuming load balancer is regional/internal and replicated across regions. | +| `EDGE_CACHE` | Google Cloud Media CDN edge-cache certs. | +| `CLIENT_AUTH` | Certificates used by mTLS trust configs, or server certificates that are authorized for client authentication. | + +#### Immutability + +The `scope` field is **create-only** in the GCP API. Once GCP creates a certificate with a given scope, that scope cannot be changed by any patch operation. The orchestrator's Management/Replace path uses `UpdateMask = "SelfManaged"`, so re-adding a certificate over an existing one preserves its original scope - even if the store's Scope property has changed. To migrate a certificate to a different scope, delete it (Management/Remove) and re-add it with the new scope. + +This is why the recommended deployment pattern is **one store per (project, location, scope) tuple**. The Scope property is store-wide, not per-cert. + +#### What happened before v1.2.1 + +Prior to v1.2.1 the orchestrator hard-coded `Scope = "DEFAULT"` on every certificate it created. Customers who needed non-default scopes (typically `ALL_REGIONS` for cross-region internal ALBs) had to pre-create empty placeholder certificate resources in GCP via Terraform with `scope = "ALL_REGIONS"`, then point Keyfactor at the existing resource as a Replace target. The new property removes that workaround: a store with Scope = `ALL_REGIONS` will create new certificate resources directly at the right scope. + +#### How the orchestrator validates the value + +`JobBase.ResolveScope` runs as the `ResolveScope` flow step on every Management/Add. It trims and uppercases the configured value, then validates it against the set GCP accepts. An unsupported value (typo, lowercase letters that don't normalize to a valid token, anything outside the four allowed values) fails with `[FAIL] ResolveScope` and a clear message naming the four legal values. Blank or null resolves to `DEFAULT`. + +#### Quick reference + +- "Where do I see what scope a certificate ended up with?" → GCP's Certificate Manager Console, or `gcloud certificate-manager certificates describe --location= --project=`. The orchestrator's Inventory job does not surface scope today. +- "Can I change a certificate's scope?" → No. Delete and re-add. +- "I have one store with DEFAULT certs and I want to add an ALL_REGIONS cert" → Create a second store with the same (project, location) but Scope = `ALL_REGIONS`. + ### Architecture and logging Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. diff --git a/integration-manifest.json b/integration-manifest.json index a24be59..5874753 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -58,6 +58,16 @@ "Required": false, "IsPAMEligible": false, "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." + }, + { + "Name": "Scope", + "DisplayName": "Certificate Scope", + "Type": "String", + "DependsOn": "", + "DefaultValue": "DEFAULT", + "Required": false, + "IsPAMEligible": false, + "Description": "GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior." } ], "ClientMachineDescription": "Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path.", From 98e585ff19d58f054be9f579096c2b8e95c078c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 14:34:42 +0000 Subject: [PATCH 24/31] docs: auto-generate README and documentation [skip ci] --- README.md | 43 +++++++++++++++++++ ...ertMgr-custom-fields-store-type-dialog.svg | 17 ++++++-- .../bash/curl_create_store_types.sh | 10 +++++ .../restmethod_create_store_types.ps1 | 10 +++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6b75139..36008e5 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ That single value carries both the GCP project and the location (region or `glob | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | | **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | +| **Certificate Scope** (custom) | GCP `scope` to apply to every new certificate created in this store. One of `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. Blank → `DEFAULT`. Immutable on each cert once set in GCP - use one store per scope. See "Certificate scope" below. | Management/Add | ##### Location semantics: where the GCP region lives @@ -130,6 +131,7 @@ Set: - **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` - **Service Account Key File Path**: leave blank (deprecated; ADC is used) - **Location**: leave blank +- **Certificate Scope**: `DEFAULT` for global external Application Load Balancers (the common case). Set to `ALL_REGIONS` if this store provisions certs for cross-region internal Application Load Balancers; `EDGE_CACHE` for Media CDN; `CLIENT_AUTH` for mTLS trust configs. See "Certificate scope" below. Authentication uses Application Default Credentials - see "Service account credentials" below. @@ -144,6 +146,7 @@ After the discovery job runs, candidates appear in **Locations → Certificate S | **Application** | Optional, free-form. | | **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. | | **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. | +| **Certificate Scope** (custom) | Defaults to `DEFAULT`. Change only if this discovered store is going to back a non-default scope (`ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`) - Discovery does not know which scope your downstream load balancers need, so the operator sets it at approval time. Discovery emits one candidate per (project, location); if you need both `DEFAULT` and `ALL_REGIONS` certs in the same (project, location), reject this candidate and create two stores manually instead. See "Certificate scope" below. | | **Create Certificate Store If Missing** | **Check this.** Tells Command to create a new certificate store record from this candidate. Without it, the candidate sits in the discover tab with no store backing it. | | **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. | @@ -213,6 +216,37 @@ GCP Certificate Manager constrains certificate resource IDs to a strict shape: The orchestrator validates the alias against this rule **before** any API calls or PFX parsing during Management/Add. A non-conforming alias fails fast with a `[FAIL] ValidateAlias` step in the flow trace and a suggestion of a normalized alias (e.g. `Cert1` → `cert1`). Rename the certificate in Keyfactor Command to the suggested form and retry the Management/Add job. +#### Certificate scope + +GCP Certificate Manager attaches a `scope` to every certificate that determines which load balancer / service families can consume it. The `Scope` custom store property tells the orchestrator which value to pass to GCP on create. + +| Value | What it is for | +|---|---| +| `DEFAULT` | Global external Application Load Balancers - the standard GCP load balancer for internet-facing traffic. This is the GCP default and the right answer for most stores. | +| `ALL_REGIONS` | Cross-region **internal** Application Load Balancers. Use this when the consuming load balancer is regional/internal and replicated across regions. | +| `EDGE_CACHE` | Google Cloud Media CDN edge-cache certs. | +| `CLIENT_AUTH` | Certificates used by mTLS trust configs, or server certificates that are authorized for client authentication. | + +##### Immutability + +The `scope` field is **create-only** in the GCP API. Once GCP creates a certificate with a given scope, that scope cannot be changed by any patch operation. The orchestrator's Management/Replace path uses `UpdateMask = "SelfManaged"`, so re-adding a certificate over an existing one preserves its original scope - even if the store's Scope property has changed. To migrate a certificate to a different scope, delete it (Management/Remove) and re-add it with the new scope. + +This is why the recommended deployment pattern is **one store per (project, location, scope) tuple**. The Scope property is store-wide, not per-cert. + +##### What happened before v1.2.1 + +Prior to v1.2.1 the orchestrator hard-coded `Scope = "DEFAULT"` on every certificate it created. Customers who needed non-default scopes (typically `ALL_REGIONS` for cross-region internal ALBs) had to pre-create empty placeholder certificate resources in GCP via Terraform with `scope = "ALL_REGIONS"`, then point Keyfactor at the existing resource as a Replace target. The new property removes that workaround: a store with Scope = `ALL_REGIONS` will create new certificate resources directly at the right scope. + +##### How the orchestrator validates the value + +`JobBase.ResolveScope` runs as the `ResolveScope` flow step on every Management/Add. It trims and uppercases the configured value, then validates it against the set GCP accepts. An unsupported value (typo, lowercase letters that don't normalize to a valid token, anything outside the four allowed values) fails with `[FAIL] ResolveScope` and a clear message naming the four legal values. Blank or null resolves to `DEFAULT`. + +##### Quick reference + +- "Where do I see what scope a certificate ended up with?" → GCP's Certificate Manager Console, or `gcloud certificate-manager certificates describe --location= --project=`. The orchestrator's Inventory job does not surface scope today. +- "Can I change a certificate's scope?" → No. Delete and re-add. +- "I have one store with DEFAULT certs and I want to add an ALL_REGIONS cert" → Create a second store with the same (project, location) but Scope = `ALL_REGIONS`. + #### Architecture and logging Every job (Discovery, Inventory, Management) uses a shared `FlowLogger` to record step-by-step progress with timing. The flow summary is appended to `JobResult.FailureMessage` on **both** success and failure paths so operators reading job history can see what happened without having to pull orchestrator-side trace logs. Errors arising from the GCP SDK are unwrapped through `AggregateException` walls and reported with HTTP status + the GCP error response body, so quota errors / IAM denials / malformed certificates surface clearly in Command's UI. @@ -345,6 +379,7 @@ the Keyfactor Command Portal | ---- | ------------ | ---- | --------------------- | -------- | ----------- | | Location | Location (deprecated) | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | String | | 🔲 Unchecked | | ServiceAccountKey | Service Account Key File Path (deprecated) | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | String | | 🔲 Unchecked | + | Scope | Certificate Scope | GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. | String | DEFAULT | 🔲 Unchecked | The Custom Fields tab should look like this: @@ -362,6 +397,12 @@ the Keyfactor Command Portal ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg) + ###### Certificate Scope + GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. + + ![GcpCertMgr Custom Field - Scope](docsource/images/GcpCertMgr-custom-field-Scope-dialog.svg) + + ## Installation @@ -424,6 +465,7 @@ the Keyfactor Command Portal | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | | Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | | ServiceAccountKey | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | + | Scope | GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. | @@ -449,6 +491,7 @@ the Keyfactor Command Portal | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | | Properties.Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | | Properties.ServiceAccountKey | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | + | Properties.Scope | GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. | 3. **Import the CSV file to create the certificate stores** diff --git a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg index cc4b462..eb84cb6 100644 --- a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg +++ b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg @@ -1,5 +1,5 @@  - + - + Edit Certificate Store Type @@ -24,7 +24,7 @@ Entry Parameters - + @@ -33,7 +33,7 @@ EDIT DELETE - Total: 2 + Total: 3 Display Name @@ -59,4 +59,13 @@ Service Account Key File Path (deprecated) String + + + + + + + Certificate Scope + String + DEFAULT \ No newline at end of file diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 53ddd2d..05672e0 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -57,6 +57,16 @@ curl -s -X POST "https://${KEYFACTOR_HOSTNAME}/${KEYFACTOR_API_PATH}/Certificate "Required": false, "IsPAMEligible": false, "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." + }, + { + "Name": "Scope", + "DisplayName": "Certificate Scope", + "Type": "String", + "DependsOn": "", + "DefaultValue": "DEFAULT", + "Required": false, + "IsPAMEligible": false, + "Description": "GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior." } ], "EntryParameters": [] diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index e479e09..7cdfa2a 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -57,6 +57,16 @@ $Body = @' "Required": false, "IsPAMEligible": false, "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." + }, + { + "Name": "Scope", + "DisplayName": "Certificate Scope", + "Type": "String", + "DependsOn": "", + "DefaultValue": "DEFAULT", + "Required": false, + "IsPAMEligible": false, + "Description": "GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior." } ], "EntryParameters": [] From 0b39469af87a0cf33855d4b36a894e1dca01e423 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 11 May 2026 14:43:09 -0400 Subject: [PATCH 25/31] refactor: model Scope as entry parameter, persist via Inventory Scope is per-certificate in GCP's data model - a single (project, location) container can hold certs at different scopes - so modeling it as a store property forced operators into one-store-per-scope and required toggling the property between adds. Entry parameter matches the actual semantics and lets a single store hold mixed-scope certs. Changes: - Move Scope from manifest Properties[] to EntryParameters[] as a MultipleChoice dropdown (DEFAULT/ALL_REGIONS/EDGE_CACHE/CLIENT_AUTH). Operators pick at Add time; the dropdown prevents typos reaching the orchestrator (ResolveScope still validates as defence-in-depth). - Remove Scope from StoreProperties.cs. - Management.cs now reads Scope from config.JobProperties["Scope"] rather than the store-properties bag. - Inventory.cs reads c.Scope from each certificates.list response and writes it into CurrentInventoryItem.Parameters["Scope"]. GCP omits the field when the cert is at DEFAULT, so null/blank normalizes to "DEFAULT" here. This makes renewals/reenrollments carry scope forward automatically - Keyfactor replays the inventoried value back into JobProperties on the next Management/Add cycle. - Regenerate scripts/store_types/{bash,powershell} so operators applying the store type from the branch get the EntryParameter shape without waiting for doctool CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 47 ++++++++++++------- GcpCertManager/Jobs/Inventory.cs | 12 +++-- GcpCertManager/Jobs/Management.cs | 28 +++++++---- GcpCertManager/StoreProperties.cs | 8 ---- docsource/gcpcertmgr.md | 30 +++++++----- integration-manifest.json | 24 ++++++---- .../bash/curl_create_store_types.sh | 20 +++++--- .../restmethod_create_store_types.ps1 | 20 +++++--- 8 files changed, 117 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484f21a..db4df54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,28 +110,41 @@ v1.2.0 - unreleased replacing the previous behavior of failing 700ms later with a wall-of-JSON HTTP 400 from GCP. See `JobBase.ValidateGcpCertificateId`. -### Added (Scope custom property) -- Added a new `Scope` custom store property that is honored by Management/Add. - Previous releases hard-coded `Scope = "DEFAULT"` on every certificate created - in GCP, which made the orchestrator unusable for environments that depend on - cross-region internal Application Load Balancers (`ALL_REGIONS`), Media CDN - (`EDGE_CACHE`), or mTLS trust-config / authorized-client server certs - (`CLIENT_AUTH`). Those customers had to pre-create empty placeholder - certificates in GCP via Terraform and then attach Keyfactor to the existing - shell. The new property lets a single store create certificates at any of - the four allowed scopes natively. +### Added (Scope as entry parameter) +- Added a new `Scope` **entry parameter** (per-certificate, not per-store) that + controls the GCP Certificate Manager `scope` value on each newly-created + certificate. Previous releases hard-coded `Scope = "DEFAULT"` and never read + scope back during Inventory, which made the orchestrator unusable for + environments that depend on cross-region internal Application Load Balancers + (`ALL_REGIONS`), Media CDN (`EDGE_CACHE`), or mTLS trust-config / + authorized-client server certs (`CLIENT_AUTH`). Those customers had to + pre-create empty placeholder certificates in GCP via Terraform with the right + scope and then attach Keyfactor to the existing shell. + - Modeled as `EntryParameters` (not `Properties`) because scope is per-cert + in GCP - a single (project, location) container can legitimately hold + certificates at different scopes. A single Keyfactor store can now hold a + mix of scopes without needing one store per scope. + - Rendered as a `MultipleChoice` dropdown in Command with the four allowed + values pre-populated, so typos cannot reach the orchestrator. - Allowed values: `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. - Values are case-normalized (uppercased and trimmed) before validation. - Anything else fails the `ResolveScope` flow step before any API call. - - Default is `DEFAULT`. Blank also resolves to `DEFAULT`, so existing v1.1 - and v1.2-pre-Scope stores keep working with no operator action. + `JobBase.ResolveScope` validates defence-in-depth: trims, uppercases, + rejects anything not in the set with a `[FAIL] ResolveScope` flow step + before any GCP API call. + - Default is `DEFAULT`. Blank, null, or missing all resolve to `DEFAULT`, + matching the pre-v1.2.1 behavior so existing stores upgrade with no + operator action. + - Inventory now reads each cert's actual `scope` from GCP's + `certificates.list` response and writes it into the cert's + `CurrentInventoryItem.Parameters` dict. GCP elides the field when the + cert is at `DEFAULT`; the orchestrator normalizes null/blank to `DEFAULT` + so Command always sees a concrete value. On subsequent renewals / + reenrollments Keyfactor replays the inventoried value into + `JobProperties`, so the cert keeps its scope through its lifecycle + automatically. - GCP's Scope field is **create-only and immutable**. Replace (overwrite) paths do not change scope: the `Patch` call's `UpdateMask` is `SelfManaged`, so GCP only updates the cert/key bytes. To change a certificate's scope, delete the certificate and re-add it. - - Recommended deployment pattern is one store per (project, location, scope) - tuple. Mixing scopes inside a single store is awkward because the property - is store-wide, not per-cert. ### Backwards compatibility - v1.1-shape stores (Store Path blank or `n/a`, Client Machine = Project ID, diff --git a/GcpCertManager/Jobs/Inventory.cs b/GcpCertManager/Jobs/Inventory.cs index a87e368..7d47293 100644 --- a/GcpCertManager/Jobs/Inventory.cs +++ b/GcpCertManager/Jobs/Inventory.cs @@ -129,7 +129,7 @@ private JobResult PerformInventory(InventoryJobConfiguration config, Logger.LogTrace( "Building Cert List Inventory Item Alias: {Name} Pem: {Pem} Private Key: dummy (from PA API)", c.Name, c.PemCertificate); - var item = BuildInventoryItem(c.Name, c.PemCertificate, true, storePath, svc); + var item = BuildInventoryItem(c.Name, c.PemCertificate, true, storePath, svc, c.Scope); if (item?.Certificates != null) inventoryItems.Add(item); } @@ -164,14 +164,20 @@ private JobResult PerformInventory(InventoryJobConfiguration config, } protected virtual CurrentInventoryItem BuildInventoryItem(string alias, string certPem, bool privateKey, - string storePath, CertificateManagerService svc) + string storePath, CertificateManagerService svc, string scope) { try { Logger.MethodEntry(); - Logger.LogTrace("Alias: {Alias} Pem: {Pem} PrivateKey: {PrivateKey}", alias, certPem, privateKey); + Logger.LogTrace("Alias: {Alias} Pem: {Pem} PrivateKey: {PrivateKey} Scope: {Scope}", + alias, certPem, privateKey, scope ?? ""); var certAttributes = GetCertificateAttributes(storePath); + // GCP omits the scope field from the response when it's the default. + // Normalize null/blank to "DEFAULT" here so Command's UI always shows a + // concrete value on inventoried certs - and so that renewal jobs land a + // non-null Scope in JobProperties when Keyfactor replays the entry params. + certAttributes["Scope"] = string.IsNullOrWhiteSpace(scope) ? "DEFAULT" : scope; var modAlias = alias.Split('/')[5]; Logger.LogTrace("Got modAlias: {ModAlias}", modAlias); diff --git a/GcpCertManager/Jobs/Management.cs b/GcpCertManager/Jobs/Management.cs index c735cd0..b37d27e 100644 --- a/GcpCertManager/Jobs/Management.cs +++ b/GcpCertManager/Jobs/Management.cs @@ -90,7 +90,6 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge Logger.LogTrace(" Location: {Location}", storeProperties.Location); Logger.LogTrace(" Project Id: {ProjectId}", storeProperties.ProjectId); Logger.LogTrace(" Service Account Key Path: {ServiceAccountKey}", storeProperties.ServiceAccountKey); - Logger.LogTrace(" Scope: {Scope}", storeProperties.Scope); CertificateManagerService svc = null; flow.Step("GetGoogleCredentials", () => @@ -109,7 +108,7 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge { case CertStoreOperationType.Add: flow.Branch("Add"); - try { return PerformAddition(svc, config, storeProperties, storePath, flow); } + try { return PerformAddition(svc, config, storePath, flow); } finally { flow.EndBranch(); } case CertStoreOperationType.Remove: flow.Branch("Remove"); @@ -130,7 +129,7 @@ private JobResult PerformRemoval(CertificateManagerService svc, ManagementJobCon } private JobResult PerformAddition(CertificateManagerService svc, ManagementJobConfiguration config, - StoreProperties storeProperties, string storePath, FlowLogger flow) + string storePath, FlowLogger flow) { // Validate the alias before any API calls or PFX parsing - GCP rejects // non-conforming IDs with HTTP 400 after we've already done the expensive @@ -138,11 +137,20 @@ private JobResult PerformAddition(CertificateManagerService svc, ManagementJobCo flow.Step("ValidateAlias", () => ValidateGcpCertificateId(CertificateName), $"alias={CertificateName}"); - // Resolve the per-store Scope custom property up front. GCP's Scope field is - // create-only, so the value has to be correct before we touch the API. + // Resolve the per-entry Scope entry parameter up front. Scope is per-cert + // (not per-store) because GCP itself allows mixed-scope certs inside the same + // (project, location). The value lands in config.JobProperties from the + // Command UI dropdown; on renewals/reenrollments Keyfactor pre-fills it from + // the cert's last-known inventory Parameters, so the cert keeps its scope + // through its lifecycle without operator intervention. + string configuredScope = null; + if (config.JobProperties != null && config.JobProperties.TryGetValue("Scope", out var rawScope)) + { + configuredScope = rawScope?.ToString(); + } string resolvedScope = null; - flow.Step("ResolveScope", () => resolvedScope = ResolveScope(storeProperties.Scope), - $"configured={storeProperties.Scope ?? ""}"); + flow.Step("ResolveScope", () => resolvedScope = ResolveScope(configuredScope), + $"configured={configuredScope ?? ""}"); var duplicate = false; flow.Step("CheckForDuplicate", () => duplicate = CheckForDuplicate(storePath, CertificateName, svc), @@ -227,10 +235,10 @@ private JobResult PerformAddition(CertificateManagerService svc, ManagementJobCo // Build the GCP certificate object. Don't serialize+log; that would leak the // private key into trace logs. // - // Scope is sourced from the per-store custom property and is honored only on - // Add. On Replace the patch's UpdateMask is "SelfManaged", so GCP ignores + // Scope comes from the per-entry "Scope" entry parameter and is honored only + // on Add. On Replace the patch's UpdateMask is "SelfManaged", so GCP ignores // every other field on the body (including Scope) - which is correct, since - // GCP rejects scope changes anyway. + // GCP refuses to change scope on an existing cert anyway. var gCertificate = new Certificate { SelfManaged = new SelfManagedCertificate { PemCertificate = pubCertPem, PemPrivateKey = privateKeyString }, diff --git a/GcpCertManager/StoreProperties.cs b/GcpCertManager/StoreProperties.cs index a401a25..95384f8 100644 --- a/GcpCertManager/StoreProperties.cs +++ b/GcpCertManager/StoreProperties.cs @@ -17,13 +17,5 @@ internal class StoreProperties public string ProjectId { get; set; } public string ServiceAccountKey { get; set; } - - // GCP Certificate Manager's Scope field is create-only and immutable. Blank - // means "let JobBase.ResolveScope pick DEFAULT" so existing stores upgrade - // without operator intervention. Non-default scopes (ALL_REGIONS for - // cross-region internal ALBs, EDGE_CACHE for Media CDN, CLIENT_AUTH for - // mTLS trust configs) must be set per-store before the first Add. - [DefaultValue("")] - public string Scope { get; set; } } } \ No newline at end of file diff --git a/docsource/gcpcertmgr.md b/docsource/gcpcertmgr.md index 0f30173..e287ddf 100644 --- a/docsource/gcpcertmgr.md +++ b/docsource/gcpcertmgr.md @@ -20,7 +20,7 @@ That single value carries both the GCP project and the location (region or `glob | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | | **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | -| **Certificate Scope** (custom) | GCP `scope` to apply to every new certificate created in this store. One of `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. Blank → `DEFAULT`. Immutable on each cert once set in GCP - use one store per scope. See "Certificate scope" below. | Management/Add | +| **Certificate Scope** (*entry parameter*, not a store property) | GCP `scope` for an individual certificate entry. One of `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. Set per-cert at Add time; Inventory persists the existing value back from GCP so renewals carry it forward. Defined under the store type's EntryParameters, not Properties. See "Certificate scope" below. | Management/Add (read from `JobProperties`), Inventory (write to per-entry `Parameters`) | #### Location semantics: where the GCP region lives @@ -66,7 +66,8 @@ Set: - **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` - **Service Account Key File Path**: leave blank (deprecated; ADC is used) - **Location**: leave blank -- **Certificate Scope**: `DEFAULT` for global external Application Load Balancers (the common case). Set to `ALL_REGIONS` if this store provisions certs for cross-region internal Application Load Balancers; `EDGE_CACHE` for Media CDN; `CLIENT_AUTH` for mTLS trust configs. See "Certificate scope" below. + +Scope is set per-cert at Add time (entry parameter, not store property) - see "Certificate scope" below. A single store can hold certificates at multiple scopes. Authentication uses Application Default Credentials - see "Service account credentials" below. @@ -81,7 +82,6 @@ After the discovery job runs, candidates appear in **Locations → Certificate S | **Application** | Optional, free-form. | | **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. | | **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. | -| **Certificate Scope** (custom) | Defaults to `DEFAULT`. Change only if this discovered store is going to back a non-default scope (`ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`) - Discovery does not know which scope your downstream load balancers need, so the operator sets it at approval time. Discovery emits one candidate per (project, location); if you need both `DEFAULT` and `ALL_REGIONS` certs in the same (project, location), reject this candidate and create two stores manually instead. See "Certificate scope" below. | | **Create Certificate Store If Missing** | **Check this.** Tells Command to create a new certificate store record from this candidate. Without it, the candidate sits in the discover tab with no store backing it. | | **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. | @@ -153,34 +153,42 @@ The orchestrator validates the alias against this rule **before** any API calls ### Certificate scope -GCP Certificate Manager attaches a `scope` to every certificate that determines which load balancer / service families can consume it. The `Scope` custom store property tells the orchestrator which value to pass to GCP on create. +GCP Certificate Manager attaches a `scope` to every certificate that determines which load balancer / service families can consume it. The orchestrator models this as a per-entry **entry parameter** (defined under `EntryParameters` on the store type), not a store property. That matches GCP's own data model - a single (project, location) container in GCP can legitimately hold certificates with different scopes. | Value | What it is for | |---|---| -| `DEFAULT` | Global external Application Load Balancers - the standard GCP load balancer for internet-facing traffic. This is the GCP default and the right answer for most stores. | +| `DEFAULT` | Global external Application Load Balancers - the standard GCP load balancer for internet-facing traffic. This is the GCP default and the right answer for most certs. | | `ALL_REGIONS` | Cross-region **internal** Application Load Balancers. Use this when the consuming load balancer is regional/internal and replicated across regions. | | `EDGE_CACHE` | Google Cloud Media CDN edge-cache certs. | | `CLIENT_AUTH` | Certificates used by mTLS trust configs, or server certificates that are authorized for client authentication. | +#### How operators set it + +On Management/Add (uploading a new cert into a store), Keyfactor Command renders a dropdown for "Certificate Scope" sourced from the store type's `EntryParameters` definition. Pick one of the four values; default is `DEFAULT`. The chosen value lands in `ManagementJobConfiguration.JobProperties["Scope"]` and the orchestrator passes it to GCP on the `certificates.create` call. + +On renewals / reenrollments, Keyfactor pre-fills the dropdown from the certificate's last-known inventory parameters, so the cert keeps its scope automatically across renewal cycles without operator action. + #### Immutability -The `scope` field is **create-only** in the GCP API. Once GCP creates a certificate with a given scope, that scope cannot be changed by any patch operation. The orchestrator's Management/Replace path uses `UpdateMask = "SelfManaged"`, so re-adding a certificate over an existing one preserves its original scope - even if the store's Scope property has changed. To migrate a certificate to a different scope, delete it (Management/Remove) and re-add it with the new scope. +The `scope` field is **create-only** in the GCP API. Once GCP creates a certificate with a given scope, that scope cannot be changed by any patch operation. The orchestrator's Management/Replace path uses `UpdateMask = "SelfManaged"`, so re-adding a certificate over an existing one preserves its original scope - even if the operator selects a different scope in the renewal dialog. To migrate a certificate to a different scope, delete it (Management/Remove) and re-add it with the new scope. + +#### Inventory persists scope per-cert -This is why the recommended deployment pattern is **one store per (project, location, scope) tuple**. The Scope property is store-wide, not per-cert. +The Inventory job reads the `scope` field off each `certificates.list` response and writes it into the cert's `CurrentInventoryItem.Parameters` dict. GCP omits the field from the response when the cert is at the default scope; the orchestrator normalizes null/blank to `DEFAULT` so Command's UI always shows a concrete value. After the first inventory run on a store, every cert displays its actual GCP scope in Command, and that value flows back through to Management jobs on subsequent renew/reenrollment operations. #### What happened before v1.2.1 -Prior to v1.2.1 the orchestrator hard-coded `Scope = "DEFAULT"` on every certificate it created. Customers who needed non-default scopes (typically `ALL_REGIONS` for cross-region internal ALBs) had to pre-create empty placeholder certificate resources in GCP via Terraform with `scope = "ALL_REGIONS"`, then point Keyfactor at the existing resource as a Replace target. The new property removes that workaround: a store with Scope = `ALL_REGIONS` will create new certificate resources directly at the right scope. +Prior to v1.2.1 the orchestrator hard-coded `Scope = "DEFAULT"` on every certificate it created and never read `scope` back during Inventory. Customers who needed non-default scopes (typically `ALL_REGIONS` for cross-region internal ALBs) had to pre-create empty placeholder certificate resources in GCP via Terraform with `scope = "ALL_REGIONS"`, then point Keyfactor at the existing resource as a Replace target. The new entry parameter removes that workaround. #### How the orchestrator validates the value -`JobBase.ResolveScope` runs as the `ResolveScope` flow step on every Management/Add. It trims and uppercases the configured value, then validates it against the set GCP accepts. An unsupported value (typo, lowercase letters that don't normalize to a valid token, anything outside the four allowed values) fails with `[FAIL] ResolveScope` and a clear message naming the four legal values. Blank or null resolves to `DEFAULT`. +`JobBase.ResolveScope` runs as the `ResolveScope` flow step on every Management/Add. It trims and uppercases the configured value, then validates it against the set GCP accepts. An unsupported value (typo, lowercase letters that don't normalize to a valid token, anything outside the four allowed values) fails with `[FAIL] ResolveScope` and a clear message naming the four legal values. Blank or null resolves to `DEFAULT`. The dropdown UI in Command should prevent invalid values from reaching the orchestrator in the first place; `ResolveScope` is defence-in-depth for direct-API submissions. #### Quick reference -- "Where do I see what scope a certificate ended up with?" → GCP's Certificate Manager Console, or `gcloud certificate-manager certificates describe --location= --project=`. The orchestrator's Inventory job does not surface scope today. +- "Where do I see what scope a certificate ended up with?" → Run inventory once - it surfaces in the cert's Parameters in Command. Also `gcloud certificate-manager certificates describe --location= --project=`. - "Can I change a certificate's scope?" → No. Delete and re-add. -- "I have one store with DEFAULT certs and I want to add an ALL_REGIONS cert" → Create a second store with the same (project, location) but Scope = `ALL_REGIONS`. +- "I have one store with DEFAULT certs and I want to add an ALL_REGIONS cert" → Add the new cert with Scope = `ALL_REGIONS` from the dropdown. Existing DEFAULT certs in the same store are unaffected; the field is per-entry, not store-wide. ### Architecture and logging diff --git a/integration-manifest.json b/integration-manifest.json index 5874753..3f22e87 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -58,21 +58,27 @@ "Required": false, "IsPAMEligible": false, "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." - }, + } + ], + "ClientMachineDescription": "Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path.", + "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", + "EntryParameters": [ { "Name": "Scope", "DisplayName": "Certificate Scope", - "Type": "String", + "Type": "MultipleChoice", "DependsOn": "", "DefaultValue": "DEFAULT", - "Required": false, - "IsPAMEligible": false, - "Description": "GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior." + "Options": "DEFAULT,ALL_REGIONS,EDGE_CACHE,CLIENT_AUTH", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + }, + "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: `DEFAULT` (global external Application Load Balancers), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. Inventory persists the existing scope back from GCP so renewals carry it forward automatically. A single store can hold certs at different scopes (the field is per-entry, not store-wide)." } - ], - "ClientMachineDescription": "Display label for grouping certificate stores in Keyfactor Command. Recommended value is the GCP Organization ID (e.g. `1005564431893`); the orchestrator does not parse a project ID out of this field. The actual GCP project + location are read from Store Path.", - "StorePathDescription": "Canonical GCP resource path in the form `projects/{projectId}/locations/{location}` (e.g. `projects/edgecerts/locations/global`). This is the single source of truth for which Certificate Manager instance the store targets. For Discovery-approved stores Keyfactor Command auto-fills this from the discovered candidate; for manually-created stores the operator types it directly.", - "EntryParameters": [] + ] } ] } diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 05672e0..0ee6ae3 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -57,18 +57,24 @@ curl -s -X POST "https://${KEYFACTOR_HOSTNAME}/${KEYFACTOR_API_PATH}/Certificate "Required": false, "IsPAMEligible": false, "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." - }, + } + ], + "EntryParameters": [ { "Name": "Scope", "DisplayName": "Certificate Scope", - "Type": "String", + "Type": "MultipleChoice", "DependsOn": "", "DefaultValue": "DEFAULT", - "Required": false, - "IsPAMEligible": false, - "Description": "GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior." + "Options": "DEFAULT,ALL_REGIONS,EDGE_CACHE,CLIENT_AUTH", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + }, + "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: DEFAULT, ALL_REGIONS, EDGE_CACHE, CLIENT_AUTH. Immutable in GCP - cannot be changed after create. Inventory persists the existing scope back from GCP so renewals carry it forward automatically." } - ], - "EntryParameters": [] + ] }' diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index 7cdfa2a..941a820 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -57,19 +57,25 @@ $Body = @' "Required": false, "IsPAMEligible": false, "Description": "**Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0." - }, + } + ], + "EntryParameters": [ { "Name": "Scope", "DisplayName": "Certificate Scope", - "Type": "String", + "Type": "MultipleChoice", "DependsOn": "", "DefaultValue": "DEFAULT", - "Required": false, - "IsPAMEligible": false, - "Description": "GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior." + "Options": "DEFAULT,ALL_REGIONS,EDGE_CACHE,CLIENT_AUTH", + "RequiredWhen": { + "HasPrivateKey": false, + "OnAdd": false, + "OnRemove": false, + "OnReenrollment": false + }, + "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: DEFAULT, ALL_REGIONS, EDGE_CACHE, CLIENT_AUTH. Immutable in GCP - cannot be changed after create. Inventory persists the existing scope back from GCP so renewals carry it forward automatically." } - ], - "EntryParameters": [] + ] } '@ From 6d2ad64c74765a91c8fe7cb1304e33be1b3ae8ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 18:45:41 +0000 Subject: [PATCH 26/31] docs: auto-generate README and documentation [skip ci] --- README.md | 48 ++++++++++------ ...ertMgr-custom-fields-store-type-dialog.svg | 17 ++---- ...Mgr-entry-parameters-store-type-dialog.svg | 55 +++++++++++++++++++ .../bash/curl_create_store_types.sh | 2 +- .../restmethod_create_store_types.ps1 | 2 +- 5 files changed, 92 insertions(+), 32 deletions(-) create mode 100644 docsource/images/GcpCertMgr-entry-parameters-store-type-dialog.svg diff --git a/README.md b/README.md index 36008e5..86f47f1 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ That single value carries both the GCP project and the location (region or `glob | **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command | | **Service Account Key File Path** (custom, *deprecated*) | v1.1 shape only. Leave blank for new stores - authentication uses Application Default Credentials. | Credential loader fallback; emits a deprecation warning when read | | **Location** (custom, *deprecated*) | v1.1 shape only. New stores leave it blank. Used as a fallback when Store Path is empty or `n/a`. | v1.1 fallback path; emits a deprecation warning when read | -| **Certificate Scope** (custom) | GCP `scope` to apply to every new certificate created in this store. One of `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. Blank → `DEFAULT`. Immutable on each cert once set in GCP - use one store per scope. See "Certificate scope" below. | Management/Add | +| **Certificate Scope** (*entry parameter*, not a store property) | GCP `scope` for an individual certificate entry. One of `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`. Set per-cert at Add time; Inventory persists the existing value back from GCP so renewals carry it forward. Defined under the store type's EntryParameters, not Properties. See "Certificate scope" below. | Management/Add (read from `JobProperties`), Inventory (write to per-entry `Parameters`) | ##### Location semantics: where the GCP region lives @@ -131,7 +131,8 @@ Set: - **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global` - **Service Account Key File Path**: leave blank (deprecated; ADC is used) - **Location**: leave blank -- **Certificate Scope**: `DEFAULT` for global external Application Load Balancers (the common case). Set to `ALL_REGIONS` if this store provisions certs for cross-region internal Application Load Balancers; `EDGE_CACHE` for Media CDN; `CLIENT_AUTH` for mTLS trust configs. See "Certificate scope" below. + +Scope is set per-cert at Add time (entry parameter, not store property) - see "Certificate scope" below. A single store can hold certificates at multiple scopes. Authentication uses Application Default Credentials - see "Service account credentials" below. @@ -146,7 +147,6 @@ After the discovery job runs, candidates appear in **Locations → Certificate S | **Application** | Optional, free-form. | | **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. | | **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. | -| **Certificate Scope** (custom) | Defaults to `DEFAULT`. Change only if this discovered store is going to back a non-default scope (`ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`) - Discovery does not know which scope your downstream load balancers need, so the operator sets it at approval time. Discovery emits one candidate per (project, location); if you need both `DEFAULT` and `ALL_REGIONS` certs in the same (project, location), reject this candidate and create two stores manually instead. See "Certificate scope" below. | | **Create Certificate Store If Missing** | **Check this.** Tells Command to create a new certificate store record from this candidate. Without it, the candidate sits in the discover tab with no store backing it. | | **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. | @@ -218,34 +218,42 @@ The orchestrator validates the alias against this rule **before** any API calls #### Certificate scope -GCP Certificate Manager attaches a `scope` to every certificate that determines which load balancer / service families can consume it. The `Scope` custom store property tells the orchestrator which value to pass to GCP on create. +GCP Certificate Manager attaches a `scope` to every certificate that determines which load balancer / service families can consume it. The orchestrator models this as a per-entry **entry parameter** (defined under `EntryParameters` on the store type), not a store property. That matches GCP's own data model - a single (project, location) container in GCP can legitimately hold certificates with different scopes. | Value | What it is for | |---|---| -| `DEFAULT` | Global external Application Load Balancers - the standard GCP load balancer for internet-facing traffic. This is the GCP default and the right answer for most stores. | +| `DEFAULT` | Global external Application Load Balancers - the standard GCP load balancer for internet-facing traffic. This is the GCP default and the right answer for most certs. | | `ALL_REGIONS` | Cross-region **internal** Application Load Balancers. Use this when the consuming load balancer is regional/internal and replicated across regions. | | `EDGE_CACHE` | Google Cloud Media CDN edge-cache certs. | | `CLIENT_AUTH` | Certificates used by mTLS trust configs, or server certificates that are authorized for client authentication. | +##### How operators set it + +On Management/Add (uploading a new cert into a store), Keyfactor Command renders a dropdown for "Certificate Scope" sourced from the store type's `EntryParameters` definition. Pick one of the four values; default is `DEFAULT`. The chosen value lands in `ManagementJobConfiguration.JobProperties["Scope"]` and the orchestrator passes it to GCP on the `certificates.create` call. + +On renewals / reenrollments, Keyfactor pre-fills the dropdown from the certificate's last-known inventory parameters, so the cert keeps its scope automatically across renewal cycles without operator action. + ##### Immutability -The `scope` field is **create-only** in the GCP API. Once GCP creates a certificate with a given scope, that scope cannot be changed by any patch operation. The orchestrator's Management/Replace path uses `UpdateMask = "SelfManaged"`, so re-adding a certificate over an existing one preserves its original scope - even if the store's Scope property has changed. To migrate a certificate to a different scope, delete it (Management/Remove) and re-add it with the new scope. +The `scope` field is **create-only** in the GCP API. Once GCP creates a certificate with a given scope, that scope cannot be changed by any patch operation. The orchestrator's Management/Replace path uses `UpdateMask = "SelfManaged"`, so re-adding a certificate over an existing one preserves its original scope - even if the operator selects a different scope in the renewal dialog. To migrate a certificate to a different scope, delete it (Management/Remove) and re-add it with the new scope. -This is why the recommended deployment pattern is **one store per (project, location, scope) tuple**. The Scope property is store-wide, not per-cert. +##### Inventory persists scope per-cert + +The Inventory job reads the `scope` field off each `certificates.list` response and writes it into the cert's `CurrentInventoryItem.Parameters` dict. GCP omits the field from the response when the cert is at the default scope; the orchestrator normalizes null/blank to `DEFAULT` so Command's UI always shows a concrete value. After the first inventory run on a store, every cert displays its actual GCP scope in Command, and that value flows back through to Management jobs on subsequent renew/reenrollment operations. ##### What happened before v1.2.1 -Prior to v1.2.1 the orchestrator hard-coded `Scope = "DEFAULT"` on every certificate it created. Customers who needed non-default scopes (typically `ALL_REGIONS` for cross-region internal ALBs) had to pre-create empty placeholder certificate resources in GCP via Terraform with `scope = "ALL_REGIONS"`, then point Keyfactor at the existing resource as a Replace target. The new property removes that workaround: a store with Scope = `ALL_REGIONS` will create new certificate resources directly at the right scope. +Prior to v1.2.1 the orchestrator hard-coded `Scope = "DEFAULT"` on every certificate it created and never read `scope` back during Inventory. Customers who needed non-default scopes (typically `ALL_REGIONS` for cross-region internal ALBs) had to pre-create empty placeholder certificate resources in GCP via Terraform with `scope = "ALL_REGIONS"`, then point Keyfactor at the existing resource as a Replace target. The new entry parameter removes that workaround. ##### How the orchestrator validates the value -`JobBase.ResolveScope` runs as the `ResolveScope` flow step on every Management/Add. It trims and uppercases the configured value, then validates it against the set GCP accepts. An unsupported value (typo, lowercase letters that don't normalize to a valid token, anything outside the four allowed values) fails with `[FAIL] ResolveScope` and a clear message naming the four legal values. Blank or null resolves to `DEFAULT`. +`JobBase.ResolveScope` runs as the `ResolveScope` flow step on every Management/Add. It trims and uppercases the configured value, then validates it against the set GCP accepts. An unsupported value (typo, lowercase letters that don't normalize to a valid token, anything outside the four allowed values) fails with `[FAIL] ResolveScope` and a clear message naming the four legal values. Blank or null resolves to `DEFAULT`. The dropdown UI in Command should prevent invalid values from reaching the orchestrator in the first place; `ResolveScope` is defence-in-depth for direct-API submissions. ##### Quick reference -- "Where do I see what scope a certificate ended up with?" → GCP's Certificate Manager Console, or `gcloud certificate-manager certificates describe --location= --project=`. The orchestrator's Inventory job does not surface scope today. +- "Where do I see what scope a certificate ended up with?" → Run inventory once - it surfaces in the cert's Parameters in Command. Also `gcloud certificate-manager certificates describe --location= --project=`. - "Can I change a certificate's scope?" → No. Delete and re-add. -- "I have one store with DEFAULT certs and I want to add an ALL_REGIONS cert" → Create a second store with the same (project, location) but Scope = `ALL_REGIONS`. +- "I have one store with DEFAULT certs and I want to add an ALL_REGIONS cert" → Add the new cert with Scope = `ALL_REGIONS` from the dropdown. Existing DEFAULT certs in the same store are unaffected; the field is per-entry, not store-wide. #### Architecture and logging @@ -379,7 +387,6 @@ the Keyfactor Command Portal | ---- | ------------ | ---- | --------------------- | -------- | ----------- | | Location | Location (deprecated) | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | String | | 🔲 Unchecked | | ServiceAccountKey | Service Account Key File Path (deprecated) | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | String | | 🔲 Unchecked | - | Scope | Certificate Scope | GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. | String | DEFAULT | 🔲 Unchecked | The Custom Fields tab should look like this: @@ -397,10 +404,19 @@ the Keyfactor Command Portal ![GcpCertMgr Custom Field - ServiceAccountKey](docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg) - ###### Certificate Scope - GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. + ##### Entry Parameters Tab + + | Name | Display Name | Description | Type | Default Value | Entry has a private key | Adding an entry | Removing an entry | Reenrolling an entry | + | ---- | ------------ | ---- | ------------- | ----------------------- | ---------------- | ----------------- | ------------------- | ----------- | + | Scope | Certificate Scope | GCP Certificate Manager `scope` for this certificate entry. Allowed: `DEFAULT` (global external Application Load Balancers), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. Inventory persists the existing scope back from GCP so renewals carry it forward automatically. A single store can hold certs at different scopes (the field is per-entry, not store-wide). | MultipleChoice | DEFAULT | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | 🔲 Unchecked | + + The Entry Parameters tab should look like this: + + ![GcpCertMgr Entry Parameters Tab](docsource/images/GcpCertMgr-entry-parameters-store-type-dialog.svg) + ##### Certificate Scope + GCP Certificate Manager `scope` for this certificate entry. Allowed: `DEFAULT` (global external Application Load Balancers), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. Inventory persists the existing scope back from GCP so renewals carry it forward automatically. A single store can hold certs at different scopes (the field is per-entry, not store-wide). - ![GcpCertMgr Custom Field - Scope](docsource/images/GcpCertMgr-custom-field-Scope-dialog.svg) + ![GcpCertMgr Entry Parameter - Scope](docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope.svg) @@ -465,7 +481,6 @@ the Keyfactor Command Portal | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | | Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | | ServiceAccountKey | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | - | Scope | GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. | @@ -491,7 +506,6 @@ the Keyfactor Command Portal | Orchestrator | Select an approved orchestrator capable of managing `GcpCertMgr` certificates. Specifically, one with the `GcpCertMgr` capability. | | Properties.Location | **Deprecated in v1.2.** The GCP location is parsed from Store Path. Leave blank for new stores. v1.1-shape stores (where Store Path is blank or `n/a`) still read this value as a fallback; expect a deprecation warning in the orchestrator log when that path is used. | | Properties.ServiceAccountKey | **Deprecated in v1.2.** Leave blank. Authenticate via Application Default Credentials instead (set `GOOGLE_APPLICATION_CREDENTIALS` as a machine-level environment variable on the orchestrator host pointing at the JSON key, or run on a GCE VM / GKE pod with workload identity). The Discovery job has no way to surface this custom property in Keyfactor Command's discovery-job UI, so ADC is the only mechanism that works uniformly across all four job types. v1.1 stores that have this populated continue to work via a deprecation-logged fallback; the field is scheduled for removal in v2.0. | - | Properties.Scope | GCP Certificate Manager `scope` value applied to every new certificate created in this store. Allowed: `DEFAULT` (global external Application Load Balancers - the GCP default), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. To use a different scope, delete and re-add the certificate. Pick the scope that matches the load balancer / service this store provisions certs for, and use one store per scope. Leave blank or set to `DEFAULT` for the legacy behavior. | 3. **Import the CSV file to create the certificate stores** diff --git a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg index eb84cb6..cc4b462 100644 --- a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg +++ b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg @@ -1,5 +1,5 @@  - + - + Edit Certificate Store Type @@ -24,7 +24,7 @@ Entry Parameters - + @@ -33,7 +33,7 @@ EDIT DELETE - Total: 3 + Total: 2 Display Name @@ -59,13 +59,4 @@ Service Account Key File Path (deprecated) String - - - - - - - Certificate Scope - String - DEFAULT \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog.svg b/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog.svg new file mode 100644 index 0000000..2a8bb8a --- /dev/null +++ b/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog.svg @@ -0,0 +1,55 @@ + + + + + + + + + Edit Certificate Store Type + + + + Basic + Advanced + Custom Fields + Entry Parameters + + + + + + + ADD + + EDIT + + DELETE + Total: 1 + + + Display Name + Type + Default Value + + + + + + + + + + + Certificate Scope + MultipleChoice + DEFAULT + \ No newline at end of file diff --git a/scripts/store_types/bash/curl_create_store_types.sh b/scripts/store_types/bash/curl_create_store_types.sh index 0ee6ae3..40ca05c 100755 --- a/scripts/store_types/bash/curl_create_store_types.sh +++ b/scripts/store_types/bash/curl_create_store_types.sh @@ -73,7 +73,7 @@ curl -s -X POST "https://${KEYFACTOR_HOSTNAME}/${KEYFACTOR_API_PATH}/Certificate "OnRemove": false, "OnReenrollment": false }, - "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: DEFAULT, ALL_REGIONS, EDGE_CACHE, CLIENT_AUTH. Immutable in GCP - cannot be changed after create. Inventory persists the existing scope back from GCP so renewals carry it forward automatically." + "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: `DEFAULT` (global external Application Load Balancers), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. Inventory persists the existing scope back from GCP so renewals carry it forward automatically. A single store can hold certs at different scopes (the field is per-entry, not store-wide)." } ] }' diff --git a/scripts/store_types/powershell/restmethod_create_store_types.ps1 b/scripts/store_types/powershell/restmethod_create_store_types.ps1 index 941a820..c80cbdd 100644 --- a/scripts/store_types/powershell/restmethod_create_store_types.ps1 +++ b/scripts/store_types/powershell/restmethod_create_store_types.ps1 @@ -73,7 +73,7 @@ $Body = @' "OnRemove": false, "OnReenrollment": false }, - "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: DEFAULT, ALL_REGIONS, EDGE_CACHE, CLIENT_AUTH. Immutable in GCP - cannot be changed after create. Inventory persists the existing scope back from GCP so renewals carry it forward automatically." + "Description": "GCP Certificate Manager `scope` for this certificate entry. Allowed: `DEFAULT` (global external Application Load Balancers), `ALL_REGIONS` (cross-region internal Application Load Balancers), `EDGE_CACHE` (Media CDN), `CLIENT_AUTH` (mTLS trust configs / authorized client server certs). **Immutable in GCP** - once a certificate is created with a given scope, GCP refuses to change it. Inventory persists the existing scope back from GCP so renewals carry it forward automatically. A single store can hold certs at different scopes (the field is per-entry, not store-wide)." } ] } From 8c952beaebc04623ec5a50a95d50ffdbb627b3d6 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 11 May 2026 14:59:43 -0400 Subject: [PATCH 27/31] chore(build): drop unused RestSharp dep, add net10.0 TFM RestSharp 107.2.1 was a dead PackageReference - no using directive or type reference exists anywhere in the source. Removing it eliminates the GHSA-4rr6-2v9v-wcpc moderate-severity NU1902 warning without needing a runtime upgrade. Add net10.0 alongside net6.0 and net8.0 so the orchestrator can run on .NET 10 hosts; build verified locally across all three TFMs. Co-Authored-By: Claude Opus 4.7 (1M context) --- GcpCertManager/GcpCertManager.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/GcpCertManager/GcpCertManager.csproj b/GcpCertManager/GcpCertManager.csproj index 1e0f43e..0aaca23 100644 --- a/GcpCertManager/GcpCertManager.csproj +++ b/GcpCertManager/GcpCertManager.csproj @@ -2,7 +2,7 @@ true - net6.0;net8.0 + net6.0;net8.0;net10.0 true disable @@ -26,7 +26,6 @@ - From ef994b213bcd2e2d2dd8c7db11fbc19c958c364c Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 11 May 2026 15:02:16 -0400 Subject: [PATCH 28/31] chore(build): drop unused System.Management.Automation dep System.Management.Automation 7.0.5 was dead weight - zero usages in the source, and the manifest already declares "PowerShell": false. The package also drove the NETSDK1206 RID warning (legacy win10-x64 etc. RIDs in Microsoft.Management.Infrastructure.Runtime.Win) on net8/net10 builds. Note: removing SMA does *not* eliminate the System.Drawing.Common critical-severity warning (NU1904 / GHSA-rxg9-xrhp-64gj). That dep is pulled in transitively by Keyfactor.Logging 1.1.1 via System.DirectoryServices 5.0.0 -> System.Security.Permissions 5.0.0 -> System.Windows.Extensions 5.0.0 -> System.Drawing.Common 5.0.0. The fix path is either an explicit PackageReference override or a Keyfactor.Logging upgrade upstream - both out of scope for this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- GcpCertManager/GcpCertManager.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/GcpCertManager/GcpCertManager.csproj b/GcpCertManager/GcpCertManager.csproj index 0aaca23..fe62910 100644 --- a/GcpCertManager/GcpCertManager.csproj +++ b/GcpCertManager/GcpCertManager.csproj @@ -26,7 +26,6 @@ - Always From c5669e6938d9590b2cd58bdbdc3918022c4bdabf Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Mon, 11 May 2026 15:03:33 -0400 Subject: [PATCH 29/31] chore(deps): override transitive System.Drawing.Common (GHSA-rxg9-xrhp-64gj) Pins System.Drawing.Common to 8.0.10 directly so the transitive 5.0.0 pulled in by Keyfactor.Logging -> System.DirectoryServices -> System.Security.Permissions -> System.Windows.Extensions cannot resolve. The 5.0.0 version has a critical-severity remote-code-execution CVE (GHSA-rxg9-xrhp-64gj); 8.0.10 carries the patch. Direct PackageReference outranks transitive resolution, so no other csproj changes are needed. Override should be removed once Keyfactor publishes a Keyfactor.Logging release that bumps System.DirectoryServices to 8.x upstream. Co-Authored-By: Claude Opus 4.7 (1M context) --- GcpCertManager/GcpCertManager.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/GcpCertManager/GcpCertManager.csproj b/GcpCertManager/GcpCertManager.csproj index fe62910..e28a001 100644 --- a/GcpCertManager/GcpCertManager.csproj +++ b/GcpCertManager/GcpCertManager.csproj @@ -26,6 +26,13 @@ + + Always From 57da1515cdc9d7b02b24603223154f50d3cf993b Mon Sep 17 00:00:00 2001 From: Brian Hill <76450501+bhillkeyfactor@users.noreply.github.com> Date: Mon, 11 May 2026 15:32:41 -0400 Subject: [PATCH 30/31] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db4df54..103f041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v1.2.0 - unreleased +v1.2.0 - Added Discovery job (`CertStores.GcpCertMgr.Discovery`) that enumerates all GCP projects accessible to the orchestrator's service account and emits one candidate store path per (project, location) pair in canonical From 9ecaba9d84fc47fce540e299105748974fe9569c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 May 2026 19:33:15 +0000 Subject: [PATCH 31/31] docs: auto-generate README and documentation [skip ci] --- README.md | 1 + ...cpCertMgr-custom-field-Location-dialog.svg | 49 +++++++++++++ ...eld-Location-validation-options-dialog.svg | 39 +++++++++++ ...-custom-field-ServiceAccountKey-dialog.svg | 49 +++++++++++++ ...ceAccountKey-validation-options-dialog.svg | 39 +++++++++++ ...ertMgr-custom-fields-store-type-dialog.svg | 2 +- ...e-type-dialog-Scope-validation-options.svg | 68 +++++++++++++++++++ ...try-parameters-store-type-dialog-Scope.svg | 51 ++++++++++++++ 8 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 docsource/images/GcpCertMgr-custom-field-Location-dialog.svg create mode 100644 docsource/images/GcpCertMgr-custom-field-Location-validation-options-dialog.svg create mode 100644 docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg create mode 100644 docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.svg create mode 100644 docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope-validation-options.svg create mode 100644 docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope.svg diff --git a/README.md b/README.md index 86f47f1..2796be2 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,7 @@ the Keyfactor Command Portal + ## Installation 1. **Download the latest Google Cloud Provider Certificate Manager Universal Orchestrator extension from GitHub.** diff --git a/docsource/images/GcpCertMgr-custom-field-Location-dialog.svg b/docsource/images/GcpCertMgr-custom-field-Location-dialog.svg new file mode 100644 index 0000000..a3cf7e6 --- /dev/null +++ b/docsource/images/GcpCertMgr-custom-field-Location-dialog.svg @@ -0,0 +1,49 @@ + + + + + + + + + Edit Custom Field + × + + + + Basic Information + + Validation Options + + Name + + Location + Display Name + + Location (deprecated) + Type + + String + + Default Value + + + Depends On + + + Service Account Key File Path (deprecated) + + + + CANCEL + + SAVE + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-custom-field-Location-validation-options-dialog.svg b/docsource/images/GcpCertMgr-custom-field-Location-validation-options-dialog.svg new file mode 100644 index 0000000..22f8bbd --- /dev/null +++ b/docsource/images/GcpCertMgr-custom-field-Location-validation-options-dialog.svg @@ -0,0 +1,39 @@ + + + + + + + + + Edit Custom Field + × + + + + Basic Information + Validation Options + + + Creating a certificate store + + + Optional + + Required + + Hidden + + + CANCEL + + SAVE + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg b/docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg new file mode 100644 index 0000000..19d5e4f --- /dev/null +++ b/docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-dialog.svg @@ -0,0 +1,49 @@ + + + + + + + + + Edit Custom Field + × + + + + Basic Information + + Validation Options + + Name + + ServiceAccountKey + Display Name + + Service Account Key File Path (deprecated) + Type + + String + + Default Value + + + Depends On + + + Location (deprecated) + + + + CANCEL + + SAVE + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.svg b/docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.svg new file mode 100644 index 0000000..22f8bbd --- /dev/null +++ b/docsource/images/GcpCertMgr-custom-field-ServiceAccountKey-validation-options-dialog.svg @@ -0,0 +1,39 @@ + + + + + + + + + Edit Custom Field + × + + + + Basic Information + Validation Options + + + Creating a certificate store + + + Optional + + Required + + Hidden + + + CANCEL + + SAVE + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg index cc4b462..8bbc791 100644 --- a/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg +++ b/docsource/images/GcpCertMgr-custom-fields-store-type-dialog.svg @@ -57,6 +57,6 @@ - Service Account Key File Path (deprecated) + Service Account Key File Path (dep... String \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope-validation-options.svg b/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope-validation-options.svg new file mode 100644 index 0000000..ddaa9ab --- /dev/null +++ b/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope-validation-options.svg @@ -0,0 +1,68 @@ + + + + + + + + + Edit Entry Parameter + × + + + + Basic Information + Validation Options + + + Entry has a private key + + + Optional + + Required + + Ignore + + + + Job Types + + Adding an entry + + + Optional + + Required + + Hidden + Removing an entry + + + Optional + + Required + + Hidden + On Device Key Generation + + + Optional + + Required + + Hidden + + + CANCEL + + SAVE + \ No newline at end of file diff --git a/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope.svg b/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope.svg new file mode 100644 index 0000000..625957a --- /dev/null +++ b/docsource/images/GcpCertMgr-entry-parameters-store-type-dialog-Scope.svg @@ -0,0 +1,51 @@ + + + + + + + + + Edit Entry Parameter + × + + + + Basic Information + + Validation Options + + Name + + Scope + Display Name + + Certificate Scope + Type + + MultipleChoice + + Default Value + + DEFAULT + Multiple Choice Options + + DEFAULT,ALL_REGIONS,EDGE_CACHE,CLIENT_AUTH + Depends On + + + + + + CANCEL + + SAVE + \ No newline at end of file