Skip to content
232 changes: 232 additions & 0 deletions v3/api/application.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Copyright 2024 Keyfactor
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
)

// isLegacyContainerAPI returns true when the server is pre-v25 and uses
// CertificateStoreContainers instead of the v25+ Applications endpoint.
// Both endpoints accept and return the same JSON schedule format.
func (c *Client) isLegacyContainerAPI() bool {
v := c.AuthClient.GetCommandVersion()
major := commandVersionMajor(v)
return major > 0 && major < 25
}

// commandVersionMajor parses the major version number from a product version
// string such as "24.4.0.0" or "25.1.0.0". Returns 0 if unparseable.
func commandVersionMajor(version string) int {
if version == "" {
return 0
}
parts := strings.SplitN(version, ".", 2)
major, err := strconv.Atoi(parts[0])
if err != nil {
return 0
}
return major
}

// appEndpoint returns the base API endpoint for the connected Command version.
// Pre-v25 uses CertificateStoreContainers; v25+ uses Applications.
// Both endpoints share the same JSON request/response format.
func (c *Client) appEndpoint() string {
if c.isLegacyContainerAPI() {
return "CertificateStoreContainers"
}
return "Applications"
}

// ListApplications returns all applications/containers.
func (c *Client) ListApplications() ([]ApplicationListItem, error) {
log.Println("[INFO] Listing applications.")

headers := &apiHeaders{
Headers: []StringTuple{
{"x-keyfactor-api-version", "1"},
{"x-keyfactor-requested-with", "APIClient"},
},
}

req := &request{
Method: "GET",
Endpoint: c.appEndpoint(),
Headers: headers,
}

resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}

var result []ApplicationListItem
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}

// GetApplication returns the full details of an application/container by integer ID.
func (c *Client) GetApplication(id int) (*ApplicationResponse, error) {
log.Printf("[INFO] Fetching application with ID %d.", id)

headers := &apiHeaders{
Headers: []StringTuple{
{"x-keyfactor-api-version", "1"},
{"x-keyfactor-requested-with", "APIClient"},
},
}

req := &request{
Method: "GET",
Endpoint: fmt.Sprintf("%s/%d", c.appEndpoint(), id),
Headers: headers,
}

resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}

var result ApplicationResponse
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

// GetApplicationByName returns the application matching the given name by
// listing all applications and then fetching the matching one by ID.
// Returns an error if no application with that name exists.
func (c *Client) GetApplicationByName(name string) (*ApplicationResponse, error) {
log.Printf("[INFO] Fetching application with name %q.", name)

apps, err := c.ListApplications()
if err != nil {
return nil, err
}

for _, app := range apps {
if app.Name == name {
return c.GetApplication(app.Id)
}
}
return nil, fmt.Errorf("application %q not found", name)
}

// CreateApplication creates a new application/container and returns the created resource.
func (c *Client) CreateApplication(createReq *ApplicationCreateRequest) (*ApplicationResponse, error) {
log.Println("[INFO] Creating application.")

headers := &apiHeaders{
Headers: []StringTuple{
{"x-keyfactor-api-version", "1"},
{"x-keyfactor-requested-with", "APIClient"},
{"Content-Type", "application/json"},
},
}

req := &request{
Method: "POST",
Endpoint: c.appEndpoint(),
Headers: headers,
Payload: createReq,
}

resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}

var result ApplicationResponse
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

// UpdateApplication performs a full replacement of an existing application/container.
// For v25+ the API uses PUT /Applications with the ID in the body.
// For pre-v25 the API uses PUT /CertificateStoreContainers/{id} with the ID in the path.
func (c *Client) UpdateApplication(id int, updateReq *ApplicationUpdateRequest) (*ApplicationResponse, error) {
log.Printf("[INFO] Updating application with ID %d.", id)

updateReq.Id = id

headers := &apiHeaders{
Headers: []StringTuple{
{"x-keyfactor-api-version", "1"},
{"x-keyfactor-requested-with", "APIClient"},
{"Content-Type", "application/json"},
},
}

// Both pre-v25 and v25+ use PUT /{endpoint} with the ID in the request body.
endpoint := c.appEndpoint()

req := &request{
Method: "PUT",
Endpoint: endpoint,
Headers: headers,
Payload: updateReq,
}

resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}

var result ApplicationResponse
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

// DeleteApplication deletes an application/container by its integer ID.
// The server returns 204 No Content on success.
func (c *Client) DeleteApplication(id int) error {
log.Printf("[INFO] Deleting application with ID %d.", id)

headers := &apiHeaders{
Headers: []StringTuple{
{"x-keyfactor-api-version", "1"},
{"x-keyfactor-requested-with", "APIClient"},
},
}

req := &request{
Method: "DELETE",
Endpoint: fmt.Sprintf("%s/%d", c.appEndpoint(), id),
Headers: headers,
}

resp, err := c.sendRequest(req)
if err != nil {
return err
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("failed to delete application: HTTP %d", resp.StatusCode)
}

return nil
}
96 changes: 96 additions & 0 deletions v3/api/application_models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright 2024 Keyfactor
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package api

