diff --git a/CHANGELOG.md b/CHANGELOG.md index 894579b..1ab6f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 63ab803..9ac6be4 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -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' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index eb4e942..69a2d2c 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/README.md b/README.md index 42d034e..a57f620 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs index f45456b..4e70f13 100644 --- a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs +++ b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs @@ -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}")] diff --git a/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs b/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs index 67b15f7..63cefa8 100644 --- a/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs +++ b/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs @@ -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 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 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() { diff --git a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs index 8785008..1f9b3be 100644 --- a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs +++ b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs @@ -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 described = new List(); - 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; } + /// + /// 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. + /// + private InfisicalProject FindActiveCertManagerProject(InfisicalConnection connection, List 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; + } + /// /// 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 diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index ad98ee2..fb35c90 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -288,6 +288,18 @@ namespace PSInfisicalAPI.Endpoints private static void RegisterProjects(Dictionary> 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, diff --git a/src/PSInfisicalAPI/Models/InfisicalOrganization.cs b/src/PSInfisicalAPI/Models/InfisicalOrganization.cs index bace43e..cac132d 100644 --- a/src/PSInfisicalAPI/Models/InfisicalOrganization.cs +++ b/src/PSInfisicalAPI/Models/InfisicalOrganization.cs @@ -9,6 +9,12 @@ namespace PSInfisicalAPI.Models public string Slug { get; set; } public string CustomerId { get; set; } public bool AuthEnforced { get; set; } + + /// + /// 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. + /// + public string DefaultCertManagerProjectId { get; set; } public bool ScimEnabled { get; set; } public DateTimeOffset? CreatedAtUtc { get; set; } public DateTimeOffset? UpdatedAtUtc { get; set; } diff --git a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs index 9122bb9..48a07f7 100644 --- a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs +++ b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs @@ -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; } diff --git a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs index 01f986b..b82b8e2 100644 --- a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs +++ b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs @@ -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) diff --git a/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs b/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs index 900f29a..3f7973d 100644 --- a/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs +++ b/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs @@ -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(response.Body); response.Clear();