From a141830f76484ecceda121f2eb10e620fbfe1b8e Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 24 Feb 2025 12:52:39 -0500 Subject: [PATCH] CHV: implement paginated list certificates endpoints (#26554) --- .../25460-add-list-host-certificates-endpoint | 1 + server/datastore/mysql/host_certificates.go | 14 +- server/fleet/host_certificates.go | 82 ++++++++--- server/fleet/service.go | 3 + server/service/devices.go | 39 ++++++ server/service/handler.go | 4 + server/service/hosts.go | 67 +++++++++ server/service/hosts_test.go | 8 ++ server/service/integration_core_test.go | 131 ++++++++++++++++++ server/service/testing_client.go | 4 +- 10 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 changes/25460-add-list-host-certificates-endpoint diff --git a/changes/25460-add-list-host-certificates-endpoint b/changes/25460-add-list-host-certificates-endpoint new file mode 100644 index 0000000000..7663b3f98e --- /dev/null +++ b/changes/25460-add-list-host-certificates-endpoint @@ -0,0 +1 @@ +* Added the list host certificates (and list device's certificates) endpoints. diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 6bd7fe91bb..7858305314 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -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 } diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index f7d8ceac3a..0d7a27975d 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -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"` diff --git a/server/fleet/service.go b/server/fleet/service.go index 4f524d2a69..1e753ad232 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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 diff --git a/server/service/devices.go b/server/service/devices.go index 990dc6718f..f8b927ea2a 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -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 +} diff --git a/server/service/handler.go b/server/service/handler.go index 3b5970bb3b..7b1f0d7669 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -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()) diff --git a/server/service/hosts.go b/server/service/hosts.go index 8c726d5354..08d522e2ad 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -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 +} diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index d115b4af21..377f624aa7 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -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) }) } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index ff3b4c4bbf..ab258cb181 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -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) + }) + } +} diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 4d5d01dcaa..378fa8e46a 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -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) {