Make -ProjectId optional across the PKI cmdlets and add application filtering

The Infisical console never asks which Certificate Manager project to use. The
project is in the URL, but it is chosen implicitly - the service resolver takes
the single cert-manager project when an organization has exactly one - and
everything below it is presented as applications. Requiring -ProjectId on every
PKI cmdlet made the module stricter than the service it wraps.

-ProjectId is now optional on Get-InfisicalCertificateApplication,
-ApplicationEnrollment, -Authority, -Certificate, -CertificatePolicy,
-CertificateProfile, Get-InfisicalPkiSubscriber, and
Request-InfisicalCertificate. Omitting it applies the same rule the server
applies and reports the resolved project on the verbose stream; an organization
with several produces an error naming the candidates rather than guessing.

Resolution is client-side rather than deferred to the server because several PKI
endpoints carry the project in the URL path - /api/v1/projects/{projectId}/
pki-subscribers and .../certificates/search - and cannot fall back to the
server's resolver at all. Doing it in one place keeps path-scoped and
query-scoped endpoints behaving identically.

Get-InfisicalCertificate -SerialNumber no longer resolves a project, since
addressing a certificate by serial does not need one.

Adds -ApplicationId and -CaId to Get-InfisicalCertificateProfile, which the
profiles endpoint already supports as query filters, so a listing can be scoped
the way the console groups profiles. Get-InfisicalCertificate already accepted
-ApplicationId. The existing five-argument ListCertificateProfiles overload is
retained.

