Skip to content

Commit d9eeec6

Browse files
spbsolubleclaude
andauthored
Ab#82568 (#51)
## Summary This PR adds several new API capabilities and bug fixes targeting Keyfactor Command v25+: - **Applications API** — Full CRUD support (`List`, `Get`, `Create`, `Update`, `Delete`) for the `/Applications` endpoint, including all schedule types and backwards compatibility for Command versions prior to v25 - **PAM Providers & Types** — Full CRUD for `/PamProviders` and `/PamProviders/Types`, with a `GetPamProviderByName` helper; model fixes for `ProviderType.Name` and store `Password` field types - **Enrollment Patterns** — Full CRUD for `/EnrollmentPattern`, with new model fields; PFX enrollments can now specify `EnrollmentPatternId` or `Template` (rather than requiring both) - **Certificate enhancements** — New fields on `GetCertificateResponse` (owner role, alt key info, curve, etc.), CSR enrollment args expanded, base64 response from `DownloadCertificate`, `findLeafCert` helper, and graceful handling of ed448 keys - **Store improvements** — Immediate inventory scheduling, `PUT` method capitalization fix, improved error messaging when deserializing store responses, password config model alignment between create/update - **Store types** — Paginate `ListStoreTypes` to avoid truncation on large deployments --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f5af6db commit d9eeec6

10 files changed

Lines changed: 559 additions & 33 deletions

v3/api/application.go

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Copyright 2024 Keyfactor
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"log"
21+
"strconv"
22+
"strings"
23+
)
24+
25+
// isLegacyContainerAPI returns true when the server is pre-v25 and uses
26+
// CertificateStoreContainers instead of the v25+ Applications endpoint.
27+
// Both endpoints accept and return the same JSON schedule format.
28+
func (c *Client) isLegacyContainerAPI() bool {
29+
v := c.AuthClient.GetCommandVersion()
30+
major := commandVersionMajor(v)
31+
return major > 0 && major < 25
32+
}
33+
34+
// commandVersionMajor parses the major version number from a product version
35+
// string such as "24.4.0.0" or "25.1.0.0". Returns 0 if unparseable.
36+
func commandVersionMajor(version string) int {
37+
if version == "" {
38+
return 0
39+
}
40+
parts := strings.SplitN(version, ".", 2)
41+
major, err := strconv.Atoi(parts[0])
42+
if err != nil {
43+
return 0
44+
}
45+
return major
46+
}
47+
48+
// appEndpoint returns the base API endpoint for the connected Command version.
49+
// Pre-v25 uses CertificateStoreContainers; v25+ uses Applications.
50+
// Both endpoints share the same JSON request/response format.
51+
func (c *Client) appEndpoint() string {
52+
if c.isLegacyContainerAPI() {
53+
return "CertificateStoreContainers"
54+
}
55+
return "Applications"
56+
}
57+
58+
// ListApplications returns all applications/containers.
59+
func (c *Client) ListApplications() ([]ApplicationListItem, error) {
60+
log.Println("[INFO] Listing applications.")
61+
62+
headers := &apiHeaders{
63+
Headers: []StringTuple{
64+
{"x-keyfactor-api-version", "1"},
65+
{"x-keyfactor-requested-with", "APIClient"},
66+
},
67+
}
68+
69+
req := &request{
70+
Method: "GET",
71+
Endpoint: c.appEndpoint(),
72+
Headers: headers,
73+
}
74+
75+
resp, err := c.sendRequest(req)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
var result []ApplicationListItem
81+
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
82+
return nil, err
83+
}
84+
return result, nil
85+
}
86+
87+
// GetApplication returns the full details of an application/container by integer ID.
88+
func (c *Client) GetApplication(id int) (*ApplicationResponse, error) {
89+
log.Printf("[INFO] Fetching application with ID %d.", id)
90+
91+
headers := &apiHeaders{
92+
Headers: []StringTuple{
93+
{"x-keyfactor-api-version", "1"},
94+
{"x-keyfactor-requested-with", "APIClient"},
95+
},
96+
}
97+
98+
req := &request{
99+
Method: "GET",
100+
Endpoint: fmt.Sprintf("%s/%d", c.appEndpoint(), id),
101+
Headers: headers,
102+
}
103+
104+
resp, err := c.sendRequest(req)
105+
if err != nil {
106+
return nil, err
107+
}
108+
109+
var result ApplicationResponse
110+
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
111+
return nil, err
112+
}
113+
return &result, nil
114+
}
115+
116+
// GetApplicationByName returns the application matching the given name by
117+
// listing all applications and then fetching the matching one by ID.
118+
// Returns an error if no application with that name exists.
119+
func (c *Client) GetApplicationByName(name string) (*ApplicationResponse, error) {
120+
log.Printf("[INFO] Fetching application with name %q.", name)
121+
122+
apps, err := c.ListApplications()
123+
if err != nil {
124+
return nil, err
125+
}
126+
127+
for _, app := range apps {
128+
if app.Name == name {
129+
return c.GetApplication(app.Id)
130+
}
131+
}
132+
return nil, fmt.Errorf("application %q not found", name)
133+
}
134+
135+
// CreateApplication creates a new application/container and returns the created resource.
136+
func (c *Client) CreateApplication(createReq *ApplicationCreateRequest) (*ApplicationResponse, error) {
137+
log.Println("[INFO] Creating application.")
138+
139+
headers := &apiHeaders{
140+
Headers: []StringTuple{
141+
{"x-keyfactor-api-version", "1"},
142+
{"x-keyfactor-requested-with", "APIClient"},
143+
{"Content-Type", "application/json"},
144+
},
145+
}
146+
147+
req := &request{
148+
Method: "POST",
149+
Endpoint: c.appEndpoint(),
150+
Headers: headers,
151+
Payload: createReq,
152+
}
153+
154+
resp, err := c.sendRequest(req)
155+
if err != nil {
156+
return nil, err
157+
}
158+
159+
var result ApplicationResponse
160+
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
161+
return nil, err
162+
}
163+
return &result, nil
164+
}
165+
166+
// UpdateApplication performs a full replacement of an existing application/container.
167+
// For v25+ the API uses PUT /Applications with the ID in the body.
168+
// For pre-v25 the API uses PUT /CertificateStoreContainers/{id} with the ID in the path.
169+
func (c *Client) UpdateApplication(id int, updateReq *ApplicationUpdateRequest) (*ApplicationResponse, error) {
170+
log.Printf("[INFO] Updating application with ID %d.", id)
171+
172+
updateReq.Id = id
173+
174+
headers := &apiHeaders{
175+
Headers: []StringTuple{
176+
{"x-keyfactor-api-version", "1"},
177+
{"x-keyfactor-requested-with", "APIClient"},
178+
{"Content-Type", "application/json"},
179+
},
180+
}
181+
182+
// Both pre-v25 and v25+ use PUT /{endpoint} with the ID in the request body.
183+
endpoint := c.appEndpoint()
184+
185+
req := &request{
186+
Method: "PUT",
187+
Endpoint: endpoint,
188+
Headers: headers,
189+
Payload: updateReq,
190+
}
191+
192+
resp, err := c.sendRequest(req)
193+
if err != nil {
194+
return nil, err
195+
}
196+
197+
var result ApplicationResponse
198+
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
199+
return nil, err
200+
}
201+
return &result, nil
202+
}
203+
204+
// DeleteApplication deletes an application/container by its integer ID.
205+
// The server returns 204 No Content on success.
206+
func (c *Client) DeleteApplication(id int) error {
207+
log.Printf("[INFO] Deleting application with ID %d.", id)
208+
209+
headers := &apiHeaders{
210+
Headers: []StringTuple{
211+
{"x-keyfactor-api-version", "1"},
212+
{"x-keyfactor-requested-with", "APIClient"},
213+
},
214+
}
215+
216+
req := &request{
217+
Method: "DELETE",
218+
Endpoint: fmt.Sprintf("%s/%d", c.appEndpoint(), id),
219+
Headers: headers,
220+
}
221+
222+
resp, err := c.sendRequest(req)
223+
if err != nil {
224+
return err
225+
}
226+
227+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
228+
return fmt.Errorf("failed to delete application: HTTP %d", resp.StatusCode)
229+
}
230+
231+
return nil
232+
}

v3/api/application_models.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2024 Keyfactor
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package api
16+
17+
// ApplicationScheduleInterval defines an interval-based inventory schedule.
18+
type ApplicationScheduleInterval struct {
19+
Minutes int `json:"Minutes"`
20+
}
21+
22+
// ApplicationScheduleDaily defines a daily time-based inventory schedule.
23+
// Also reused as the shape for ExactlyOnce.
24+
type ApplicationScheduleDaily struct {
25+
Time string `json:"Time"` // ISO 8601 datetime string (e.g. "2023-11-25T23:30:00Z")
26+
}
27+
28+
// ApplicationScheduleWeekly defines a weekly inventory schedule.
29+
// Days are weekday names ("Sunday"…"Saturday"); Time is an ISO 8601 UTC datetime.
30+
type ApplicationScheduleWeekly struct {
31+
Days []string `json:"Days"` // e.g. ["Monday", "Wednesday"]
32+
Time string `json:"Time"` // ISO 8601 datetime string
33+
}
34+
35+
// ApplicationScheduleMonthly defines a monthly inventory schedule.
36+
// Day is the day-of-month (1–31); Time is an ISO 8601 UTC datetime.
37+
type ApplicationScheduleMonthly struct {
38+
Day int `json:"Day"`
39+
Time string `json:"Time"` // ISO 8601 datetime string
40+
}
41+
42+
// ApplicationSchedule holds the schedule configuration for an application.
43+
// Set exactly one field; omit all to disable the schedule (Off).
44+
//
45+
// - Immediate: run once immediately (server may convert to ExactlyOnce on next read)
46+
// - Interval: run every N minutes
47+
// - Daily: run at the same time each day
48+
// - Weekly: run on specific weekdays at a given time
49+
// - Monthly: run on a specific day of each month at a given time
50+
// - ExactlyOnce: run exactly once at the specified time
51+
type ApplicationSchedule struct {
52+
Immediate *bool `json:"Immediate,omitempty"`
53+
Interval *ApplicationScheduleInterval `json:"Interval,omitempty"`
54+
Daily *ApplicationScheduleDaily `json:"Daily,omitempty"`
55+
Weekly *ApplicationScheduleWeekly `json:"Weekly,omitempty"`
56+
Monthly *ApplicationScheduleMonthly `json:"Monthly,omitempty"`
57+
ExactlyOnce *ApplicationScheduleDaily `json:"ExactlyOnce,omitempty"`
58+
}
59+
60+
// ApplicationCertStore is a minimal certificate store reference within an application detail response.
61+
type ApplicationCertStore struct {
62+
Id string `json:"Id"` // Store GUID (UUID)
63+
}
64+
65+
// ApplicationListItem represents one entry returned by GET /Applications (list endpoint).
66+
// The Schedule field is returned as a cron expression string by the list endpoint.
67+
type ApplicationListItem struct {
68+
Id int `json:"Id"`
69+
Name string `json:"Name"`
70+
Schedule string `json:"Schedule"`
71+
}
72+
73+
// ApplicationResponse is the full application detail returned by GET /Applications/{id}.
74+
type ApplicationResponse struct {
75+
Id int `json:"Id"`
76+
Name string `json:"Name"`
77+
OverwriteSchedules bool `json:"OverwriteSchedules"`
78+
Schedule *ApplicationSchedule `json:"Schedule,omitempty"`
79+
CertificateStores []ApplicationCertStore `json:"CertificateStores,omitempty"`
80+
}
81+
82+
// ApplicationCreateRequest is the request body for POST /Applications.
83+
type ApplicationCreateRequest struct {
84+
Name string `json:"Name"`
85+
OverwriteSchedules bool `json:"OverwriteSchedules"`
86+
Schedule *ApplicationSchedule `json:"Schedule,omitempty"`
87+
}
88+
89+
// ApplicationUpdateRequest is the request body for PUT /Applications/{id}.
90+
// The Id field is set automatically by UpdateApplication.
91+
type ApplicationUpdateRequest struct {
92+
Id int `json:"Id"`
93+
Name string `json:"Name"`
94+
OverwriteSchedules bool `json:"OverwriteSchedules"`
95+
Schedule *ApplicationSchedule `json:"Schedule,omitempty"`
96+
}

