Resolve to the organization's active cert-manager project instead of erroring

Several Certificate Manager projects in one organization is a normal
configuration, not an ambiguity to reject. Infisical designates one as the
organization's active project and serves certificate applications only from it:

  if (req.internalCertManagerProjectId !== activeProjectId) {
    throw new BadRequestError({ message: "Applications are only available on
    this organization's active Certificate Manager project." });
  }

So an application-centric workflow is single-project by design, and resolving to
the active project is what makes it work. Resolution now picks that project when
several exist, falling back to the first - saying so on the verbose stream -
when the organization designates none. Only an organization with no Certificate
Manager project at all still errors, because there is genuinely nothing to
resolve to.

Adds InfisicalOrganization.DefaultCertManagerProjectId, which is what the
organization record calls its active project, so the choice is read rather than
guessed.

Moves Get-InfisicalProject onto /api/v1/projects. /api/v1/workspace mounts
Infisical's deprecated project router; it is retained as a fallback candidate so
older servers keep working, and the endpoint shape test now expects the current
route.

The end-to-end README example drops to the four calls that actually do the work:
find the application, pick its profile, gather SANs, request. The project lookup
is gone because -ProjectId resolves itself, and the CA lookup is gone because the
profile already binds its issuing CA and -InstallChain installs the chain
regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:58:56 -04:00
parent 633f40c1fa
commit 276958e3a8
12 changed files with 162 additions and 44 deletions
+13 -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.1958
- Build produced from commit 633f40c1fa54.
## Unreleased (carried forward)
## 2026.07.31.1924
- Build produced from commit 93b0cc1924ec.
## Unreleased (carried forward)
## Unreleased (carried forward)
## 2026.07.31.0045
@@ -42,6 +48,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos
## Unreleased (carried forward)
### Changed (project scoping, follow-up)
- Several Certificate Manager projects in one organization is **no longer an error**. Infisical designates one as the organization's active project and serves certificate applications only from it, so resolution now picks that one; when none is designated the first is used and the verbose line says so.
- `InfisicalOrganization.DefaultCertManagerProjectId` exposes the organization's active Certificate Manager project.
- `Get-InfisicalProject` now calls `/api/v1/projects`, keeping the previously used `/api/v1/workspace` as a fallback candidate — that route mounts Infisical's deprecated project router.
### 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.
+2 -2
View File
@@ -1,6 +1,6 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.07.31.1924'
ModuleVersion = '2026.07.31.1958'
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 = '93b0cc1924ec'
CommitHash = '633f40c1fa54'
}
}
}
Binary file not shown.
+23 -28
View File
@@ -161,43 +161,22 @@ $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specializ
$Connection = Connect-Infisical @ConnectInfisicalParameters
$Project = Get-InfisicalProject -Type cert-manager | Select-Object -First 1
$Application = Get-InfisicalCertificateApplication | Where-Object {($_.Name -ieq '2pint')}
$Project
#region Certificate authorities. Not required for profile issuance - the profile already binds its CA - but
# useful for confirming the chain you expect to be installed.
$CAList = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal
$RootCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $True)} | Select-Object -First 1
$RootCA
$IntermediateCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $False)}
$IntermediateCA
#endregion
$CertificateProfile = Get-InfisicalCertificateProfile -ProjectId ($Project.Id) -IncludeConfigs |
Where-Object {($_.EnrollmentType -iin @('API')) -and ($_.Slug -imatch '.*Server.*')} |
$CertificateProfile = Get-InfisicalCertificateProfile -ApplicationId ($Application.Id) -IncludeConfigs |
Where-Object {($_.EnrollmentType -ieq 'api') -and ($_.Slug -imatch 'server')} |
Select-Object -First 1
$CertificateProfile
$SanList = Get-InfisicalSANList
$SanList
$RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
$RequestInfisicalCertificateParameters.ProjectId = $Project.Id
$RequestInfisicalCertificateParameters.CertificateProfileId = $CertificateProfile.Id
$RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper()
$RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]'
$RequestInfisicalCertificateParameters.DnsName.AddRange($SanList)
$RequestInfisicalCertificateParameters.DnsName.Add('app.contoso.com')
$RequestInfisicalCertificateParameters.DnsName.Add('api.contoso.com')
$RequestInfisicalCertificateParameters.DnsName.Add('boot.contoso.com')
$RequestInfisicalCertificateParameters.Ttl = '90d'
$RequestInfisicalCertificateParameters.Metadata = [Ordered]@{ Environment = 'Production'; Owner = 'Platform' }
$RequestInfisicalCertificateParameters.Install = $True
$RequestInfisicalCertificateParameters.InstallChain = $True
$RequestInfisicalCertificateParameters.Verbose = $True
@@ -207,6 +186,8 @@ $Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParamete
$Null = Disconnect-Infisical -Verbose
```
Four calls: find the application, pick its profile, gather SANs, request. No project lookup — `-ProjectId` resolves itself — and no CA lookup, because the profile already binds its issuing CA and `-InstallChain` installs the whole chain regardless.
Note `$CertificateProfile` rather than `$Profile`: `$Profile` is an automatic variable in PowerShell (the path to the current profile script), and assigning to it works but shadows something the host relies on.
`-StoreName`/`-StoreLocation` are omitted deliberately — see [Where certificates get installed](#where-certificates-get-installed).
@@ -235,15 +216,29 @@ VERBOSE: -ProjectId was not supplied; resolved the organization's only Certifica
'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:
Several Certificate Manager projects in one organization is not an error. Infisical designates one as the organization's **active** project, and that is what resolution picks:
```text
This organization has 2 Certificate Manager projects, so -ProjectId cannot be resolved automatically.
Pass it explicitly. Available: 'Platform PKI' (aaaa...), 'Lab PKI' (bbbb...).
VERBOSE: -ProjectId was not supplied; resolved the organization's active Certificate Manager project
'Platform PKI' (aaaa...).
```
If no active project is designated, the first is used and the verbose line says so; pass `-ProjectId` to target another.
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.
#### One project per organization, in practice
An organization *can* hold several Certificate Manager projects, but **certificate applications are served only from the active one**. The applications router rejects anything else outright:
```ts
if (req.internalCertManagerProjectId !== activeProjectId) {
throw new BadRequestError({ message: "Applications are only available on this organization's active Certificate Manager project." });
}
```
So an application-centric workflow is single-project by design. Additional Certificate Manager projects can exist and hold their own CAs, policies, profiles, and certificates, but they are reachable only by passing `-ProjectId` explicitly, and applications will not work in them.
#### Projects contain applications
The two are different levels, which is worth keeping straight when reading output:
@@ -50,7 +50,9 @@ namespace PSInfisicalAPI.Tests
[InlineData(InfisicalEndpointNames.CreateSecret, "POST", "/api/v3/secrets/raw/{secretName}")]
[InlineData(InfisicalEndpointNames.UpdateSecret, "PATCH", "/api/v3/secrets/raw/{secretName}")]
[InlineData(InfisicalEndpointNames.DeleteSecret, "DELETE", "/api/v3/secrets/raw/{secretName}")]
[InlineData(InfisicalEndpointNames.ListProjects, "GET", "/api/v1/workspace")]
// /api/v1/workspace mounts Infisical's deprecated project router; /api/v1/projects is the current one
// and is preferred, with the deprecated route retained as a fallback candidate.
[InlineData(InfisicalEndpointNames.ListProjects, "GET", "/api/v1/projects")]
[InlineData(InfisicalEndpointNames.RetrieveProject, "GET", "/api/v1/workspace/{projectId}")]
[InlineData(InfisicalEndpointNames.CreateProject, "POST", "/api/v2/workspace")]
[InlineData(InfisicalEndpointNames.UpdateProject, "PATCH", "/api/v1/workspace/{projectId}")]
@@ -92,6 +92,45 @@ namespace PSInfisicalAPI.Tests
Assert.Equal(typeof(string), parameters[1].ParameterType);
}
[Fact]
public void Resolution_Does_Not_Error_When_An_Organization_Has_Several_Projects()
{
// Certificate applications are served only from the organization's active project, so several
// Certificate Manager projects is a normal configuration rather than an ambiguity to reject.
MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)
.GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance);
List<string> called = GetCalledMethodNames(resolver);
Assert.Contains("FindActiveCertManagerProject", called);
MethodInfo finder = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)
.GetMethod("FindActiveCertManagerProject", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(finder);
Assert.Equal(typeof(PSInfisicalAPI.Models.InfisicalProject), finder.ReturnType);
}
[Fact]
public void The_Organizations_Active_Cert_Manager_Project_Is_Modelled()
{
PropertyInfo property = typeof(PSInfisicalAPI.Models.InfisicalOrganization)
.GetProperty("DefaultCertManagerProjectId");
Assert.NotNull(property);
Assert.Equal(typeof(string), property.PropertyType);
}
[Fact]
public void Project_Listing_Prefers_The_Current_Route_Over_The_Deprecated_One()
{
// /api/v1/workspace mounts Infisical's deprecated project router; /api/v1/projects is current.
IReadOnlyList<PSInfisicalAPI.Endpoints.InfisicalEndpointDefinition> candidates =
PSInfisicalAPI.Endpoints.InfisicalEndpointRegistry.GetCandidates(
PSInfisicalAPI.Endpoints.InfisicalEndpointNames.ListProjects);
Assert.True(candidates.Count >= 2, "both the current and deprecated routes should be registered");
Assert.Equal("/api/v1/projects", candidates[0].Template);
Assert.Contains(candidates, c => c.Template == "/api/v1/workspace");
}
[Fact]
public void An_Explicit_ProjectId_Short_Circuits_Resolution()
{
@@ -7,6 +7,7 @@ using System.Runtime.ExceptionServices;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Organizations;
using PSInfisicalAPI.Projects;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
@@ -96,28 +97,77 @@ namespace PSInfisicalAPI.Cmdlets
"This organization has no Certificate Manager project, so there is nothing to resolve -ProjectId to. Create one in Infisical, or pass -ProjectId explicitly.");
}
InfisicalProject chosen = certManagerProjects[0];
string reason = "the organization's only Certificate Manager project";
if (certManagerProjects.Count > 1)
{
List<string> described = new List<string>();
foreach (InfisicalProject project in certManagerProjects)
// More than one is not an error. Infisical designates one of them as the organization's active
// Certificate Manager project, and certificate applications are only served from that one, so
// resolving to it is what makes an application-centric script work.
InfisicalProject active = FindActiveCertManagerProject(connection, certManagerProjects);
if (active != null)
{
described.Add(string.Concat("'", project.Name ?? project.Slug, "' (", project.Id, ")"));
chosen = active;
reason = "the organization's active Certificate Manager project";
}
else
{
reason = string.Concat(
"the first of ", certManagerProjects.Count.ToString(CultureInfo.InvariantCulture),
" Certificate Manager projects (no active project is set on the organization; pass -ProjectId to choose another)");
}
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;
_resolvedCertManagerProjectId = chosen.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, ")."));
"-ProjectId was not supplied; resolved ", reason, ": '",
chosen.Name ?? chosen.Slug, "' (", _resolvedCertManagerProjectId, ")."));
return _resolvedCertManagerProjectId;
}
/// <summary>
/// Finds the organization's active Certificate Manager project among the candidates. Certificate
/// applications are served only from this project, so when several exist it is the one a PKI call
/// should target. Returns null when the organization designates none, leaving the caller to fall back.
/// </summary>
private InfisicalProject FindActiveCertManagerProject(InfisicalConnection connection, List<InfisicalProject> candidates)
{
try
{
InfisicalOrganizationClient organizationClient = new InfisicalOrganizationClient(HttpClient, Logger);
InfisicalOrganization[] organizations = organizationClient.List(connection);
if (organizations == null) { return null; }
string organizationId = connection != null ? connection.OrganizationId : null;
foreach (InfisicalOrganization organization in organizations)
{
if (organization == null || string.IsNullOrEmpty(organization.DefaultCertManagerProjectId)) { continue; }
if (!string.IsNullOrEmpty(organizationId)
&& !string.Equals(organization.Id, organizationId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
foreach (InfisicalProject candidate in candidates)
{
if (string.Equals(candidate.Id, organization.DefaultCertManagerProjectId, StringComparison.OrdinalIgnoreCase))
{
return candidate;
}
}
}
}
catch (Exception exception)
{
if (IsPipelineControlException(exception)) { throw; }
Logger.Verbose(GetType().Name, string.Concat("Could not read the organization's active Certificate Manager project (continuing): ", exception.Message));
}
return null;
}
/// <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
@@ -288,6 +288,18 @@ namespace PSInfisicalAPI.Endpoints
private static void RegisterProjects(Dictionary<string, List<InfisicalEndpointDefinition>> map)
{
// /api/v1/projects is the current route; /api/v1/workspace mounts Infisical's deprecated project
// router and is kept only as a fallback for older servers.
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.ListProjects,
Resource = "Projects",
Version = "v1",
Method = "GET",
Template = "/api/v1/projects",
RequiresAuthorization = true
});
Add(map, new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.ListProjects,
@@ -9,6 +9,12 @@ namespace PSInfisicalAPI.Models
public string Slug { get; set; }
public string CustomerId { get; set; }
public bool AuthEnforced { get; set; }
/// <summary>
/// The organization's active Certificate Manager project. Certificate applications are only available on
/// this project, so it is what a PKI call resolves to when an organization has more than one.
/// </summary>
public string DefaultCertManagerProjectId { get; set; }
public bool ScimEnabled { get; set; }
public DateTimeOffset? CreatedAtUtc { get; set; }
public DateTimeOffset? UpdatedAtUtc { get; set; }
@@ -11,6 +11,7 @@ namespace PSInfisicalAPI.Organizations
[JsonProperty("slug")] public string Slug { get; set; }
[JsonProperty("customerId")] public string CustomerId { get; set; }
[JsonProperty("authEnforced")] public bool AuthEnforced { get; set; }
[JsonProperty("defaultCertManagerProjectId", NullValueHandling = NullValueHandling.Ignore)] public string DefaultCertManagerProjectId { get; set; }
[JsonProperty("scimEnabled")] public bool ScimEnabled { get; set; }
[JsonProperty("createdAt")] public string CreatedAt { get; set; }
[JsonProperty("updatedAt")] public string UpdatedAt { get; set; }
@@ -21,6 +21,7 @@ namespace PSInfisicalAPI.Organizations
Slug = dto.Slug,
CustomerId = dto.CustomerId,
AuthEnforced = dto.AuthEnforced,
DefaultCertManagerProjectId = dto.DefaultCertManagerProjectId,
ScimEnabled = dto.ScimEnabled,
CreatedAtUtc = ParseTimestamp(dto.CreatedAt),
UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt)
@@ -45,7 +45,7 @@ namespace PSInfisicalAPI.Projects
try
{
_logger.Information(Component, "Attempting to list Infisical projects. Please Wait...");
InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListProjects, "ListProjects", null, queryParameters, null);
InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.ListProjects, "ListProjects", null, queryParameters, null);
InfisicalProjectListResponseDto dto = _serializer.Deserialize<InfisicalProjectListResponseDto>(response.Body);
response.Clear();