Also fixes the README Quick Start, which did not run: it passed -ProjectId and
-Environment to Connect-Infisical, which has neither, failing with "A parameter
cannot be found that matches parameter name 'ProjectId'". Project, environment,
and secret path are per-call parameters. Documents that a project contains
applications, since listing projects returning one entry while the console shows
several applications is the confusing part.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:25:09 -04:00
parent 93b0cc1924
commit 633f40c1fa
17 changed files with 431 additions and 16 deletions
+21 -1
View File
@@ -6,11 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased
## 2026.07.31.1924
- Build produced from commit 93b0cc1924ec.
## Unreleased (carried forward)
## 2026.07.31.0045
- Build produced from commit d47a5af6b3a6.
## Unreleased (carried forward)
## Unreleased (carried forward)
## 2026.07.30.2350
@@ -36,6 +42,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased (carried forward)
### Changed (project scoping)
- **`-ProjectId` is now optional on every PKI cmdlet** (`Get-InfisicalCertificateApplication`, `-ApplicationEnrollment`, `-Authority`, `-Certificate`, `-CertificatePolicy`, `-CertificateProfile`, `Get-InfisicalPkiSubscriber`, `Request-InfisicalCertificate`). The Infisical console never asks which Certificate Manager project to use because its resolver selects the single cert-manager project when an organization has exactly one; the module was stricter than the service it wraps. Omitting `-ProjectId` applies the same rule and reports the resolved project on the verbose stream, and an organization with several produces an error listing them.
- Resolution is client-side because several PKI endpoints carry the project in the URL path (`/api/v1/projects/{projectId}/pki-subscribers`, `/certificates/search`) and cannot defer to the server's resolver.
- `Get-InfisicalCertificate -SerialNumber` no longer resolves a project, since addressing a certificate by serial does not need one.
### Added (application scoping)
- `Get-InfisicalCertificateProfile -ApplicationId` and `-CaId` filter a listing the way the console groups profiles. `Get-InfisicalCertificate` already accepted `-ApplicationId`.
### Fixed (documentation)
- The README Quick Start did not run: it passed `-ProjectId` and `-Environment` to `Connect-Infisical`, which has neither, failing with "A parameter cannot be found that matches parameter name 'ProjectId'". Project, environment, and secret path are per-call parameters.
### Added (certificate metadata)
- `Request-InfisicalCertificate -Metadata` attaches key/value pairs to the issued or reused certificate, accepting any `IDictionary` (hashtable, `[Ordered]`, generic dictionary).
+2 -2
View File
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.07.31.0045'
ModuleVersion = '2026.07.31.1924'
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 = 'd47a5af6b3a6'
CommitHash = '93b0cc1924ec'
}
}
}
Binary file not shown.
@@ -1066,6 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters</d
<maml:alertSet>
<maml:title>Notes</maml:title>
<maml:alert>
<maml:para>-ProjectId is optional. The Infisical console never asks which Certificate Manager project to use, because its resolver selects the single cert-manager project when an organization has exactly one; omitting -ProjectId applies the same rule and reports the resolved project on the verbose stream. Pass it explicitly when an organization has more than one, in which case the error lists the candidates. Note that a project contains applications: Get-InfisicalProject -Type cert-manager returns the project, while Get-InfisicalCertificateApplication returns the applications inside it.</maml:para>
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently.</maml:para>
</maml:alert>
</maml:alertSet>
@@ -1066,6 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters</d
<maml:alertSet>
<maml:title>Notes</maml:title>
<maml:alert>
<maml:para>-ProjectId is optional. The Infisical console never asks which Certificate Manager project to use, because its resolver selects the single cert-manager project when an organization has exactly one; omitting -ProjectId applies the same rule and reports the resolved project on the verbose stream. Pass it explicitly when an organization has more than one, in which case the error lists the candidates. Note that a project contains applications: Get-InfisicalProject -Type cert-manager returns the project, while Get-InfisicalCertificateApplication returns the applications inside it.</maml:para>
<maml:para>ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently.</maml:para>
</maml:alert>
</maml:alertSet>
+55 -3
View File
@@ -134,16 +134,16 @@ $secureSecret = Read-Host -AsSecureString 'Client Secret'
$connection = Connect-Infisical `
-BaseUri 'https://app.infisical.com' `
-OrganizationId '00000000-0000-0000-0000-000000000000' `
-ProjectId '11111111-1111-1111-1111-111111111111' `
-Environment 'dev' `
-ClientId 'machine-identity-client-id' `
-ClientSecret $secureSecret `
-PassThru
Get-InfisicalSecret -SecretPath '/'
Get-InfisicalSecret -ProjectId '11111111-1111-1111-1111-111111111111' -Environment 'dev' -SecretPath '/'
Disconnect-Infisical
```
`Connect-Infisical` establishes the session; project, environment, and secret path are supplied per call. On the PKI cmdlets `-ProjectId` is optional — see [Project scoping](#project-scoping).
## End-to-end: request and install a chained certificate
Connects, selects a `cert-manager` project, sources SANs from `Get-InfisicalSANList`, requests a certificate through a certificate profile, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively.
@@ -211,6 +211,58 @@ Note `$CertificateProfile` rather than `$Profile`: `$Profile` is an automatic va
`-StoreName`/`-StoreLocation` are omitted deliberately — see [Where certificates get installed](#where-certificates-get-installed).
### Project scoping
Opening **Certificate Manager** in the Infisical console never asks which project to use. The project is in the URL — `/organizations/{orgId}/projects/cert-manager/{projectId}/applications` — but it is chosen for you, and everything below it is presented as **applications**. That is because Infisical's own resolver takes the single Certificate Manager project when an organization has exactly one:
```ts
const projects = await projectDAL.find({ orgId: actorOrgId, type: ProjectType.CertificateManager });
if (projects.length === 1) return projects[0].id;
```
The PKI cmdlets follow the same rule: **`-ProjectId` is optional**. Omit it and the module resolves the organization's only Certificate Manager project, reporting the choice on the verbose stream:
```powershell
Get-InfisicalCertificateApplication
Get-InfisicalCertificateAuthority -Kind Internal
Get-InfisicalCertificateProfile -IncludeConfigs
Get-InfisicalPkiSubscriber
Get-InfisicalCertificate -Status active
```
```text
VERBOSE: -ProjectId was not supplied; resolved the organization's only Certificate Manager project
'Microsoft Endpoint Configuration Manager' (2122628e-...).
```
Pass `-ProjectId` explicitly when an organization has more than one — the resolver cannot guess, and says so with the candidates listed:
```text
This organization has 2 Certificate Manager projects, so -ProjectId cannot be resolved automatically.
Pass it explicitly. Available: 'Platform PKI' (aaaa...), 'Lab PKI' (bbbb...).
```
This is resolved client-side rather than left to the server because several PKI endpoints carry the project in the URL path (`/api/v1/projects/{projectId}/pki-subscribers`, `/certificates/search`) and cannot defer to the server's resolver.
#### Projects contain applications
The two are different levels, which is worth keeping straight when reading output:
| | What it is | Cmdlet |
| --- | --- | --- |
| **Project** | The Certificate Manager project itself. One per organization in most setups. | `Get-InfisicalProject -Type cert-manager` |
| **Application** | A grouping of profiles, members, and certificates inside that project. | `Get-InfisicalCertificateApplication` |
So a project named `mecm` can contain applications named `2pint` and `microsoft-endpoint-configuration-manager`; listing projects returns only `mecm`, because the others are not projects. Every application carries the `ProjectId` it belongs to.
Profiles can be filtered to an application, matching how the console groups them:
```powershell
$Application = Get-InfisicalCertificateApplication | Where-Object {($_.Name -eq '2pint')}
Get-InfisicalCertificateProfile -ApplicationId $Application.Id -IncludeConfigs
Get-InfisicalCertificate -ApplicationId $Application.Id
```
### Example output
```text
@@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using System.Reflection;
using System.Security.Cryptography.X509Certificates;
using Xunit;
namespace PSInfisicalAPI.Tests
{
/// <summary>
/// The Infisical console never asks which Certificate Manager project to use when an organization has one,
/// because its resolver selects it implicitly. Requiring -ProjectId made the module stricter than the
/// service it wraps, so these pin the parameter as optional across the PKI surface.
/// </summary>
public class ProjectScopingTests
{
private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly;
private static readonly string[] PkiCmdletTypes = new[]
{
"PSInfisicalAPI.Cmdlets.GetInfisicalCertificateApplicationCmdlet",
"PSInfisicalAPI.Cmdlets.GetInfisicalCertificateApplicationEnrollmentCmdlet",
"PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet",
"PSInfisicalAPI.Cmdlets.GetInfisicalCertificateCmdlet",
"PSInfisicalAPI.Cmdlets.GetInfisicalCertificatePolicyCmdlet",
"PSInfisicalAPI.Cmdlets.GetInfisicalCertificateProfileCmdlet",
"PSInfisicalAPI.Cmdlets.GetInfisicalPkiSubscriberCmdlet",
"PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet"
};
[Fact]
public void ProjectId_Is_Optional_On_Every_Pki_Cmdlet()
{
List<string> offenders = new List<string>();
foreach (string typeName in PkiCmdletTypes)
{
Type type = ModuleAssembly.GetType(typeName, true);
PropertyInfo projectId = type.GetProperty("ProjectId");
Assert.True(projectId != null, string.Concat(typeName, " has no ProjectId property"));
foreach (CustomAttributeData attribute in projectId.GetCustomAttributesData())
{
if (attribute.AttributeType != typeof(ParameterAttribute)) { continue; }
foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
{
if (named.MemberName == "Mandatory" && (bool)named.TypedValue.Value)
{
offenders.Add(type.Name);
}
}
}
}
Assert.Empty(offenders);
}
[Fact]
public void Every_Pki_Cmdlet_Resolves_The_Project_Rather_Than_Assuming_One()
{
// Optional without resolution would simply send an empty projectId, so confirm each cmdlet calls
// the resolver.
List<string> missing = new List<string>();
foreach (string typeName in PkiCmdletTypes)
{
Type type = ModuleAssembly.GetType(typeName, true);
MethodInfo processRecord = type.GetMethod("ProcessRecord", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
Assert.NotNull(processRecord);
if (!GetCalledMethodNames(processRecord).Contains("ResolveCertManagerProjectId"))
{
missing.Add(type.Name);
}
}
Assert.Empty(missing);
}
[Fact]
public void Resolver_Is_Available_To_Cmdlets_And_Returns_A_Project_Id()
{
MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)
.GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(resolver);
Assert.Equal(typeof(string), resolver.ReturnType);
ParameterInfo[] parameters = resolver.GetParameters();
Assert.Equal(2, parameters.Length);
Assert.Equal(typeof(PSInfisicalAPI.Connections.InfisicalConnection), parameters[0].ParameterType);
Assert.Equal(typeof(string), parameters[1].ParameterType);
}
[Fact]
public void An_Explicit_ProjectId_Short_Circuits_Resolution()
{
// Supplying the project must never trigger a lookup, so the explicit value has to be returned
// before any client is constructed.
PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet cmdlet =
new PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet();
MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)
.GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance);
// A null connection would throw the moment a lookup was attempted; returning cleanly proves it did not.
string result = (string)resolver.Invoke(cmdlet, new object[] { null, "explicit-project-id" });
Assert.Equal("explicit-project-id", result);
}
[Fact]
public void Certificate_Retrieval_By_Serial_Does_Not_Resolve_A_Project()
{
// Addressing a certificate by serial needs no project, so the Single parameter set must not pay
// for a lookup.
Type type = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateCmdlet", true);
PropertyInfo serialNumber = type.GetProperty("SerialNumber");
Assert.NotNull(serialNumber);
bool serialIsMandatoryOnSingle = false;
foreach (CustomAttributeData attribute in serialNumber.GetCustomAttributesData())
{
if (attribute.AttributeType != typeof(ParameterAttribute)) { continue; }
bool isSingle = false;
bool isMandatory = false;
foreach (CustomAttributeNamedArgument named in attribute.NamedArguments)
{
if (named.MemberName == "ParameterSetName" && (string)named.TypedValue.Value == "Single") { isSingle = true; }
if (named.MemberName == "Mandatory" && (bool)named.TypedValue.Value) { isMandatory = true; }
}
if (isSingle && isMandatory) { serialIsMandatoryOnSingle = true; }
}
Assert.True(serialIsMandatoryOnSingle);
}
[Fact]
public void Profiles_Can_Be_Filtered_To_An_Application_The_Way_The_Console_Groups_Them()
{
Type type = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateProfileCmdlet", true);
PropertyInfo applicationId = type.GetProperty("ApplicationId");
Assert.NotNull(applicationId);
Assert.Equal(typeof(string), applicationId.PropertyType);
PropertyInfo caId = type.GetProperty("CaId");
Assert.NotNull(caId);
}
[Fact]
public void Profile_Listing_Keeps_Its_Original_Overload_For_Existing_Callers()
{
Type clientType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalPkiClient", true);
MethodInfo original = clientType.GetMethod(
"ListCertificateProfiles",
BindingFlags.Public | BindingFlags.Instance,
null,
new[] { typeof(PSInfisicalAPI.Connections.InfisicalConnection), typeof(string), typeof(int?), typeof(int?), typeof(bool?) },
null);
Assert.NotNull(original);
MethodInfo filtered = clientType.GetMethod(
"ListCertificateProfiles",
BindingFlags.Public | BindingFlags.Instance,
null,
new[] { typeof(PSInfisicalAPI.Connections.InfisicalConnection), typeof(string), typeof(int?), typeof(int?), typeof(bool?), typeof(string), typeof(string) },
null);
Assert.NotNull(filtered);
}
private static List<string> GetCalledMethodNames(MethodInfo method)
{
List<string> names = new List<string>();
MethodBody body = method.GetMethodBody();
if (body == null) { return names; }
byte[] il = body.GetILAsByteArray();
if (il == null) { return names; }
const byte Call = 0x28;
const byte CallVirt = 0x6F;
for (int i = 0; i + 4 < il.Length; i++)
{
if (il[i] != Call && il[i] != CallVirt) { continue; }
int token = BitConverter.ToInt32(il, i + 1);
try
{
MethodBase resolved = method.Module.ResolveMethod(token);
if (resolved != null) { names.Add(resolved.Name); }
}
catch (ArgumentException)
{
}
}
return names;
}
}
}
@@ -18,7 +18,7 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("Name")]
public string ApplicationName { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
[Parameter(ParameterSetName = "List")] public int? Limit { get; set; }
@@ -29,6 +29,11 @@ namespace PSInfisicalAPI.Cmdlets
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal))
@@ -18,13 +18,18 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("CertificateProfileId")]
public string ProfileId { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
InfisicalCertificateApplicationEnrollment enrollment = client.GetCertificateApplicationEnrollment(connection, ApplicationId, ProfileId, ProjectId);
@@ -14,7 +14,7 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("Id")]
public string CaId { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
[Parameter(ParameterSetName = "List")]
[ValidateSet("Internal", "Acme", "Any")]
@@ -25,6 +25,11 @@ namespace PSInfisicalAPI.Cmdlets
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal))
@@ -16,7 +16,7 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("Id", "Identifier")]
public string SerialNumber { get; set; }
[Parameter(ParameterSetName = "List", Mandatory = true)] public string ProjectId { get; set; }
[Parameter(ParameterSetName = "List")] public string ProjectId { get; set; }
[Parameter(ParameterSetName = "List")] public string CommonName { get; set; }
[Parameter(ParameterSetName = "List")] public string FriendlyName { get; set; }
[Parameter(ParameterSetName = "List")] public string Search { get; set; }
@@ -59,6 +59,7 @@ namespace PSInfisicalAPI.Cmdlets
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
// Retrieval by serial addresses the certificate directly, so it needs no project.
if (string.Equals(ParameterSetName, "Single", StringComparison.Ordinal))
{
InfisicalCertificate cert = client.RetrieveCertificate(connection, SerialNumber);
@@ -70,6 +71,11 @@ namespace PSInfisicalAPI.Cmdlets
return;
}
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery
{
ProjectId = ProjectId,
@@ -14,7 +14,7 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("Id", "CertificatePolicyId")]
public string PolicyId { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
[Parameter(ParameterSetName = "List")] public int? Limit { get; set; }
@@ -25,6 +25,11 @@ namespace PSInfisicalAPI.Cmdlets
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal))
@@ -14,7 +14,19 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("Id", "CertificateProfileId")]
public string ProfileId { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
/// <summary>
/// Narrows the listing to one certificate application, which is how the Infisical console groups
/// profiles. See Get-InfisicalCertificateApplication.
/// </summary>
[Parameter(ParameterSetName = "List", ValueFromPipelineByPropertyName = true)]
public string ApplicationId { get; set; }
/// <summary>
/// Narrows the listing to profiles issued by one certificate authority.
/// </summary>
[Parameter(ParameterSetName = "List")] public string CaId { get; set; }
[Parameter(ParameterSetName = "List")] public int? Limit { get; set; }
@@ -27,6 +39,11 @@ namespace PSInfisicalAPI.Cmdlets
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal))
@@ -41,8 +58,12 @@ namespace PSInfisicalAPI.Cmdlets
}
bool? includeConfigs = MyInvocation.BoundParameters.ContainsKey("IncludeConfigs") ? (bool?)IncludeConfigs.IsPresent : null;
InfisicalCertificateProfile[] all = client.ListCertificateProfiles(connection, ProjectId, Limit, Offset, includeConfigs);
Logger.Information("Get-InfisicalCertificateProfile", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate profile(s)."));
InfisicalCertificateProfile[] all = client.ListCertificateProfiles(connection, ProjectId, Limit, Offset, includeConfigs, ApplicationId, CaId);
string scope = !string.IsNullOrEmpty(ApplicationId)
? string.Concat(" for application '", ApplicationId, "'")
: (!string.IsNullOrEmpty(CaId) ? string.Concat(" for certificate authority '", CaId, "'") : string.Empty);
Logger.Information("Get-InfisicalCertificateProfile", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate profile(s)", scope, "."));
foreach (InfisicalCertificateProfile profile in all)
{
WriteObject(profile);
@@ -14,13 +14,18 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("SubscriberName", "Slug")]
public string Name { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
if (string.Equals(ParameterSetName, "ByName", StringComparison.Ordinal))
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using System.Runtime.ExceptionServices;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Projects;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
@@ -13,9 +16,12 @@ namespace PSInfisicalAPI.Cmdlets
{
public abstract class InfisicalCmdletBase : PSCmdlet
{
private const string CertManagerProjectType = "cert-manager";
private IInfisicalLogger _logger;
private IInfisicalHttpClient _httpClient;
private bool? _isElevated;
private string _resolvedCertManagerProjectId;
protected IInfisicalLogger Logger
{
@@ -49,6 +55,69 @@ namespace PSInfisicalAPI.Cmdlets
return current != null && current.SkipCertificateCheck;
}
/// <summary>
/// Resolves the Certificate Manager project a PKI call should target.
/// <para>
/// The Infisical UI never asks which project to use: an organization with a single Certificate Manager
/// project has it selected implicitly, which is why the project appears only in the URL and everything
/// below it is presented as applications. The API behaves the same way - its own resolver takes the
/// single cert-manager project when there is exactly one - so requiring -ProjectId on every cmdlet made
/// the module stricter than the service it wraps.
/// </para>
/// <para>
/// This mirrors that rule client-side, which is necessary because several PKI endpoints carry the project
/// in the URL path and cannot defer to the server's resolver. Resolved once per cmdlet instance.
/// </para>
/// </summary>
protected string ResolveCertManagerProjectId(InfisicalConnection connection, string explicitValue)
{
if (!string.IsNullOrEmpty(explicitValue)) { return explicitValue; }
if (_resolvedCertManagerProjectId != null) { return _resolvedCertManagerProjectId; }
InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger);
InfisicalProject[] projects = client.List(connection, CertManagerProjectType, false);
List<InfisicalProject> certManagerProjects = new List<InfisicalProject>();
if (projects != null)
{
foreach (InfisicalProject project in projects)
{
if (project == null) { continue; }
if (string.Equals(project.Type, CertManagerProjectType, StringComparison.OrdinalIgnoreCase))
{
certManagerProjects.Add(project);
}
}
}
if (certManagerProjects.Count == 0)
{
throw new InfisicalConfigurationException(
"This organization has no Certificate Manager project, so there is nothing to resolve -ProjectId to. Create one in Infisical, or pass -ProjectId explicitly.");
}
if (certManagerProjects.Count > 1)
{
List<string> described = new List<string>();
foreach (InfisicalProject project in certManagerProjects)
{
described.Add(string.Concat("'", project.Name ?? project.Slug, "' (", project.Id, ")"));
}
throw new InfisicalConfigurationException(string.Concat(
"This organization has ", certManagerProjects.Count.ToString(CultureInfo.InvariantCulture),
" Certificate Manager projects, so -ProjectId cannot be resolved automatically. Pass it explicitly. Available: ",
string.Join(", ", described.ToArray()), "."));
}
_resolvedCertManagerProjectId = certManagerProjects[0].Id;
Logger.Verbose(GetType().Name, string.Concat(
"-ProjectId was not supplied; resolved the organization's only Certificate Manager project '",
certManagerProjects[0].Name ?? certManagerProjects[0].Slug, "' (", _resolvedCertManagerProjectId, ")."));
return _resolvedCertManagerProjectId;
}
/// <summary>
/// Reports whether the host process is running elevated. Evaluated through the PowerShell engine rather
/// than WindowsIdentity directly, because the module targets netstandard2.0 and does not carry a
@@ -28,7 +28,7 @@ namespace PSInfisicalAPI.Cmdlets
[Alias("ProfileId")]
public string CertificateProfileId { get; set; }
[Parameter(Mandatory = true)] public string ProjectId { get; set; }
[Parameter] public string ProjectId { get; set; }
[Parameter] public IDictionary Subject { get; set; }
[Parameter] public string CommonName { get; set; }
[Parameter] public string Country { get; set; }
@@ -85,6 +85,11 @@ namespace PSInfisicalAPI.Cmdlets
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
// The UI never asks which Certificate Manager project to use when an organization has only
// one; -ProjectId is optional here for the same reason. Assigned back so every call below
// sees the resolved value without threading a second variable through.
ProjectId = ResolveCertManagerProjectId(connection, ProjectId);
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
// Resolved once so reuse detection looks in the same stores the install will write to.
@@ -530,6 +530,16 @@ namespace PSInfisicalAPI.Pki
}
public InfisicalCertificateProfile[] ListCertificateProfiles(InfisicalConnection connection, string projectId, int? limit, int? offset, bool? includeConfigs)
{
return ListCertificateProfiles(connection, projectId, limit, offset, includeConfigs, null, null);
}
/// <summary>
/// Lists certificate profiles, optionally narrowed to one application or issuing CA. Applications are
/// how the UI groups profiles, so filtering by application is what makes a script read the way the
/// console does.
/// </summary>
public InfisicalCertificateProfile[] ListCertificateProfiles(InfisicalConnection connection, string projectId, int? limit, int? offset, bool? includeConfigs, string applicationId, string caId)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
if (string.IsNullOrEmpty(projectId)) { throw new InfisicalConfigurationException("ProjectId is required."); }
@@ -541,6 +551,8 @@ namespace PSInfisicalAPI.Pki
if (limit.HasValue) { query.Add(new KeyValuePair<string, string>("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); }
if (offset.HasValue) { query.Add(new KeyValuePair<string, string>("offset", offset.Value.ToString(CultureInfo.InvariantCulture))); }
if (includeConfigs.HasValue) { query.Add(new KeyValuePair<string, string>("includeConfigs", includeConfigs.Value ? "true" : "false")); }
if (!string.IsNullOrEmpty(applicationId)) { query.Add(new KeyValuePair<string, string>("applicationId", applicationId)); }
if (!string.IsNullOrEmpty(caId)) { query.Add(new KeyValuePair<string, string>("caId", caId)); }
try
{