v3/api/certificate.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -299,27 +299,58 @@ func (c *Client) DownloadCertificate(
299299
return nil, nil, &jsonResp.Content, p7bErr
300300
}
301301

302-
var leaf *x509.Certificate
302+
leaf := findLeafCert(certs)
303303
if len(certs) > 1 {
304-
//leaf is last cert in chain
305-
leaf = certs[0] // First cert in chain is the leaf
306304
return leaf, certs, &jsonResp.Content, nil
307305
}
306+
return leaf, nil, &jsonResp.Content, nil
307+
}
308+
309+
// findLeafCert returns the end-entity (leaf) certificate from a set of
310+
// certificates. It identifies the leaf as the cert whose Subject is not used
311+
// as an Issuer by any other cert in the set — i.e. nothing is signed by it.
312+
// This is order-independent and handles both root-first and leaf-first P7Bs.
313+
//
314+
// When the set contains only one cert, or when the algorithm cannot determine
315+
// a unique leaf (e.g. all certs are self-signed), certs[0] is returned as a
316+
// safe fallback.
317+
func findLeafCert(certs []*x509.Certificate) *x509.Certificate {
318+
if len(certs) == 0 {
319+
return nil
320+
}
321+
if len(certs) == 1 {
322+
return certs[0]
323+
}
308324

309-
return certs[0], nil, &jsonResp.Content, nil
325+
// Build a set of all RawIssuer values (subjects that issued something).
326+
issuers := make(map[string]bool, len(certs))
327+
for _, c := range certs {
328+
issuers[string(c.RawIssuer)] = true
329+
}
330+
331+
// The leaf's Subject is not in the issuers set.
332+
for _, c := range certs {
333+
if !issuers[string(c.RawSubject)] {
334+
return c
335+
}
336+
}
337+
338+
// Fallback: cannot distinguish (e.g. single self-signed cert in multi-cert set).
339+
return certs[0]
310340
}
311341

312342
// EnrollCSR takes arguments for EnrollCSRFctArgs to enroll a passed Certificate Signing
313343
// Request with Keyfactor. An EnrollResponse containing a signed certificate is returned upon successful
314344
// enrollment. Required fields to complete a CSR enrollment are:
315345
// - CSR : string
316-
// - Template : string
346+
// - Template : string (or EnrollmentPatternId on Command v25+)
317347
// - CertificateAuthority : string
318348
func (c *Client) EnrollCSR(ea *EnrollCSRFctArgs) (*EnrollResponse, error) {
319349
log.Println("[INFO] Signing CSR with Keyfactor")
320350

321-
/* Ensure required inputs exist */
322-
if (ea.Template == "") || (ea.CertificateAuthority == "") {
351+
/* Ensure required inputs exist.
352+
On Command v25+ an EnrollmentPatternId can substitute for Template. */
353+
if (ea.Template == "" && ea.EnrollmentPatternId == 0) || (ea.CertificateAuthority == "") {
323354
return nil, errors.New("invalid or nonexistent values required for csr enrollment")
324355
}
325356

0 commit comments

Comments
 (0)