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; } + } }