// ApplicationScheduleInterval defines an interval-based inventory schedule.
type ApplicationScheduleInterval struct {
Minutes int `json:"Minutes"`
}

// ApplicationScheduleDaily defines a daily time-based inventory schedule.
// Also reused as the shape for ExactlyOnce.
type ApplicationScheduleDaily struct {
Time string `json:"Time"` // ISO 8601 datetime string (e.g. "2023-11-25T23:30:00Z")
}

// ApplicationScheduleWeekly defines a weekly inventory schedule.
// Days are weekday names ("Sunday"…"Saturday"); Time is an ISO 8601 UTC datetime.
type ApplicationScheduleWeekly struct {
Days []string `json:"Days"` // e.g. ["Monday", "Wednesday"]
Time string `json:"Time"` // ISO 8601 datetime string
}

// ApplicationScheduleMonthly defines a monthly inventory schedule.
// Day is the day-of-month (1–31); Time is an ISO 8601 UTC datetime.
type ApplicationScheduleMonthly struct {
Day int `json:"Day"`
Time string `json:"Time"` // ISO 8601 datetime string
}

// ApplicationSchedule holds the schedule configuration for an application.
// Set exactly one field; omit all to disable the schedule (Off).
//
// - Immediate: run once immediately (server may convert to ExactlyOnce on next read)
// - Interval: run every N minutes
// - Daily: run at the same time each day
// - Weekly: run on specific weekdays at a given time
// - Monthly: run on a specific day of each month at a given time
// - ExactlyOnce: run exactly once at the specified time
type ApplicationSchedule struct {
Immediate *bool `json:"Immediate,omitempty"`
Interval *ApplicationScheduleInterval `json:"Interval,omitempty"`
Daily *ApplicationScheduleDaily `json:"Daily,omitempty"`
Weekly *ApplicationScheduleWeekly `json:"Weekly,omitempty"`
Monthly *ApplicationScheduleMonthly `json:"Monthly,omitempty"`
ExactlyOnce *ApplicationScheduleDaily `json:"ExactlyOnce,omitempty"`
}

// ApplicationCertStore is a minimal certificate store reference within an application detail response.
type ApplicationCertStore struct {
Id string `json:"Id"` // Store GUID (UUID)
}

// ApplicationListItem represents one entry returned by GET /Applications (list endpoint).
// The Schedule field is returned as a cron expression string by the list endpoint.
type ApplicationListItem struct {
Id int `json:"Id"`
Name string `json:"Name"`
Schedule string `json:"Schedule"`
}

// ApplicationResponse is the full application detail returned by GET /Applications/{id}.
type ApplicationResponse struct {
Id int `json:"Id"`
Name string `json:"Name"`
OverwriteSchedules bool `json:"OverwriteSchedules"`
Schedule *ApplicationSchedule `json:"Schedule,omitempty"`
CertificateStores []ApplicationCertStore `json:"CertificateStores,omitempty"`
}

// ApplicationCreateRequest is the request body for POST /Applications.
type ApplicationCreateRequest struct {
Name string `json:"Name"`
OverwriteSchedules bool `json:"OverwriteSchedules"`
Schedule *ApplicationSchedule `json:"Schedule,omitempty"`
}

// ApplicationUpdateRequest is the request body for PUT /Applications/{id}.
// The Id field is set automatically by UpdateApplication.
type ApplicationUpdateRequest struct {
Id int `json:"Id"`
Name string `json:"Name"`
OverwriteSchedules bool `json:"OverwriteSchedules"`
Schedule *ApplicationSchedule `json:"Schedule,omitempty"`
}
45 changes: 38 additions & 7 deletions v3/api/certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,27 +299,58 @@ func (c *Client) DownloadCertificate(
return nil, nil, &jsonResp.Content, p7bErr
}

var leaf *x509.Certificate
leaf := findLeafCert(certs)
if len(certs) > 1 {
//leaf is last cert in chain
leaf = certs[0] // First cert in chain is the leaf
return leaf, certs, &jsonResp.Content, nil
}
return leaf, nil, &jsonResp.Content, nil
}

// findLeafCert returns the end-entity (leaf) certificate from a set of
// certificates. It identifies the leaf as the cert whose Subject is not used
// as an Issuer by any other cert in the set — i.e. nothing is signed by it.
// This is order-independent and handles both root-first and leaf-first P7Bs.
//
// When the set contains only one cert, or when the algorithm cannot determine
// a unique leaf (e.g. all certs are self-signed), certs[0] is returned as a
// safe fallback.
func findLeafCert(certs []*x509.Certificate) *x509.Certificate {
if len(certs) == 0 {
return nil
}
if len(certs) == 1 {
return certs[0]
}

return certs[0], nil, &jsonResp.Content, nil
// Build a set of all RawIssuer values (subjects that issued something).
issuers := make(map[string]bool, len(certs))
for _, c := range certs {
issuers[string(c.RawIssuer)] = true
}

// The leaf's Subject is not in the issuers set.
for _, c := range certs {
if !issuers[string(c.RawSubject)] {
return c
}
}

// Fallback: cannot distinguish (e.g. single self-signed cert in multi-cert set).
return certs[0]
}

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

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

Expand Down
Loading
Loading