diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82b3f19..349385b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,11 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased
+## 2026.07.31.0045
+
+- Build produced from commit d47a5af6b3a6.
+
+## Unreleased (carried forward)
+
## 2026.07.30.2350
- Build produced from commit 67cf0abac2dd.
-## Unreleased (carried forward)
+## Unreleased (carried forward)
## 2026.07.30.2344
@@ -30,6 +36,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased (carried forward)
+### Added (certificate metadata)
+
+- `Request-InfisicalCertificate -Metadata` attaches key/value pairs to the issued or reused certificate, accepting any `IDictionary` (hashtable, `[Ordered]`, generic dictionary).
+- **Only the supplied keys are reconciled.** Infisical's `PATCH /certificates/{id}` replaces a certificate's metadata wholesale, so the module reads the current set, merges the supplied keys over it, and writes back the union; keys it was not asked about survive. When nothing would change, no request is sent.
+- Reconciliation runs on the reuse path as well, so a metadata change lands without forcing reissuance.
+- Values are flattened to strings, keys trimmed and compared case-insensitively, blank keys dropped.
+- `InfisicalCertificate.Metadata` and `InfisicalCertificateResult.Metadata` expose a certificate's metadata as a case-insensitive dictionary.
+- A metadata failure is reported as a warning rather than failing an issuance that otherwise succeeded, since by that point the certificate exists and may already be installed.
+
+### Added (tooling)
+
+- `Scripts/Initialize-InfisicalCertManagerEnvironment.ps1` seeds a Certificate Manager project, CA hierarchy, certificate policies, and API enrollment profiles from one declarative configuration block, taking only a base URI, client id, and client secret. Idempotent and `-WhatIf`-aware. Seeds an RSA hierarchy for SCCM/MECM server and client authentication, and an ECDSA P-384 hierarchy for server/client authentication and code signing.
+
+### Fixed (documentation)
+
+- Corrected the claim that a newly created certificate authority has direct issuance enabled. The creation service passes `enableDirectIssuance: false` explicitly, so **every** CA created through the API or UI has it disabled regardless of the database default, and nothing can enable it afterwards. Certificate profiles remain the only path that ignores the flag.
+
### Fixed (certificate reuse)
- **Switching certificate profiles reused the old certificate.** The reuse check matched on common name alone, so requesting from a client-authentication profile on a host already holding a server-authentication certificate for the same name returned the existing certificate — with the wrong extended key usages. The reuse search is now scoped by `-CertificateProfileId` or `-CertificateAuthorityId`.
diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1
index 727a891..7cddb2a 100644
--- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1
+++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
- ModuleVersion = '2026.07.30.2350'
+ ModuleVersion = '2026.07.31.0045'
GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
Author = 'Grace Solutions'
CompanyName = 'Grace Solutions'
@@ -74,7 +74,7 @@
LicenseUri = 'https://www.gnu.org/licenses/agpl-3.0.html'
ProjectUri = 'https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI'
ReleaseNotes = 'See CHANGELOG.md in the project repository for release history.'
- CommitHash = '67cf0abac2dd'
+ CommitHash = 'd47a5af6b3a6'
}
}
}
\ No newline at end of file
diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll
index a1a4d8a..5664731 100644
Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ
diff --git a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml
index 132b5ff..16d82b8 100644
--- a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml
+++ b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml
@@ -1289,6 +1289,7 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
Installing a root into CurrentUser\Root makes Windows display a modal trust confirmation dialog, and the call blocks until it is answered; in a non-interactive session this looks like a hang. The cmdlet emits a warning before blocking. Run elevated or pass -StoreLocation LocalMachine to install machine-wide without a prompt.
Only the leaf honours -StoreName (default My). Chain members are routed by what they are: a self-signed certificate is a root and goes to the trusted-root store, anything with an issuer above it is a subordinate CA and goes to the intermediate store, for a chain of any depth. Issuers are installed before the leaf, and the chain is then validated against the machine's stores; an incomplete chain is reported as a warning naming the missing issuer, which is the condition Windows shows as "The issuer of this certificate could not be found".
The installed certificate's Windows friendly name defaults to the common name in upper case. -FriendlyName overrides it and is accepted on every parameter set; on the -CertificateAuthorityId path the same value is additionally sent to Infisical as the issued certificate's friendlyName.
+ -Metadata attaches key/value pairs to the certificate in Infisical and accepts any IDictionary, such as a hashtable or an [Ordered] dictionary. Only the supplied keys are reconciled; keys already on the certificate that the call does not mention are left alone, so several callers can each own their own keys. This is performed client-side because Infisical's PATCH replaces a certificate's metadata wholesale, so the module reads the current set, merges the supplied keys over it, and writes back the union; when nothing would change no request is sent. Reconciliation also runs on the reuse path, so a metadata change lands without forcing reissuance. Values are flattened to strings, keys are trimmed and compared case-insensitively, and blank keys are dropped. The resulting metadata is returned on the result's Metadata property. A metadata failure is reported as a warning and does not fail an issuance that otherwise succeeded.
The reuse check is scoped to the issuer being requested: the search is filtered by -CertificateProfileId or -CertificateAuthorityId, so a certificate issued by a different profile is not reused. This matters when two profiles over one CA differ in key usage, such as server authentication versus client authentication, where a common-name match alone would return a certificate with the wrong extended key usages. Reuse additionally requires the existing certificate to carry every requested subject alternative name, so adding an entry to -DnsName or -IpAddress issues a new certificate instead of returning one that would fail validation for the new name. The rule is coverage rather than equality: a certificate carrying more names than requested still qualifies, DNS names compare case-insensitively, and IP addresses are normalized so ::1 matches 0:0:0:0:0:0:0:1. Use -Force when the SAN set needs trimming rather than extending. When Infisical cannot be reached the check falls back to matching on the common name alone and says so with a warning.
diff --git a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml
index 132b5ff..16d82b8 100644
--- a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml
+++ b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml
@@ -1289,6 +1289,7 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
Installing a root into CurrentUser\Root makes Windows display a modal trust confirmation dialog, and the call blocks until it is answered; in a non-interactive session this looks like a hang. The cmdlet emits a warning before blocking. Run elevated or pass -StoreLocation LocalMachine to install machine-wide without a prompt.
Only the leaf honours -StoreName (default My). Chain members are routed by what they are: a self-signed certificate is a root and goes to the trusted-root store, anything with an issuer above it is a subordinate CA and goes to the intermediate store, for a chain of any depth. Issuers are installed before the leaf, and the chain is then validated against the machine's stores; an incomplete chain is reported as a warning naming the missing issuer, which is the condition Windows shows as "The issuer of this certificate could not be found".
The installed certificate's Windows friendly name defaults to the common name in upper case. -FriendlyName overrides it and is accepted on every parameter set; on the -CertificateAuthorityId path the same value is additionally sent to Infisical as the issued certificate's friendlyName.
+ -Metadata attaches key/value pairs to the certificate in Infisical and accepts any IDictionary, such as a hashtable or an [Ordered] dictionary. Only the supplied keys are reconciled; keys already on the certificate that the call does not mention are left alone, so several callers can each own their own keys. This is performed client-side because Infisical's PATCH replaces a certificate's metadata wholesale, so the module reads the current set, merges the supplied keys over it, and writes back the union; when nothing would change no request is sent. Reconciliation also runs on the reuse path, so a metadata change lands without forcing reissuance. Values are flattened to strings, keys are trimmed and compared case-insensitively, and blank keys are dropped. The resulting metadata is returned on the result's Metadata property. A metadata failure is reported as a warning and does not fail an issuance that otherwise succeeded.
The reuse check is scoped to the issuer being requested: the search is filtered by -CertificateProfileId or -CertificateAuthorityId, so a certificate issued by a different profile is not reused. This matters when two profiles over one CA differ in key usage, such as server authentication versus client authentication, where a common-name match alone would return a certificate with the wrong extended key usages. Reuse additionally requires the existing certificate to carry every requested subject alternative name, so adding an entry to -DnsName or -IpAddress issues a new certificate instead of returning one that would fail validation for the new name. The rule is coverage rather than equality: a certificate carrying more names than requested still qualifies, DNS names compare case-insensitively, and IP addresses are normalized so ::1 matches 0:0:0:0:0:0:0:1. Use -Force when the SAN set needs trimming rather than extending. When Infisical cannot be reached the check falls back to matching on the common name alone and says so with a warning.
diff --git a/README.md b/README.md
index 2448a76..2ad11ed 100644
--- a/README.md
+++ b/README.md
@@ -309,6 +309,42 @@ Windows will report "The issuer of this certificate could not be found" until th
The installed certificate's Windows friendly name defaults to the common name in upper case (`WEB01`), which is what shows in `certmgr`. Pass `-FriendlyName` on any parameter set to override it; on the `-CertificateAuthorityId` path the same value is also forwarded to Infisical as the issued certificate's `friendlyName`.
+### Metadata
+
+`-Metadata` attaches key/value pairs to the certificate in Infisical, and accepts any `IDictionary` — a hashtable, an `[Ordered]` dictionary, or a generic `Dictionary[String,String]`:
+
+```powershell
+$RequestInfisicalCertificateParameters.Metadata = [Ordered]@{
+ Environment = 'Production'
+ Owner = 'Platform Engineering'
+ ManagedBy = 'Invoke-SecretStaging'
+ Site = 'HQ'
+}
+```
+
+**Only the supplied keys are reconciled.** Keys already on the certificate that this call does not mention are left alone, so several callers can each own their own keys without clobbering each other:
+
+```powershell
+Request-InfisicalCertificate @Parameters -Metadata @{ Owner = 'Platform' } # certificate now has Owner
+Request-InfisicalCertificate @Parameters -Metadata @{ Site = 'HQ' } # Owner survives; Site added
+Request-InfisicalCertificate @Parameters -Metadata @{ Owner = 'Security' } # Owner updated; Site survives
+```
+
+This is done client-side. Infisical's `PATCH /certificates/{id}` replaces a certificate's metadata wholesale — the service deletes every row before inserting what it was sent — so the module reads the current set, merges the supplied keys over it, and writes back the union. When nothing would change, no request is sent at all.
+
+Reconciliation runs on the reuse path too, so a metadata change lands without forcing reissuance. Values are flattened to strings (`443` becomes `"443"`, `$True` becomes `"True"`, `$Null` becomes `""`), keys are trimmed and compared case-insensitively, and blank keys are dropped.
+
+The result carries the certificate's metadata after reconciliation:
+
+```powershell
+$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters
+$Certificate.Metadata['Environment'] # Production
+```
+
+Metadata never fails an issuance that otherwise succeeded. By the time it is applied the certificate exists and may already be installed, so a failure is reported as a warning and the certificate is still returned.
+
+Metadata is also a search filter — `Get-InfisicalCertificate` accepts `-Metadata` to find certificates by the keys you stamped on them.
+
### Reuse and renewal
A second run does not issue a new certificate if a still-valid one is already installed. That check is **scoped to the issuer you asked for**, not just the common name: the reuse search is filtered by `-CertificateProfileId` or `-CertificateAuthorityId`, so switching profiles issues a new certificate rather than handing back the old one.
@@ -343,7 +379,7 @@ When the resolved location is `LocalMachine` and `-KeyStorageFlags` was not supp
| Parameter | Common name | Use when |
| -------------------------- | ------------------------------------ | ------------------------------------------------------------------------ |
| `-CertificateProfileId` | **Per request**, constrained by policy | Fleet enrollment — many machines, each with its own CN. Works on any CA. |
-| `-CertificateAuthorityId` | **Per request**, unconstrained | Fleet enrollment where no policy is wanted. Needs direct issuance on the CA. |
+| `-CertificateAuthorityId` | **Per request**, unconstrained | Rarely usable — needs direct issuance, which cannot be enabled (see below). |
| `-PkiSubscriberSlug` | **Fixed** by the subscriber record | One named identity — a specific service or host, provisioned in advance. |
**A PKI subscriber is a per-identity object, not a fleet template.** `signSubscriberCert` rejects any CSR whose CN differs from the subscriber's:
@@ -413,7 +449,15 @@ t.renameColumn("requireTemplateForIssuance", "enableDirectIssuance");
.update({ name: slugifiedName, enableDirectIssuance: !ca.enableDirectIssuance });
```
-Any CA created before that migration with "require template for issuance" enabled now reads `EnableDirectIssuance = False` permanently. The options are to **use a profile** (which ignores the flag), or to create a new CA — new CAs default to `true`.
+Any CA created before that migration with "require template for issuance" enabled now reads `EnableDirectIssuance = False` permanently.
+
+Creating a new CA does not help either. Although the database column defaults to `true`, the creation service passes `false` explicitly:
+
+```ts
+const ca = await certificateAuthorityDAL.create({ projectId, name: resolvedCaName, status, enableDirectIssuance: false }, tx);
+```
+
+So **every CA created through the API or UI has direct issuance disabled**, and nothing can turn it on afterwards. Use a certificate profile, which ignores the flag entirely.
```powershell
Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal |
diff --git a/src/PSInfisicalAPI.Tests/CertificateMetadataTests.cs b/src/PSInfisicalAPI.Tests/CertificateMetadataTests.cs
new file mode 100644
index 0000000..db359cc
--- /dev/null
+++ b/src/PSInfisicalAPI.Tests/CertificateMetadataTests.cs
@@ -0,0 +1,244 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Reflection;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace PSInfisicalAPI.Tests
+{
+ ///
+ /// PATCH /certificates/{id} replaces a certificate's metadata wholesale, so "reconcile only the supplied
+ /// keys" has to be produced client-side by merging over the current set. These pin that merge.
+ ///
+ public class CertificateMetadataTests
+ {
+ private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
+
+ private static Dictionary NormalizeMetadata(IDictionary source)
+ {
+ Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
+ MethodInfo method = cmdletType.GetMethod("NormalizeMetadata", BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.NotNull(method);
+ return (Dictionary)method.Invoke(null, new object[] { source });
+ }
+
+ [Fact]
+ public void Metadata_Parameter_Accepts_Any_IDictionary_On_Every_Parameter_Set()
+ {
+ Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet", true);
+ PropertyInfo metadata = cmdletType.GetProperty("Metadata");
+ Assert.NotNull(metadata);
+ Assert.Equal(typeof(IDictionary), metadata.PropertyType);
+
+ foreach (CustomAttributeData attribute in metadata.GetCustomAttributesData())
+ {
+ if (attribute.AttributeType != typeof(System.Management.Automation.ParameterAttribute)) { continue; }
+ foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
+ {
+ Assert.NotEqual("ParameterSetName", named.MemberName);
+ }
+ }
+ }
+
+ [Fact]
+ public void Hashtable_And_OrderedDictionary_Both_Normalize()
+ {
+ Hashtable hashtable = new Hashtable { { "Environment", "Production" }, { "Owner", "Platform" } };
+ Dictionary fromHashtable = NormalizeMetadata(hashtable);
+ Assert.Equal(2, fromHashtable.Count);
+ Assert.Equal("Production", fromHashtable["Environment"]);
+
+ OrderedDictionary ordered = new OrderedDictionary();
+ ordered.Add("Environment", "Production");
+ ordered.Add("Owner", "Platform");
+ Dictionary fromOrdered = NormalizeMetadata(ordered);
+ Assert.Equal(2, fromOrdered.Count);
+ Assert.Equal("Platform", fromOrdered["Owner"]);
+ }
+
+ [Fact]
+ public void Non_String_Values_Are_Flattened_For_An_Api_That_Takes_Only_Strings()
+ {
+ Hashtable source = new Hashtable
+ {
+ { "Port", 443 },
+ { "Enabled", true },
+ { "Ratio", 1.5d },
+ { "Issued", new DateTime(2026, 7, 30, 0, 0, 0, DateTimeKind.Utc) }
+ };
+
+ Dictionary result = NormalizeMetadata(source);
+
+ Assert.Equal("443", result["Port"]);
+ Assert.Equal("True", result["Enabled"]);
+ Assert.Equal("1.5", result["Ratio"]);
+ Assert.False(string.IsNullOrEmpty(result["Issued"]));
+ }
+
+ [Fact]
+ public void Null_Values_Become_Empty_Strings_And_Blank_Keys_Are_Dropped()
+ {
+ // The API models a valueless key as an empty string, and rejects an empty key outright.
+ Hashtable source = new Hashtable
+ {
+ { "Present", null },
+ { " ", "orphan" },
+ { " Padded ", "trimmed" }
+ };
+
+ Dictionary result = NormalizeMetadata(source);
+
+ Assert.Equal(string.Empty, result["Present"]);
+ Assert.False(result.ContainsKey(" "));
+ Assert.True(result.ContainsKey("Padded"));
+ Assert.Equal("trimmed", result["Padded"]);
+ }
+
+ [Fact]
+ public void Keys_Are_Case_Insensitive()
+ {
+ Hashtable source = new Hashtable { { "Environment", "Production" } };
+ Dictionary result = NormalizeMetadata(source);
+ Assert.Equal("Production", result["ENVIRONMENT"]);
+ Assert.Equal("Production", result["environment"]);
+ }
+
+ [Fact]
+ public void A_Null_Or_Empty_Dictionary_Normalizes_To_Nothing()
+ {
+ Assert.Empty(NormalizeMetadata(null));
+ Assert.Empty(NormalizeMetadata(new Hashtable()));
+ }
+
+ [Fact]
+ public void Update_Request_Serializes_As_The_Key_Value_Array_The_Api_Expects()
+ {
+ Type requestType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalUpdateCertificateMetadataRequestDto", true);
+ Type entryType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMetadataEntryDto", true);
+
+ object entry = Activator.CreateInstance(entryType);
+ entryType.GetProperty("Key").SetValue(entry, "Environment");
+ entryType.GetProperty("Value").SetValue(entry, "Production");
+
+ Type listType = typeof(List<>).MakeGenericType(entryType);
+ object list = Activator.CreateInstance(listType);
+ listType.GetMethod("Add").Invoke(list, new object[] { entry });
+
+ object request = Activator.CreateInstance(requestType);
+ requestType.GetProperty("Metadata").SetValue(request, list);
+
+ string json = JsonConvert.SerializeObject(request);
+ Assert.Equal("{\"metadata\":[{\"key\":\"Environment\",\"value\":\"Production\"}]}", json);
+ }
+
+ [Fact]
+ public void Response_Metadata_Maps_To_A_Case_Insensitive_Dictionary()
+ {
+ Type mapper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMapper", true);
+ MethodInfo map = mapper.GetMethod("MapMetadata", BindingFlags.Public | BindingFlags.Static);
+ Assert.NotNull(map);
+
+ Type entryType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateSearchMetadataEntryDto", true);
+ Array entries = Array.CreateInstance(entryType, 2);
+
+ object first = Activator.CreateInstance(entryType);
+ entryType.GetProperty("Key").SetValue(first, "Environment");
+ entryType.GetProperty("Value").SetValue(first, "Production");
+ entries.SetValue(first, 0);
+
+ object second = Activator.CreateInstance(entryType);
+ entryType.GetProperty("Key").SetValue(second, "Owner");
+ entryType.GetProperty("Value").SetValue(second, null);
+ entries.SetValue(second, 1);
+
+ Dictionary result = (Dictionary)map.Invoke(null, new object[] { entries });
+
+ Assert.Equal(2, result.Count);
+ Assert.Equal("Production", result["ENVIRONMENT"]);
+ Assert.Equal(string.Empty, result["Owner"]);
+ }
+
+ [Fact]
+ public void Absent_Response_Metadata_Maps_To_An_Empty_Dictionary_Not_Null()
+ {
+ Type mapper = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateMapper", true);
+ MethodInfo map = mapper.GetMethod("MapMetadata", BindingFlags.Public | BindingFlags.Static);
+
+ Dictionary result = (Dictionary)map.Invoke(null, new object[] { null });
+ Assert.NotNull(result);
+ Assert.Empty(result);
+ }
+
+ [Theory]
+ // current supplied expected merged
+ [InlineData("a=1;b=2", "c=3", "a=1;b=2;c=3")] // untouched keys survive a partial update
+ [InlineData("a=1;b=2", "b=9", "a=1;b=9")] // supplied key wins
+ [InlineData("", "a=1", "a=1")] // first write onto a bare certificate
+ [InlineData("a=1", "A=2", "a=2")] // case-insensitive key collision updates in place
+ [InlineData("a=1;b=2", "", "a=1;b=2")] // nothing supplied changes nothing
+ public void Merge_Reconciles_Only_The_Supplied_Keys(string current, string supplied, string expected)
+ {
+ // Mirrors ReconcileCertificateMetadata's merge, which cannot be exercised directly without an
+ // HTTP round trip. The rule under test: start from current, overlay supplied, never remove.
+ Dictionary merged = new Dictionary(Parse(current), StringComparer.OrdinalIgnoreCase);
+ foreach (KeyValuePair pair in Parse(supplied))
+ {
+ merged[pair.Key] = pair.Value;
+ }
+
+ Assert.Equal(Format(Parse(expected)), Format(merged));
+ }
+
+ private static Dictionary Parse(string value)
+ {
+ Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrEmpty(value)) { return result; }
+
+ foreach (string pair in value.Split(';'))
+ {
+ if (string.IsNullOrEmpty(pair)) { continue; }
+ string[] parts = pair.Split('=');
+ result[parts[0]] = parts.Length > 1 ? parts[1] : string.Empty;
+ }
+
+ return result;
+ }
+
+ private static string Format(Dictionary value)
+ {
+ List pairs = new List();
+ foreach (KeyValuePair entry in value)
+ {
+ pairs.Add(string.Concat(entry.Key.ToLowerInvariant(), "=", entry.Value));
+ }
+
+ pairs.Sort(StringComparer.Ordinal);
+ return string.Join(";", pairs.ToArray());
+ }
+
+ [Fact]
+ public void Result_Object_Carries_The_Reconciled_Metadata()
+ {
+ PSInfisicalAPI.Models.InfisicalCertificateResult result = new PSInfisicalAPI.Models.InfisicalCertificateResult();
+ Assert.Null(result.Metadata);
+
+ result.Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Environment", "Production" } };
+ Assert.Equal("Production", result.Metadata["environment"]);
+ }
+
+ [Fact]
+ public void Metadata_Update_Endpoint_Is_Registered_For_Both_Route_Namespaces()
+ {
+ IReadOnlyList candidates =
+ PSInfisicalAPI.Endpoints.InfisicalEndpointRegistry.GetCandidates(
+ PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdateCertificateMetadata);
+
+ Assert.Contains(candidates, c => c.Template == "/api/v1/cert-manager/certificates/{certificateId}");
+ Assert.Contains(candidates, c => c.Template == "/api/v1/pki/certificates/{certificateId}");
+ Assert.All(candidates, c => Assert.Equal("PATCH", c.Method));
+ Assert.All(candidates, c => Assert.True(c.RequiresAuthorization));
+ }
+ }
+}
diff --git a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs
index 5e37785..bd45ba3 100644
--- a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs
+++ b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs
@@ -52,6 +52,12 @@ namespace PSInfisicalAPI.Cmdlets
// Available on every parameter set: it names the installed certificate in the Windows store. The CA path
// additionally forwards it to Infisical as the issued certificate's friendlyName.
[Parameter] public string FriendlyName { get; set; }
+
+ ///
+ /// Metadata to attach to the issued or reused certificate. Only the supplied keys are reconciled;
+ /// any other metadata already on the certificate is left alone.
+ ///
+ [Parameter] public IDictionary Metadata { get; set; }
[Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; }
[Parameter(ParameterSetName = "ByCa")]
[Parameter(ParameterSetName = "ByProfile")] public string[] KeyUsage { get; set; }
@@ -116,6 +122,9 @@ namespace PSInfisicalAPI.Cmdlets
}
}
+ // Reconciled on the reuse path too, so a metadata change lands without forcing reissuance.
+ reuseResult.Metadata = ApplyMetadata(client, connection, existing.SerialNumber);
+
WriteObject(reuseResult);
return;
}
@@ -161,6 +170,7 @@ namespace PSInfisicalAPI.Cmdlets
}
InfisicalCertificateResult resultObj = InfisicalCertificateRequestHelpers.BuildResult(cert, signed);
+ resultObj.Metadata = ApplyMetadata(client, connection, signed.SerialNumber);
bool hasExplicitPath = !string.IsNullOrEmpty(PrivateKeyPath);
if (hasExplicitPath && !string.IsNullOrEmpty(resultObj.PrivateKeyPem))
@@ -183,6 +193,79 @@ namespace PSInfisicalAPI.Cmdlets
}
}
+ ///
+ /// Reconciles -Metadata onto the certificate identified by and returns
+ /// the certificate's resulting metadata. Only the supplied keys are touched, so a certificate can carry
+ /// metadata from several sources without them overwriting each other.
+ ///
+ /// Metadata never fails an issuance that otherwise succeeded: by the time this runs the certificate
+ /// exists and may already be installed, so a failure here is reported as a warning and the certificate
+ /// is still emitted.
+ ///
+ ///
+ private Dictionary ApplyMetadata(InfisicalPkiClient client, InfisicalConnection connection, string serialNumber)
+ {
+ Dictionary desired = NormalizeMetadata(Metadata);
+ if (desired.Count == 0) { return null; }
+
+ if (string.IsNullOrEmpty(serialNumber))
+ {
+ Logger.Warning(Component, "-Metadata was supplied but the certificate has no serial number to identify it by; metadata was not applied.");
+ return null;
+ }
+
+ try
+ {
+ InfisicalCertificate record = client.RetrieveCertificate(connection, serialNumber);
+ if (record == null || string.IsNullOrEmpty(record.Id))
+ {
+ Logger.Warning(Component, string.Concat("-Metadata was supplied but certificate '", serialNumber, "' could not be resolved in Infisical; metadata was not applied."));
+ return null;
+ }
+
+ Dictionary result = client.ReconcileCertificateMetadata(connection, record.Id, desired);
+ Logger.Information(Component, string.Concat(
+ "Reconciled ", desired.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ " metadata key(s) onto certificate '", record.Id, "'; it now carries ",
+ (result != null ? result.Count : 0).ToString(System.Globalization.CultureInfo.InvariantCulture), " key(s)."));
+
+ return result;
+ }
+ catch (Exception metadataException)
+ {
+ if (IsPipelineControlException(metadataException)) { throw; }
+ Logger.Warning(Component, string.Concat("The certificate was issued but its metadata could not be applied: ", metadataException.Message));
+ return null;
+ }
+ }
+
+ ///
+ /// Flattens the caller's dictionary into string key/value pairs. PowerShell hands over hashtables whose
+ /// keys and values are arbitrary objects, and the API accepts only strings.
+ ///
+ internal static Dictionary NormalizeMetadata(IDictionary source)
+ {
+ Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (source == null) { return result; }
+
+ foreach (DictionaryEntry entry in source)
+ {
+ if (entry.Key == null) { continue; }
+
+ string key = Convert.ToString(entry.Key, System.Globalization.CultureInfo.InvariantCulture);
+ if (string.IsNullOrWhiteSpace(key)) { continue; }
+ key = key.Trim();
+
+ string value = entry.Value != null
+ ? Convert.ToString(entry.Value, System.Globalization.CultureInfo.InvariantCulture)
+ : string.Empty;
+
+ result[key] = value ?? string.Empty;
+ }
+
+ return result;
+ }
+
///
/// The Windows friendly name shown in certmgr. Defaults to the common name in upper case, which is the
/// host identity operators look for; -FriendlyName overrides it.
diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs
index 64278e4..fad8a05 100644
--- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs
+++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs
@@ -61,6 +61,7 @@ namespace PSInfisicalAPI.Endpoints
public const string SearchCertificates = "SearchCertificates";
public const string RetrieveCertificate = "RetrieveCertificate";
public const string GetCertificateBundle = "GetCertificateBundle";
+ public const string UpdateCertificateMetadata = "UpdateCertificateMetadata";
public const string SignCertificateBySubscriber = "SignCertificateBySubscriber";
public const string SignCertificateByCa = "SignCertificateByCa";
public const string IssueCertificateByProfile = "IssueCertificateByProfile";
diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs
index 5e7554e..ad98ee2 100644
--- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs
+++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs
@@ -698,6 +698,26 @@ namespace PSInfisicalAPI.Endpoints
ContainsSecretMaterialInResponse = true
});
+ Add(map, new InfisicalEndpointDefinition
+ {
+ Name = InfisicalEndpointNames.UpdateCertificateMetadata,
+ Resource = "Pki",
+ Version = "v1",
+ Method = "PATCH",
+ Template = "/api/v1/cert-manager/certificates/{certificateId}",
+ RequiresAuthorization = true
+ });
+
+ Add(map, new InfisicalEndpointDefinition
+ {
+ Name = InfisicalEndpointNames.UpdateCertificateMetadata,
+ Resource = "Pki",
+ Version = "v1",
+ Method = "PATCH",
+ Template = "/api/v1/pki/certificates/{certificateId}",
+ RequiresAuthorization = true
+ });
+
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.SignCertificateBySubscriber,
diff --git a/src/PSInfisicalAPI/Models/InfisicalCertificate.cs b/src/PSInfisicalAPI/Models/InfisicalCertificate.cs
index fd6b936..f3588c9 100644
--- a/src/PSInfisicalAPI/Models/InfisicalCertificate.cs
+++ b/src/PSInfisicalAPI/Models/InfisicalCertificate.cs
@@ -36,6 +36,12 @@ namespace PSInfisicalAPI.Models
public string Source { get; set; }
public string EnrollmentType { get; set; }
public bool HasPrivateKey { get; set; }
+
+ ///
+ /// Metadata key/value pairs attached to the certificate. Case-insensitive on key, matching how the
+ /// API treats them and how callers supply them from PowerShell.
+ ///
+ public System.Collections.Generic.Dictionary Metadata { get; set; }
public int? RevocationReason { get; set; }
public string RenewalError { get; set; }
public int? RenewBeforeDays { get; set; }
diff --git a/src/PSInfisicalAPI/Models/InfisicalCertificateResult.cs b/src/PSInfisicalAPI/Models/InfisicalCertificateResult.cs
index 7dc5404..3a77dfb 100644
--- a/src/PSInfisicalAPI/Models/InfisicalCertificateResult.cs
+++ b/src/PSInfisicalAPI/Models/InfisicalCertificateResult.cs
@@ -17,6 +17,11 @@ namespace PSInfisicalAPI.Models
public string StatusMessage { get; set; }
public string CertificateRequestId { get; set; }
+ ///
+ /// The certificate's metadata in Infisical after reconciliation, or null when -Metadata was not used.
+ ///
+ public System.Collections.Generic.Dictionary Metadata { get; set; }
+
public override string ToString()
{
if (Leaf != null) { return Leaf.Subject; }
diff --git a/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs b/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs
index 44e8def..4ccf61e 100644
--- a/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs
+++ b/src/PSInfisicalAPI/Pki/InfisicalCertificateDtos.cs
@@ -37,6 +37,7 @@ namespace PSInfisicalAPI.Pki
[JsonProperty("source")] public string Source { get; set; }
[JsonProperty("enrollmentType")] public string EnrollmentType { get; set; }
[JsonProperty("hasPrivateKey")] public bool HasPrivateKey { get; set; }
+ [JsonProperty("metadata", NullValueHandling = NullValueHandling.Ignore)] public InfisicalCertificateSearchMetadataEntryDto[] Metadata { get; set; }
[JsonProperty("revocationReason")] public int? RevocationReason { get; set; }
[JsonProperty("renewalError")] public string RenewalError { get; set; }
[JsonProperty("renewBeforeDays")] public int? RenewBeforeDays { get; set; }
@@ -96,6 +97,26 @@ namespace PSInfisicalAPI.Pki
[JsonProperty("value", NullValueHandling = NullValueHandling.Ignore)] public string Value { get; set; }
}
+ ///
+ /// Body for PATCH /certificates/{id}. The API replaces the certificate's entire metadata set with what
+ /// is sent, so this must always carry the full desired set rather than a delta.
+ ///
+ internal sealed class InfisicalUpdateCertificateMetadataRequestDto
+ {
+ [JsonProperty("metadata")] public List Metadata { get; set; }
+ }
+
+ internal sealed class InfisicalCertificateMetadataEntryDto
+ {
+ [JsonProperty("key")] public string Key { get; set; }
+ [JsonProperty("value")] public string Value { get; set; }
+ }
+
+ internal sealed class InfisicalUpdateCertificateMetadataResponseDto
+ {
+ [JsonProperty("metadata")] public List Metadata { get; set; }
+ }
+
internal sealed class InfisicalCertificateBundleResponseDto
{
[JsonProperty("serialNumber")] public string SerialNumber { get; set; }
diff --git a/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs b/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs
index 064f284..16d37d0 100644
--- a/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs
+++ b/src/PSInfisicalAPI/Pki/InfisicalCertificateMapper.cs
@@ -7,6 +7,24 @@ namespace PSInfisicalAPI.Pki
{
internal static class InfisicalCertificateMapper
{
+ ///
+ /// Projects the API's metadata array into a case-insensitive dictionary. A certificate with no
+ /// metadata maps to an empty dictionary rather than null, so callers can index it unconditionally.
+ ///
+ public static Dictionary MapMetadata(InfisicalCertificateSearchMetadataEntryDto[] entries)
+ {
+ Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (entries == null) { return result; }
+
+ foreach (InfisicalCertificateSearchMetadataEntryDto entry in entries)
+ {
+ if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
+ result[entry.Key] = entry.Value ?? string.Empty;
+ }
+
+ return result;
+ }
+
public static InfisicalCertificate Map(InfisicalCertificateResponseDto dto, string fallbackProjectId)
{
if (dto == null)
@@ -17,6 +35,7 @@ namespace PSInfisicalAPI.Pki
return new InfisicalCertificate
{
Id = dto.Id,
+ Metadata = MapMetadata(dto.Metadata),
ProjectId = !string.IsNullOrEmpty(dto.ProjectId) ? dto.ProjectId : fallbackProjectId,
CaId = dto.CaId,
CaName = dto.CaName,
diff --git a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs
index 657ea93..4830ab0 100644
--- a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs
+++ b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs
@@ -209,6 +209,104 @@ namespace PSInfisicalAPI.Pki
}
}
+ ///
+ /// Applies metadata to a certificate, reconciling only the supplied keys.
+ ///
+ /// PATCH /certificates/{id} replaces a certificate's metadata wholesale - the service deletes every
+ /// existing row before inserting what it was sent - so sending just the caller's keys would silently
+ /// discard everything else attached to the certificate. The current set is read first and the supplied
+ /// keys are merged over it, leaving untouched keys intact. Re-running with the same input is a no-op.
+ ///
+ ///
+ /// The certificate's full metadata after reconciliation.
+ public Dictionary ReconcileCertificateMetadata(InfisicalConnection connection, string certificateId, IReadOnlyDictionary desired)
+ {
+ if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
+ if (string.IsNullOrEmpty(certificateId)) { throw new InfisicalConfigurationException("CertificateId is required."); }
+
+ Dictionary current = GetCertificateMetadata(connection, certificateId);
+ if (desired == null || desired.Count == 0) { return current; }
+
+ Dictionary merged = new Dictionary(current, StringComparer.OrdinalIgnoreCase);
+ bool changed = false;
+ foreach (KeyValuePair pair in desired)
+ {
+ if (string.IsNullOrEmpty(pair.Key)) { continue; }
+ string value = pair.Value ?? string.Empty;
+
+ string existing;
+ if (merged.TryGetValue(pair.Key, out existing) && string.Equals(existing, value, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ merged[pair.Key] = value;
+ changed = true;
+ }
+
+ if (!changed)
+ {
+ _logger.Verbose(Component, string.Concat("Certificate metadata already matches the requested keys for '", certificateId, "'; no update sent."));
+ return merged;
+ }
+
+ List entries = new List();
+ foreach (KeyValuePair pair in merged)
+ {
+ entries.Add(new InfisicalCertificateMetadataEntryDto { Key = pair.Key, Value = pair.Value ?? string.Empty });
+ }
+
+ Dictionary pathParameters = new Dictionary { { "certificateId", certificateId } };
+ string body = _serializer.Serialize(new InfisicalUpdateCertificateMetadataRequestDto { Metadata = entries });
+
+ try
+ {
+ _logger.Information(Component, string.Concat("Attempting to update metadata on certificate '", certificateId, "'. Please Wait..."));
+ InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.UpdateCertificateMetadata, "UpdateCertificateMetadata", pathParameters, null, body);
+ InfisicalUpdateCertificateMetadataResponseDto dto = _serializer.Deserialize(response.Body);
+ response.Clear();
+
+ Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (dto != null && dto.Metadata != null)
+ {
+ foreach (InfisicalCertificateMetadataEntryDto entry in dto.Metadata)
+ {
+ if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
+ result[entry.Key] = entry.Value ?? string.Empty;
+ }
+ }
+ else
+ {
+ result = merged;
+ }
+
+ _logger.Information(Component, "Infisical certificate metadata update was successful.");
+ return result;
+ }
+ catch (Exception)
+ {
+ _logger.Error(Component, "Infisical certificate metadata update failed.");
+ throw;
+ }
+ }
+
+ ///
+ /// Reads the metadata currently attached to a certificate, addressed by its identifier.
+ ///
+ public Dictionary GetCertificateMetadata(InfisicalConnection connection, string certificateId)
+ {
+ if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
+ if (string.IsNullOrEmpty(certificateId)) { throw new InfisicalConfigurationException("CertificateId is required."); }
+
+ InfisicalCertificate certificate = RetrieveCertificate(connection, certificateId);
+ if (certificate == null || certificate.Metadata == null)
+ {
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new Dictionary(certificate.Metadata, StringComparer.OrdinalIgnoreCase);
+ }
+
public InfisicalSignedCertificate SignCertificateBySubscriber(InfisicalConnection connection, string subscriberName, string projectId, string csrPem)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }