Skip to content

Commit 22cea0a

Browse files
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) <noreply@anthropic.com>
1 parent 387cba5 commit 22cea0a

6 files changed

Lines changed: 125 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,29 @@ v1.2.0 - unreleased
110110
replacing the previous behavior of failing 700ms later with a wall-of-JSON
111111
HTTP 400 from GCP. See `JobBase.ValidateGcpCertificateId`.
112112

113+
### Added (Scope custom property)
114+
- Added a new `Scope` custom store property that is honored by Management/Add.
115+
Previous releases hard-coded `Scope = "DEFAULT"` on every certificate created
116+
in GCP, which made the orchestrator unusable for environments that depend on
117+
cross-region internal Application Load Balancers (`ALL_REGIONS`), Media CDN
118+
(`EDGE_CACHE`), or mTLS trust-config / authorized-client server certs
119+
(`CLIENT_AUTH`). Those customers had to pre-create empty placeholder
120+
certificates in GCP via Terraform and then attach Keyfactor to the existing
121+
shell. The new property lets a single store create certificates at any of
122+
the four allowed scopes natively.
123+
- Allowed values: `DEFAULT`, `ALL_REGIONS`, `EDGE_CACHE`, `CLIENT_AUTH`.
124+
Values are case-normalized (uppercased and trimmed) before validation.
125+
Anything else fails the `ResolveScope` flow step before any API call.
126+
- Default is `DEFAULT`. Blank also resolves to `DEFAULT`, so existing v1.1
127+
and v1.2-pre-Scope stores keep working with no operator action.
128+
- GCP's Scope field is **create-only and immutable**. Replace (overwrite)
129+
paths do not change scope: the `Patch` call's `UpdateMask` is `SelfManaged`,
130+
so GCP only updates the cert/key bytes. To change a certificate's scope,
131+
delete the certificate and re-add it.
132+
- Recommended deployment pattern is one store per (project, location, scope)
133+
tuple. Mixing scopes inside a single store is awkward because the property
134+
is store-wide, not per-cert.
135+
113136
### Backwards compatibility
114137
- v1.1-shape stores (Store Path blank or `n/a`, Client Machine = Project ID,
115138
Location custom property = region) continue to work via a deprecation-logged

GcpCertManager/Jobs/JobBase.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,41 @@ protected static void ValidateGcpCertificateId(string alias)
212212
}
213213
}
214214

215+
// GCP Certificate Manager's create-only Scope values. Anything else produces an
216+
// HTTP 400 INVALID_ARGUMENT from the create call, so validate up front and reject
217+
// typos with a clear message instead of letting them reach the API.
218+
// Source: https://cloud.google.com/certificate-manager/docs/reference/rest/v1/projects.locations.certificates#Certificate.Scope
219+
private static readonly System.Collections.Generic.HashSet<string> AllowedScopes =
220+
new System.Collections.Generic.HashSet<string>(StringComparer.Ordinal)
221+
{
222+
"DEFAULT",
223+
"EDGE_CACHE",
224+
"ALL_REGIONS",
225+
"CLIENT_AUTH"
226+
};
227+
228+
/// <summary>
229+
/// Normalize the per-store <c>Scope</c> custom property to a value GCP will
230+
/// accept. Blank → <c>DEFAULT</c> (matches pre-v1.2 behavior, so unmigrated
231+
/// stores keep working). Other values are uppercased and validated against the
232+
/// set GCP allows; an unknown value throws <see cref="ArgumentException"/> so
233+
/// the operator sees a clear failure before any API call.
234+
/// </summary>
235+
protected static string ResolveScope(string configuredScope)
236+
{
237+
if (string.IsNullOrWhiteSpace(configuredScope)) return "DEFAULT";
238+
239+
var normalized = configuredScope.Trim().ToUpperInvariant();
240+
if (!AllowedScopes.Contains(normalized))
241+
{
242+
throw new ArgumentException(
243+
$"Unsupported Scope '{configuredScope}'. GCP Certificate Manager accepts only " +
244+
"DEFAULT, EDGE_CACHE, ALL_REGIONS, or CLIENT_AUTH. Edit the store's Scope custom property and retry.",
245+
nameof(configuredScope));
246+
}
247+
return normalized;
248+
}
249+
215250
private static string SuggestValidAlias(string alias)
216251
{
217252
if (string.IsNullOrEmpty(alias)) return "cert";

GcpCertManager/Jobs/Management.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge
9090
Logger.LogTrace(" Location: {Location}", storeProperties.Location);
9191
Logger.LogTrace(" Project Id: {ProjectId}", storeProperties.ProjectId);
9292
Logger.LogTrace(" Service Account Key Path: {ServiceAccountKey}", storeProperties.ServiceAccountKey);
93+
Logger.LogTrace(" Scope: {Scope}", storeProperties.Scope);
9394

9495
CertificateManagerService svc = null;
9596
flow.Step("GetGoogleCredentials", () =>
@@ -108,7 +109,7 @@ private JobResult PerformManagement(ManagementJobConfiguration config, FlowLogge
108109
{
109110
case CertStoreOperationType.Add:
110111
flow.Branch("Add");
111-
try { return PerformAddition(svc, config, storePath, flow); }
112+
try { return PerformAddition(svc, config, storeProperties, storePath, flow); }
112113
finally { flow.EndBranch(); }
113114
case CertStoreOperationType.Remove:
114115
flow.Branch("Remove");
@@ -129,14 +130,20 @@ private JobResult PerformRemoval(CertificateManagerService svc, ManagementJobCon
129130
}
130131

131132
private JobResult PerformAddition(CertificateManagerService svc, ManagementJobConfiguration config,
132-
string storePath, FlowLogger flow)
133+
StoreProperties storeProperties, string storePath, FlowLogger flow)
133134
{
134135
// Validate the alias before any API calls or PFX parsing - GCP rejects
135136
// non-conforming IDs with HTTP 400 after we've already done the expensive
136137
// work, so failing fast saves both time and a confusing error message.
137138
flow.Step("ValidateAlias", () => ValidateGcpCertificateId(CertificateName),
138139
$"alias={CertificateName}");
139140

141+
// Resolve the per-store Scope custom property up front. GCP's Scope field is
142+
// create-only, so the value has to be correct before we touch the API.
143+
string resolvedScope = null;
144+
flow.Step("ResolveScope", () => resolvedScope = ResolveScope(storeProperties.Scope),
145+
$"configured={storeProperties.Scope ?? "<blank>"}");
146+
140147
var duplicate = false;
141148
flow.Step("CheckForDuplicate", () => duplicate = CheckForDuplicate(storePath, CertificateName, svc),
142149
$"alias={CertificateName}");
@@ -219,15 +226,17 @@ private JobResult PerformAddition(CertificateManagerService svc, ManagementJobCo
219226

220227
// Build the GCP certificate object. Don't serialize+log; that would leak the
221228
// private key into trace logs.
229+
//
230+
// Scope is sourced from the per-store custom property and is honored only on
231+
// Add. On Replace the patch's UpdateMask is "SelfManaged", so GCP ignores
232+
// every other field on the body (including Scope) - which is correct, since
233+
// GCP rejects scope changes anyway.
222234
var gCertificate = new Certificate
223235
{
224236
SelfManaged = new SelfManagedCertificate { PemCertificate = pubCertPem, PemPrivateKey = privateKeyString },
225237
Name = CertificateName,
226238
Description = CertificateName,
227-
// Scope does not come back in inventory, so hard-code it. Customers
228-
// running edge-cache stores will need to override this in a future
229-
// store-property if/when that scope becomes used.
230-
Scope = "DEFAULT"
239+
Scope = resolvedScope
231240
};
232241

233242
if (duplicate && config.Overwrite)

GcpCertManager/StoreProperties.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,13 @@ internal class StoreProperties
1717
public string ProjectId { get; set; }
1818

1919
public string ServiceAccountKey { get; set; }
20+
21+
// GCP Certificate Manager's Scope field is create-only and immutable. Blank
22+
// means "let JobBase.ResolveScope pick DEFAULT" so existing stores upgrade
23+
// without operator intervention. Non-default scopes (ALL_REGIONS for
24+
// cross-region internal ALBs, EDGE_CACHE for Media CDN, CLIENT_AUTH for
25+
// mTLS trust configs) must be set per-store before the first Add.
26+
[DefaultValue("")]
27+
public string Scope { get; set; }
2028
}
2129
}

docsource/gcpcertmgr.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ That single value carries both the GCP project and the location (region or `glob
2020
| **Client Machine** | Display label only. Recommended: GCP Organization ID (e.g. `1005564431893`). Not parsed. | UI grouping in Command |
2121
| **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 |
2222
| **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 |
23+
| **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 |
2324

2425
#### Location semantics: where the GCP region lives
2526

@@ -65,6 +66,7 @@ Set:
6566
- **Store Path**: `projects/{projectId}/locations/{location}` - e.g. `projects/edgecerts/locations/global`
6667
- **Service Account Key File Path**: leave blank (deprecated; ADC is used)
6768
- **Location**: leave blank
69+
- **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.
6870

6971
Authentication uses Application Default Credentials - see "Service account credentials" below.
7072

@@ -79,6 +81,7 @@ After the discovery job runs, candidates appear in **Locations → Certificate S
7981
| **Application** | Optional, free-form. |
8082
| **Location** (custom) | Leave blank. Deprecated v1.1 field; the location is parsed from Store Path. |
8183
| **Service Account Key File Path** (custom) | Leave blank. Deprecated v1.1 field; authentication uses Application Default Credentials. |
84+
| **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. |
8285
| **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. |
8386
| **Inventory Schedule** | Pick a cadence (e.g. Daily) for the inventory job to run after the store is created. |
8487

@@ -148,6 +151,37 @@ GCP Certificate Manager constrains certificate resource IDs to a strict shape:
148151

149152
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.
150153

154+
### Certificate scope
155+
156+
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.
157+
158+
| Value | What it is for |
159+
|---|---|
160+
| `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. |
161+
| `ALL_REGIONS` | Cross-region **internal** Application Load Balancers. Use this when the consuming load balancer is regional/internal and replicated across regions. |
162+
| `EDGE_CACHE` | Google Cloud Media CDN edge-cache certs. |
163+
| `CLIENT_AUTH` | Certificates used by mTLS trust configs, or server certificates that are authorized for client authentication. |
164+
165+
#### Immutability
166+
167+
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.
168+
169+
This is why the recommended deployment pattern is **one store per (project, location, scope) tuple**. The Scope property is store-wide, not per-cert.
170+
171+
#### What happened before v1.2.1
172+
173+
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.
174+
175+
#### How the orchestrator validates the value
176+
177+
`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`.
178+
179+
#### Quick reference
180+
181+
- "Where do I see what scope a certificate ended up with?" → GCP's Certificate Manager Console, or `gcloud certificate-manager certificates describe <name> --location=<loc> --project=<proj>`. The orchestrator's Inventory job does not surface scope today.
182+
- "Can I change a certificate's scope?" → No. Delete and re-add.
183+
- "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`.
184+
151185
### Architecture and logging
152186

153187
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.

integration-manifest.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@
5858
"Required": false,
5959
"IsPAMEligible": false,
6060
"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."
61+
},
62+
{
63+
"Name": "Scope",
64+
"DisplayName": "Certificate Scope",
65+
"Type": "String",
66+
"DependsOn": "",
67+
"DefaultValue": "DEFAULT",
68+
"Required": false,
69+
"IsPAMEligible": false,
70+
"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."
6171
}
6272
],
6373
"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.",

0 commit comments

Comments
 (0)