Merge pull request 'Certificate metadata reconciliation and a cert-manager seeding script' (#22) from dev into main
Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
+24
-1
@@ -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`.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1289,6 +1289,7 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
|
||||
<maml:para>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.</maml:para>
|
||||
<maml:para>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".</maml:para>
|
||||
<maml:para>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.</maml:para>
|
||||
<maml:para>-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.</maml:para>
|
||||
<maml:para>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.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
|
||||
@@ -1289,6 +1289,7 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi
|
||||
<maml:para>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.</maml:para>
|
||||
<maml:para>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".</maml:para>
|
||||
<maml:para>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.</maml:para>
|
||||
<maml:para>-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.</maml:para>
|
||||
<maml:para>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.</maml:para>
|
||||
</maml:alert>
|
||||
</maml:alertSet>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class CertificateMetadataTests
|
||||
{
|
||||
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
|
||||
|
||||
private static Dictionary<string, string> 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<string, string>)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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string> 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<string, string> result = (Dictionary<string, string>)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<string, string> result = (Dictionary<string, string>)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<string, string> merged = new Dictionary<string, string>(Parse(current), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (KeyValuePair<string, string> pair in Parse(supplied))
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
Assert.Equal(Format(Parse(expected)), Format(merged));
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Parse(string value)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(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<string, string> value)
|
||||
{
|
||||
List<string> pairs = new List<string>();
|
||||
foreach (KeyValuePair<string, string> 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<string, string>(StringComparer.OrdinalIgnoreCase) { { "Environment", "Production" } };
|
||||
Assert.Equal("Production", result.Metadata["environment"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Metadata_Update_Endpoint_Is_Registered_For_Both_Route_Namespaces()
|
||||
{
|
||||
IReadOnlyList<PSInfisicalAPI.Endpoints.InfisicalEndpointDefinition> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconciles -Metadata onto the certificate identified by <paramref name="serialNumber"/> 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private Dictionary<string, string> ApplyMetadata(InfisicalPkiClient client, InfisicalConnection connection, string serialNumber)
|
||||
{
|
||||
Dictionary<string, string> 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<string, string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> NormalizeMetadata(IDictionary source)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,12 @@ namespace PSInfisicalAPI.Models
|
||||
public string Source { get; set; }
|
||||
public string EnrollmentType { get; set; }
|
||||
public bool HasPrivateKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; }
|
||||
public int? RevocationReason { get; set; }
|
||||
public string RenewalError { get; set; }
|
||||
public int? RenewBeforeDays { get; set; }
|
||||
|
||||
@@ -17,6 +17,11 @@ namespace PSInfisicalAPI.Models
|
||||
public string StatusMessage { get; set; }
|
||||
public string CertificateRequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The certificate's metadata in Infisical after reconciliation, or null when -Metadata was not used.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (Leaf != null) { return Leaf.Subject; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed class InfisicalUpdateCertificateMetadataRequestDto
|
||||
{
|
||||
[JsonProperty("metadata")] public List<InfisicalCertificateMetadataEntryDto> 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<InfisicalCertificateMetadataEntryDto> Metadata { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class InfisicalCertificateBundleResponseDto
|
||||
{
|
||||
[JsonProperty("serialNumber")] public string SerialNumber { get; set; }
|
||||
|
||||
@@ -7,6 +7,24 @@ namespace PSInfisicalAPI.Pki
|
||||
{
|
||||
internal static class InfisicalCertificateMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> MapMetadata(InfisicalCertificateSearchMetadataEntryDto[] entries)
|
||||
{
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(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,
|
||||
|
||||
@@ -209,6 +209,104 @@ namespace PSInfisicalAPI.Pki
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies metadata to a certificate, reconciling only the supplied keys.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>The certificate's full metadata after reconciliation.</returns>
|
||||
public Dictionary<string, string> ReconcileCertificateMetadata(InfisicalConnection connection, string certificateId, IReadOnlyDictionary<string, string> desired)
|
||||
{
|
||||
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
|
||||
if (string.IsNullOrEmpty(certificateId)) { throw new InfisicalConfigurationException("CertificateId is required."); }
|
||||
|
||||
Dictionary<string, string> current = GetCertificateMetadata(connection, certificateId);
|
||||
if (desired == null || desired.Count == 0) { return current; }
|
||||
|
||||
Dictionary<string, string> merged = new Dictionary<string, string>(current, StringComparer.OrdinalIgnoreCase);
|
||||
bool changed = false;
|
||||
foreach (KeyValuePair<string, string> 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<InfisicalCertificateMetadataEntryDto> entries = new List<InfisicalCertificateMetadataEntryDto>();
|
||||
foreach (KeyValuePair<string, string> pair in merged)
|
||||
{
|
||||
entries.Add(new InfisicalCertificateMetadataEntryDto { Key = pair.Key, Value = pair.Value ?? string.Empty });
|
||||
}
|
||||
|
||||
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "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<InfisicalUpdateCertificateMetadataResponseDto>(response.Body);
|
||||
response.Clear();
|
||||
|
||||
Dictionary<string, string> result = new Dictionary<string, string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the metadata currently attached to a certificate, addressed by its identifier.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> 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<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return new Dictionary<string, string>(certificate.Metadata, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public InfisicalSignedCertificate SignCertificateBySubscriber(InfisicalConnection connection, string subscriberName, string projectId, string csrPem)
|
||||
{
|
||||
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
|
||||
|
||||
Reference in New Issue
Block a user