CHV: implement paginated list certificates endpoints (#26554)

This commit is contained in:
Martin Angers
2025-02-24 12:52:39 -05:00
committed by GitHub
parent ea35a2d77d
commit a141830f76
10 changed files with 323 additions and 30 deletions
@@ -0,0 +1 @@
* Added the list host certificates (and list device's certificates) endpoints.
+3 -11
View File
@@ -73,15 +73,8 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ce
}
func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
// TODO: move this to the service layer and do validation of the order key?
if opts.OrderKey == "" {
// default sort by common name ascending
opts.OrderKey = "common_name"
opts.OrderDirection = fleet.OrderAscending
}
stmt := `
SELECT
SELECT
id,
sha1_sum,
host_id,
@@ -105,8 +98,8 @@ SELECT
issuer_org_unit,
issuer_common_name
FROM
host_certificates
WHERE
host_certificates
WHERE
host_id = ?
AND deleted_at IS NULL`
@@ -126,7 +119,6 @@ WHERE
certs = certs[:len(certs)-1]
}
}
return certs, metaData, nil
}
+65 -17
View File
@@ -24,24 +24,24 @@ type HostCertificateRecord struct {
DeletedAt *time.Time `json:"-" db:"deleted_at"`
// The following fields are extracted from the certificate.
NotValidAfter time.Time `json:"-" db:"not_valid_after"`
NotValidBefore time.Time `json:"-" db:"not_valid_before"`
CertificateAuthority bool `json:"-" db:"certificate_authority"`
CommonName string `json:"-" db:"common_name"`
KeyAlgorithm string `json:"-" db:"key_algorithm"`
KeyStrength int `json:"-" db:"key_strength"`
KeyUsage string `json:"-" db:"key_usage"`
Serial string `json:"-" db:"serial"`
SigningAlgorithm string `json:"-" db:"signing_algorithm"`
NotValidAfter time.Time `json:"-" db:"not_valid_after"`
NotValidBefore time.Time `json:"-" db:"not_valid_before"`
CertificateAuthority bool `json:"-" db:"certificate_authority"`
CommonName string `json:"-" db:"common_name"`
KeyAlgorithm string `json:"-" db:"key_algorithm"`
KeyStrength int `json:"-" db:"key_strength"`
KeyUsage string `json:"-" db:"key_usage"`
Serial string `json:"-" db:"serial"`
SigningAlgorithm string `json:"-" db:"signing_algorithm"`
SubjectCountry string `json:"-" db:"subject_country"`
SubjectOrganization string `json:"-" db:"subject_org"`
SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"`
SubjectCommonName string `json:"-" db:"subject_common_name"`
IssuerCountry string `json:"-" db:"issuer_country"`
IssuerOrganization string `json:"-" db:"issuer_org"`
IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"`
IssuerCommonName string `json:"-" db:"issuer_common_name"`
SubjectCountry string `json:"-" db:"subject_country"`
SubjectOrganization string `json:"-" db:"subject_org"`
SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"`
SubjectCommonName string `json:"-" db:"subject_common_name"`
IssuerCountry string `json:"-" db:"issuer_country"`
IssuerOrganization string `json:"-" db:"issuer_org"`
IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"`
IssuerCommonName string `json:"-" db:"issuer_common_name"`
}
func NewHostCertificateRecord(
@@ -82,6 +82,54 @@ func NewHostCertificateRecord(
}
}
// ToPayload fills a HostCertificatePayload with the fields of a
// HostCertificateRecord. The HostCertificatePayload is used in API responses.
func (r *HostCertificateRecord) ToPayload() *HostCertificatePayload {
subject := &HostCertificateNameDetails{
CommonName: r.SubjectCommonName,
Country: r.SubjectCountry,
Organization: r.SubjectOrganization,
OrganizationalUnit: r.SubjectOrganizationalUnit,
}
issuer := &HostCertificateNameDetails{
CommonName: r.IssuerCommonName,
Country: r.IssuerCountry,
Organization: r.IssuerOrganization,
OrganizationalUnit: r.IssuerOrganizationalUnit,
}
return &HostCertificatePayload{
ID: r.ID,
NotValidAfter: r.NotValidAfter,
NotValidBefore: r.NotValidBefore,
CertificateAuthority: r.CertificateAuthority,
CommonName: r.CommonName,
KeyAlgorithm: r.KeyAlgorithm,
KeyStrength: r.KeyStrength,
KeyUsage: r.KeyUsage,
Serial: r.Serial,
SigningAlgorithm: r.SigningAlgorithm,
Subject: subject,
Issuer: issuer,
}
}
// HostCertificatePayload is the JSON model for API endpoints that return host certificates.
type HostCertificatePayload struct {
ID uint `json:"id"`
NotValidAfter time.Time `json:"not_valid_after"`
NotValidBefore time.Time `json:"not_valid_before"`
CertificateAuthority bool `json:"certificate_authority"`
CommonName string `json:"common_name"`
KeyAlgorithm string `json:"key_algorithm"`
KeyStrength int `json:"key_strength"`
KeyUsage string `json:"key_usage"`
Serial string `json:"serial"`
SigningAlgorithm string `json:"signing_algorithm"`
Subject *HostCertificateNameDetails `json:"subject,omitempty"`
Issuer *HostCertificateNameDetails `json:"issuer,omitempty"`
}
type HostCertificateNameDetails struct {
CommonName string `json:"common_name"`
Country string `json:"country"`
+3
View File
@@ -435,6 +435,9 @@ type Service interface {
// the specified host.
ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
// ListHostCertificates lists the certificates installed on the specified host.
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificatePayload, *PaginationMetadata, error)
// /////////////////////////////////////////////////////////////////////////////
// AppConfigService provides methods for configuring the Fleet application
+39
View File
@@ -685,3 +685,42 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle
}
return getDeviceSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil //nolint:gosec // dismiss G115
}
////////////////////////////////////////////////////////////////////////////////
// List Current Device's Certificates
////////////////////////////////////////////////////////////////////////////////
type listDeviceCertificatesRequest struct {
Token string `url:"token"`
fleet.ListOptions
}
func (r *listDeviceCertificatesRequest) deviceAuthToken() string {
return r.Token
}
type listDeviceCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
func (r listDeviceCertificatesResponse) Error() error { return r.Err }
func listDeviceCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
return listDevicePoliciesResponse{Err: err}, nil
}
req := request.(*listDeviceCertificatesRequest)
res, meta, err := svc.ListHostCertificates(ctx, host.ID, req.ListOptions)
if err != nil {
return listDeviceCertificatesResponse{Err: err}, nil
}
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listDeviceCertificatesResponse{Certificates: res, Meta: meta}, nil
}
+4
View File
@@ -409,6 +409,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{})
ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/software", getHostSoftwareEndpoint, getHostSoftwareRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates", listHostCertificatesEndpoint, listHostCertificatesRequest{})
ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{})
@@ -810,6 +811,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
de.WithCustomMiddleware(
errorLimiter.Limit("install_self_service", desktopQuota),
).POST("/api/_version_/fleet/device/{token}/software/install/{software_title_id}", submitSelfServiceSoftwareInstall, fleetSelfServiceSoftwareInstallRequest{})
de.WithCustomMiddleware(
errorLimiter.Limit("get_device_certificates", desktopQuota),
).GET("/api/_version_/fleet/device/{token}/certificates", listDeviceCertificatesEndpoint, listDeviceCertificatesRequest{})
// mdm-related endpoints available via device authentication
demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM())
+67
View File
@@ -2715,3 +2715,70 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee
software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts)
return software, meta, ctxerr.Wrap(ctx, err, "list host software")
}
////////////////////////////////////////////////////////////////////////////////
// Host Certificates
////////////////////////////////////////////////////////////////////////////////
type listHostCertificatesRequest struct {
ID uint `url:"id"`
fleet.ListOptions
}
type listHostCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
func (r listHostCertificatesResponse) Error() error { return r.Err }
func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listHostCertificatesRequest)
res, meta, err := svc.ListHostCertificates(ctx, req.ID, req.ListOptions)
if err != nil {
return listHostCertificatesResponse{Err: err}, nil
}
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil
}
var listHostCertificatesSortCols = map[string]bool{
"common_name": true,
"not_valid_after": true,
}
func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, ctxerr.Wrap(ctx, err, "failed to load host")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, nil, err
}
}
// query/after not supported, always include pagination info
opts.MatchQuery = ""
opts.After = ""
opts.IncludeMetadata = true
// default sort order is common name ascending
if opts.OrderKey == "" || !listHostCertificatesSortCols[opts.OrderKey] {
opts.OrderKey = "common_name"
}
certs, meta, err := svc.ds.ListHostCertificates(ctx, hostID, opts)
if err != nil {
return nil, nil, err
}
payload := make([]*fleet.HostCertificatePayload, 0, len(certs))
for _, cert := range certs {
payload = append(payload, cert.ToPayload())
}
return payload, meta, nil
}
+8
View File
@@ -665,6 +665,9 @@ func TestHostAuth(t *testing.T) {
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
return true, nil
}
ds.ListHostCertificatesFunc = func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
return nil, nil, nil
}
testCases := []struct {
name string
@@ -812,6 +815,11 @@ func TestHostAuth(t *testing.T) {
_, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, _, err = svc.ListHostCertificates(ctx, 1, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailTeamRead, err)
_, _, err = svc.ListHostCertificates(ctx, 2, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
})
}
+131
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"crypto/sha1" // nolint: gosec
"database/sql"
"encoding/csv"
"encoding/json"
@@ -12974,3 +12975,133 @@ func (s *integrationTestSuite) TestSecretVariables() {
require.Len(t, secrets, 1)
assert.Equal(t, "value", secrets[0].Value)
}
func (s *integrationTestSuite) TestHostCertificates() {
t := s.T()
ctx := context.Background()
token := "good_token"
host := createOrbitEnrolledHost(t, "linux", "host1", s.ds)
createDeviceTokenForHost(t, s.ds, host.ID, token)
// no certificate at the moment
var certResp listHostCertificatesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp)
require.Empty(t, certResp.Certificates)
certResp = listHostCertificatesResponse{}
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK)
err := json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Empty(t, certResp.Certificates)
// create some certs for that host
certNames := []string{"a", "b", "c", "d", "e"}
now := time.Now()
// sorting by not_valid_after should get us "d", "c", "e", "a", "b"
notValidAfterTimes := []time.Time{
now.Add(time.Minute), now.Add(time.Hour),
now.Add(time.Second), now.Add(time.Millisecond),
now.Add(2 * time.Second)}
certs := make([]*fleet.HostCertificateRecord, 0, len(certNames))
for i, name := range certNames {
certs = append(certs, &fleet.HostCertificateRecord{
HostID: host.ID,
CommonName: name,
SHA1Sum: sha1.New().Sum([]byte(name)), // nolint: gosec
SubjectCountry: "s" + name,
IssuerCountry: "i" + name,
NotValidAfter: notValidAfterTimes[i],
})
}
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, certs))
// list all certs
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp)
require.Len(t, certResp.Certificates, len(certNames))
for i, cert := range certResp.Certificates {
want := certNames[i]
require.Equal(t, want, cert.CommonName)
require.NotNil(t, cert.Subject)
require.Equal(t, "s"+want, cert.Subject.Country)
require.NotNil(t, cert.Issuer)
require.Equal(t, "i"+want, cert.Issuer.Country)
}
certResp = listHostCertificatesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Len(t, certResp.Certificates, len(certNames))
for i, cert := range certResp.Certificates {
want := certNames[i]
require.Equal(t, want, cert.CommonName)
require.NotNil(t, cert.Subject)
require.Equal(t, "s"+want, cert.Subject.Country)
require.NotNil(t, cert.Issuer)
require.Equal(t, "i"+want, cert.Issuer.Country)
}
// non-existing host
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID+1000), nil, http.StatusNotFound, &certResp)
// for the device endpoint, the token is the authentication so if it doesn't
// exist, the endpoint is unauthorized.
certResp = listHostCertificatesResponse{}
s.DoRawNoAuth("GET", "/api/latest/fleet/device/NO-SUCH-TOKEN/certificates", nil, http.StatusUnauthorized)
pluckCertNames := func(certs []*fleet.HostCertificatePayload) []string {
names := make([]string, 0, len(certs))
for _, cert := range certs {
names = append(names, cert.CommonName)
}
return names
}
// invalid sort column silently defaults to "common_name"
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, "order_key", "no-such-column")
require.Len(t, certResp.Certificates, len(certNames))
require.Equal(t, certNames, pluckCertNames(certResp.Certificates))
certResp = listHostCertificatesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, "order_key", "no-such-column")
err = json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Len(t, certResp.Certificates, len(certNames))
require.Equal(t, certNames, pluckCertNames(certResp.Certificates))
// test the pagination options
cases := []struct {
queryParams []string
wantNames []string
wantMeta fleet.PaginationMetadata
}{
{queryParams: []string{"page", "0", "per_page", "2"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
{queryParams: []string{"page", "1", "per_page", "2"}, wantNames: []string{"c", "d"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}},
{queryParams: []string{"page", "2", "per_page", "2"}, wantNames: []string{"e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
{queryParams: []string{"page", "3", "per_page", "2"}, wantNames: []string{}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
{queryParams: []string{"page", "0", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"e", "d", "c", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
{queryParams: []string{"page", "1", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"a"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
{queryParams: []string{"page", "0", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"d", "c", "e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
{queryParams: []string{"page", "1", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
}
for _, c := range cases {
t.Run(strings.Join(c.queryParams, "_"), func(t *testing.T) {
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, c.queryParams...)
require.Len(t, certResp.Certificates, len(c.wantNames))
require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates))
require.Equal(t, c.wantMeta, *certResp.Meta)
certResp = listHostCertificatesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, c.queryParams...)
err = json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Len(t, certResp.Certificates, len(c.wantNames))
require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates))
require.Equal(t, c.wantMeta, *certResp.Meta)
})
}
}
+2 -2
View File
@@ -293,8 +293,8 @@ func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedS
}, queryParams...)
}
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int) *http.Response {
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil)
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil, queryParams...)
}
func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {