From ebabd6cf26d66541cccf1c826f6120297428368f Mon Sep 17 00:00:00 2001 From: GraceSolutions Date: Thu, 4 Jun 2026 17:02:03 -0400 Subject: [PATCH] Add profile-based issuance to Request-InfisicalCertificate New ByProfile parameter set bound by -CertificateProfileId (alias ProfileId) POSTs to /api/v1/cert-manager/certificates with the profile id, the locally generated CSR, and an attributes envelope (subject fields, ttl, notBefore, notAfter, keyUsages, extendedKeyUsages). The wrapped response is unwrapped into the existing InfisicalSignedCertificate so reuse, install, chain-completion and key-protection paths remain unchanged. Issuance that returns without a certificate (e.g. status pending_approval) raises a configuration exception that surfaces the reported status and message. Ttl/NotBefore/NotAfter/KeyUsage/ExtendedKeyUsage parameters are now shared by ByCa and ByProfile. MAML help and existing parameter-set test updated. --- CHANGELOG.md | 1 + .../en-US/PSInfisicalAPI.dll-Help.xml | 8 ++- .../CsrAndRequestCmdletTests.cs | 1 + .../RequestInfisicalCertificateCmdlet.cs | 27 +++++++-- .../Endpoints/InfisicalEndpointNames.cs | 1 + .../Endpoints/InfisicalEndpointRegistry.cs | 11 ++++ src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs | 60 +++++++++++++++++++ .../Pki/InfisicalSignCertificateDtos.cs | 40 +++++++++++++ 8 files changed, 142 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb41c8..dee5a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - `Get-InfisicalCertificateProfile` added with `List` (default) and `ById` parameter sets. List binds to `GET /api/v1/cert-manager/certificate-profiles` (optional `-Limit`, `-Offset`, `-IncludeConfigs`); ById binds to `GET /api/v1/cert-manager/certificate-profiles/{certificateProfileId}`. New `InfisicalCertificateProfile` model surfaces ca/policy ids, slug, enrollment type, per-profile defaults (ttl, key/extended key usages), and the embedded CA/policy/apiConfig summaries. - `Get-InfisicalCertificatePolicy` added with `List` (default) and `ById` parameter sets. List binds to `GET /api/v1/cert-manager/certificate-policies` (optional `-Limit`, `-Offset`); ById binds to `GET /api/v1/cert-manager/certificate-policies/{certificatePolicyId}`. New `InfisicalCertificatePolicy` model surfaces subject, SANs, key usages, extended key usages, algorithms, and validity. Polymorphic string-or-array fields (`allowed`, `required`, `keyAlgorithm`) are normalized to arrays; `sans` is normalized whether the API returns an object or an array. - `Get-InfisicalCertificateAuthority` gains a `-Kind` parameter on the List parameter set with values `Internal` (default, preserves prior behavior against `/api/v1/cert-manager/ca/internal`), `Any` (binds to the generic `/api/v1/cert-manager/ca` endpoint which returns both internal and ACME CAs), and `Acme` (uses the generic endpoint and client-side filters to ACME issuers only). ById retrieval is unchanged and still resolves against the internal CA endpoint. +- `Request-InfisicalCertificate` gains a `ByProfile` parameter set bound by the new `-CertificateProfileId` parameter (alias `ProfileId`). The cmdlet generates a local keypair and CSR as usual, then POSTs to `/api/v1/cert-manager/certificates` with the profile id, the CSR, and a subject/attribute envelope (commonName, organization, organizationalUnit, country, state, locality, ttl, notBefore, notAfter, keyUsages, extendedKeyUsages). The wrapped response (`{certificate:{certificate,certificateChain,issuingCaCertificate,serialNumber,certificateId,privateKey}, certificateRequestId, status, message}`) is unwrapped into the existing `InfisicalSignedCertificate` shape so the install / reuse / chain-completion paths continue to work unchanged. Issuance that returns without a certificate (e.g. status `pending_approval`) raises a configuration exception that surfaces the reported status and message. ## 2026.06.04.1920 diff --git a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml index 82c903b..799d13e 100644 --- a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml @@ -1269,7 +1269,7 @@ $SearchInfisicalCertificateResult = Search-InfisicalCertificate @SearchInfisical InfisicalCertificate - Generates a keypair locally, builds a CSR, and submits it for signing either via a PKI subscriber (-PkiSubscriberSlug, default parameter set) or by direct CA signing (-CertificateAuthorityId). On subsequent runs an existing certificate whose CN matches and whose remaining lifetime exceeds -RenewalThresholdDays is reused; pass -Force to always issue or -AllowRenewal to allow rotation inside the threshold. Optional flags install the leaf (-Install) and chain (-InstallChain) into a Windows certificate store, and control private-key protection (-PrivateKeyProtection, -PersistKey, -MachineKey, -PrivateKeyPath, -KeyStorageFlags). Honors -WhatIf and -Confirm. + Generates a keypair locally, builds a CSR, and submits it for signing via one of three parameter sets: a PKI subscriber (-PkiSubscriberSlug, default), direct CA signing (-CertificateAuthorityId), or a certificate profile (-CertificateProfileId, POSTs to /api/v1/cert-manager/certificates with the profile bound). On subsequent runs an existing certificate whose CN matches and whose remaining lifetime exceeds -RenewalThresholdDays is reused; pass -Force to always issue or -AllowRenewal to allow rotation inside the threshold. Optional flags install the leaf (-Install) and chain (-InstallChain) into a Windows certificate store, and control private-key protection (-PrivateKeyProtection, -PersistKey, -MachineKey, -PrivateKeyPath, -KeyStorageFlags). Honors -WhatIf and -Confirm. Notes @@ -1306,6 +1306,12 @@ $RequestInfisicalCertificateParameters.Verbose = $True $RequestInfisicalCertificateResult = Request-InfisicalCertificate @RequestInfisicalCertificateParameters Issues (or renews within 30 days) a 3072-bit RSA certificate for the local FQDN, installs the leaf and chain into LocalMachine\My with a non-exportable machine-bound persistent key. + + EXAMPLE 3 + $Profile = Get-InfisicalCertificateProfile | Where-Object { $_.Slug -eq 'web-tier-profile' } +Request-InfisicalCertificate -CertificateProfileId $Profile.Id -CommonName 'web01.contoso.com' -Ttl '90d' + Issues a certificate via the modern profile API (POST /api/v1/cert-manager/certificates). The profile binds the CA, policy, and defaults so no subscriber is required. + diff --git a/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs b/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs index 95c8232..48f32ab 100644 --- a/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs +++ b/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs @@ -158,6 +158,7 @@ namespace PSInfisicalAPI.Tests Assert.NotNull(cmdletType.GetProperty("PkiSubscriberSlug")); Assert.NotNull(cmdletType.GetProperty("CertificateAuthorityId")); + Assert.NotNull(cmdletType.GetProperty("CertificateProfileId")); Assert.NotNull(cmdletType.GetProperty("Subject")); Assert.NotNull(cmdletType.GetProperty("CommonName")); Assert.NotNull(cmdletType.GetProperty("DnsName")); diff --git a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs index 1fc3b09..7f0ac2e 100644 --- a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs @@ -23,6 +23,10 @@ namespace PSInfisicalAPI.Cmdlets [Alias("CaId")] public string CertificateAuthorityId { get; set; } + [Parameter(ParameterSetName = "ByProfile", Mandatory = true, Position = 0)] + [Alias("ProfileId")] + public string CertificateProfileId { get; set; } + [Parameter] public string ProjectId { get; set; } [Parameter] public IDictionary Subject { get; set; } [Parameter] public string CommonName { get; set; } @@ -38,13 +42,18 @@ namespace PSInfisicalAPI.Cmdlets [Parameter] public int KeySize { get; set; } = 2048; [Parameter] public InfisicalEcCurve Curve { get; set; } = InfisicalEcCurve.P256; - [Parameter(ParameterSetName = "ByCa")] public string Ttl { get; set; } - [Parameter(ParameterSetName = "ByCa")] public string NotBefore { get; set; } - [Parameter(ParameterSetName = "ByCa")] public string NotAfter { get; set; } + [Parameter(ParameterSetName = "ByCa")] + [Parameter(ParameterSetName = "ByProfile")] public string Ttl { get; set; } + [Parameter(ParameterSetName = "ByCa")] + [Parameter(ParameterSetName = "ByProfile")] public string NotBefore { get; set; } + [Parameter(ParameterSetName = "ByCa")] + [Parameter(ParameterSetName = "ByProfile")] public string NotAfter { get; set; } [Parameter(ParameterSetName = "ByCa")] public string FriendlyName { get; set; } [Parameter(ParameterSetName = "ByCa")] public string PkiCollectionId { get; set; } - [Parameter(ParameterSetName = "ByCa")] public string[] KeyUsage { get; set; } - [Parameter(ParameterSetName = "ByCa")] public string[] ExtendedKeyUsage { get; set; } + [Parameter(ParameterSetName = "ByCa")] + [Parameter(ParameterSetName = "ByProfile")] public string[] KeyUsage { get; set; } + [Parameter(ParameterSetName = "ByCa")] + [Parameter(ParameterSetName = "ByProfile")] public string[] ExtendedKeyUsage { get; set; } [Parameter] public SwitchParameter Install { get; set; } [Parameter] public StoreName StoreName { get; set; } = StoreName.My; @@ -104,7 +113,7 @@ namespace PSInfisicalAPI.Cmdlets return; } - string target = string.Concat("PKI subscriber '", PkiSubscriberSlug ?? "(n/a)", "' or CA '", CertificateAuthorityId ?? "(n/a)", "' for CN=", csrSubject.CommonName); + string target = string.Concat("PKI subscriber '", PkiSubscriberSlug ?? "(n/a)", "', CA '", CertificateAuthorityId ?? "(n/a)", "', or profile '", CertificateProfileId ?? "(n/a)", "' for CN=", csrSubject.CommonName); if (!ShouldProcess(target, "Request new certificate")) { return; } InfisicalCsrOptions csrOptions = new InfisicalCsrOptions { KeyAlgorithm = KeyAlgorithm, RsaKeySize = KeySize, EcCurve = Curve }; @@ -198,6 +207,12 @@ namespace PSInfisicalAPI.Cmdlets return client.SignCertificateBySubscriber(connection, PkiSubscriberSlug, projectId, csrPem); } + if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal)) + { + InfisicalCsrSubject subject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress); + return client.IssueCertificateByProfile(connection, CertificateProfileId, csrPem, subject.CommonName, subject.Organization, subject.OrganizationalUnit, subject.Country, subject.State, subject.Locality, Ttl, NotBefore, NotAfter, KeyUsage, ExtendedKeyUsage); + } + return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage); } } diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs index 7ccdd03..fa32617 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs @@ -51,6 +51,7 @@ namespace PSInfisicalAPI.Endpoints public const string GetCertificateBundle = "GetCertificateBundle"; public const string SignCertificateBySubscriber = "SignCertificateBySubscriber"; public const string SignCertificateByCa = "SignCertificateByCa"; + public const string IssueCertificateByProfile = "IssueCertificateByProfile"; public const string ListPkiSubscribers = "ListPkiSubscribers"; public const string GetPkiSubscriber = "GetPkiSubscriber"; diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index 06abc80..242a202 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -623,6 +623,17 @@ namespace PSInfisicalAPI.Endpoints ContainsSecretMaterialInResponse = true }); + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.IssueCertificateByProfile, + Resource = "Pki", + Version = "v1", + Method = "POST", + Template = "/api/v1/cert-manager/certificates", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.ListPkiSubscribers, diff --git a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs index 65e52b2..03f76e7 100644 --- a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs +++ b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs @@ -300,6 +300,66 @@ namespace PSInfisicalAPI.Pki }; } + public InfisicalSignedCertificate IssueCertificateByProfile(InfisicalConnection connection, string profileId, string csrPem, string commonName, string organization, string organizationalUnit, string country, string state, string locality, string ttl, string notBefore, string notAfter, IEnumerable keyUsages, IEnumerable extendedKeyUsages) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(profileId)) { throw new InfisicalConfigurationException("CertificateProfileId is required."); } + if (string.IsNullOrEmpty(csrPem)) { throw new InfisicalConfigurationException("CSR is required."); } + + InfisicalIssueCertificateAttributesDto attributes = new InfisicalIssueCertificateAttributesDto + { + CommonName = commonName, + Organization = organization, + OrganizationalUnit = organizationalUnit, + Country = country, + State = state, + Locality = locality, + Ttl = ttl, + NotBefore = notBefore, + NotAfter = notAfter, + KeyUsages = keyUsages != null ? new List(keyUsages) : null, + ExtendedKeyUsages = extendedKeyUsages != null ? new List(extendedKeyUsages) : null + }; + + InfisicalIssueCertificateByProfileRequestDto request = new InfisicalIssueCertificateByProfileRequestDto + { + ProfileId = profileId, + Csr = csrPem, + Attributes = attributes + }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to issue certificate via profile '", profileId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.IssueCertificateByProfile, "IssueCertificateByProfile", null, null, body); + InfisicalIssueCertificateResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + if (dto == null || dto.Certificate == null || string.IsNullOrEmpty(dto.Certificate.Certificate)) + { + string status = dto != null ? dto.Status : "unknown"; + string message = dto != null ? dto.Message : null; + throw new InfisicalConfigurationException(string.Concat("Certificate was not issued (status='", status ?? "unknown", "'", string.IsNullOrEmpty(message) ? "" : string.Concat(", message='", message, "'"), "). The certificate profile may require manual approval or additional validation.")); + } + + InfisicalSignedCertificate signed = new InfisicalSignedCertificate + { + SerialNumber = dto.Certificate.SerialNumber, + CertificatePem = dto.Certificate.Certificate, + CertificateChainPem = dto.Certificate.CertificateChain, + IssuingCaCertificatePem = dto.Certificate.IssuingCaCertificate + }; + _logger.Information(Component, "Infisical certificate issuance (profile) was successful."); + return signed; + } + catch (Exception) + { + _logger.Error(Component, "Infisical certificate issuance (profile) failed."); + throw; + } + } + public InfisicalPkiSubscriber[] ListPkiSubscribers(InfisicalConnection connection, string projectId) { if (connection == null) { throw new ArgumentNullException(nameof(connection)); } diff --git a/src/PSInfisicalAPI/Pki/InfisicalSignCertificateDtos.cs b/src/PSInfisicalAPI/Pki/InfisicalSignCertificateDtos.cs index e6a82eb..71d3647 100644 --- a/src/PSInfisicalAPI/Pki/InfisicalSignCertificateDtos.cs +++ b/src/PSInfisicalAPI/Pki/InfisicalSignCertificateDtos.cs @@ -30,4 +30,44 @@ namespace PSInfisicalAPI.Pki [JsonProperty("issuingCaCertificate")] public string IssuingCaCertificate { get; set; } [JsonProperty("serialNumber")] public string SerialNumber { get; set; } } + + internal sealed class InfisicalIssueCertificateByProfileRequestDto + { + [JsonProperty("profileId")] public string ProfileId { get; set; } + [JsonProperty("csr", NullValueHandling = NullValueHandling.Ignore)] public string Csr { get; set; } + [JsonProperty("attributes", NullValueHandling = NullValueHandling.Ignore)] public InfisicalIssueCertificateAttributesDto Attributes { get; set; } + } + + internal sealed class InfisicalIssueCertificateAttributesDto + { + [JsonProperty("commonName", NullValueHandling = NullValueHandling.Ignore)] public string CommonName { get; set; } + [JsonProperty("organization", NullValueHandling = NullValueHandling.Ignore)] public string Organization { get; set; } + [JsonProperty("organizationalUnit", NullValueHandling = NullValueHandling.Ignore)] public string OrganizationalUnit { get; set; } + [JsonProperty("country", NullValueHandling = NullValueHandling.Ignore)] public string Country { get; set; } + [JsonProperty("state", NullValueHandling = NullValueHandling.Ignore)] public string State { get; set; } + [JsonProperty("locality", NullValueHandling = NullValueHandling.Ignore)] public string Locality { get; set; } + [JsonProperty("ttl", NullValueHandling = NullValueHandling.Ignore)] public string Ttl { get; set; } + [JsonProperty("notBefore", NullValueHandling = NullValueHandling.Ignore)] public string NotBefore { get; set; } + [JsonProperty("notAfter", NullValueHandling = NullValueHandling.Ignore)] public string NotAfter { get; set; } + [JsonProperty("keyUsages", NullValueHandling = NullValueHandling.Ignore)] public List KeyUsages { get; set; } + [JsonProperty("extendedKeyUsages", NullValueHandling = NullValueHandling.Ignore)] public List ExtendedKeyUsages { get; set; } + } + + internal sealed class InfisicalIssueCertificateResponseDto + { + [JsonProperty("certificate")] public InfisicalIssueCertificateInnerDto Certificate { get; set; } + [JsonProperty("certificateRequestId")] public string CertificateRequestId { get; set; } + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("message")] public string Message { get; set; } + } + + internal sealed class InfisicalIssueCertificateInnerDto + { + [JsonProperty("certificate")] public string Certificate { get; set; } + [JsonProperty("certificateChain")] public string CertificateChain { get; set; } + [JsonProperty("issuingCaCertificate")] public string IssuingCaCertificate { get; set; } + [JsonProperty("serialNumber")] public string SerialNumber { get; set; } + [JsonProperty("certificateId")] public string CertificateId { get; set; } + [JsonProperty("privateKey")] public string PrivateKey { get; set; } + } }