diff --git a/changes/32103-show-certificates-true-total-count b/changes/32103-show-certificates-true-total-count new file mode 100644 index 0000000000..4351410f84 --- /dev/null +++ b/changes/32103-show-certificates-true-total-count @@ -0,0 +1 @@ +* Return count in list host certificates API response, and use it in the certificate table \ No newline at end of file diff --git a/frontend/__mocks__/certificatesMock.ts b/frontend/__mocks__/certificatesMock.ts index 93b2d3f56f..2ab88f3b30 100644 --- a/frontend/__mocks__/certificatesMock.ts +++ b/frontend/__mocks__/certificatesMock.ts @@ -43,6 +43,7 @@ const DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK: IGetHostCertificatesResponse = { has_next_results: false, has_previous_results: false, }, + count: 1, }; export const createMockGetHostCertificatesResponse = ( diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index 80f8c604e6..2246085a07 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -88,9 +88,7 @@ const CertificatesTable = ({ disableMultiRowSelect onSelectSingleRow={onClickTableRow} renderTableHelpText={() => helpText} - renderCount={() => ( - - )} + renderCount={() => } pageSize={pageSize} pageIndex={page} defaultSortHeader={sortHeader} diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index fdb7142f2d..0d7256eb06 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -43,6 +43,7 @@ export interface IGetDeviceCertificatesResponse { has_next_results: boolean; has_previous_results: boolean; }; + count: number; } export interface IGetDeviceCertsRequestParams extends IListOptions { diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index ba04d78712..621ecda9cf 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -216,6 +216,7 @@ export interface IGetHostCertificatesResponse { has_next_results: boolean; has_previous_results: boolean; }; + count: number; } export type ILoadHostDetailsExtension = "macadmins"; diff --git a/frontend/test/handlers/device-handler.ts b/frontend/test/handlers/device-handler.ts index 889d07a3fc..4a536de9c6 100644 --- a/frontend/test/handlers/device-handler.ts +++ b/frontend/test/handlers/device-handler.ts @@ -79,6 +79,7 @@ export const defaultDeviceCertificatesHandler = http.get( has_next_results: false, has_previous_results: false, }, + count: 1, }); } ); diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 1247c99ec0..8dfdc9432a 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -254,7 +254,6 @@ func loadHostCertIDsForSHA1DB(ctx context.Context, tx sqlx.QueryerContext, hostI var certs []*fleet.HostCertificateRecord stmt, args, err := sqlx.In(stmt, binarySHA1s, hostID) - if err != nil { return nil, ctxerr.Wrap(ctx, err, "building load host cert ids query") } @@ -271,7 +270,16 @@ func loadHostCertIDsForSHA1DB(ctx context.Context, tx sqlx.QueryerContext, hostI } func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) { - stmt := ` + const fromWhereClause = ` +FROM + host_certificates hc + INNER JOIN host_certificate_sources hcs ON hc.id = hcs.host_certificate_id +WHERE + hc.host_id = ? + AND hc.deleted_at IS NULL + ` + + stmt := fmt.Sprintf(` SELECT hc.id, hc.sha1_sum, @@ -297,15 +305,14 @@ SELECT hc.issuer_common_name, hcs.source, hcs.username -FROM - host_certificates hc - INNER JOIN host_certificate_sources hcs ON hc.id = hcs.host_certificate_id -WHERE - hc.host_id = ? - AND hc.deleted_at IS NULL` + %s`, fromWhereClause) - args := []interface{}{hostID} - stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, args, &opts) + countStmt := fmt.Sprintf(` + SELECT COUNT(*) %s + `, fromWhereClause) + + baseArgs := []interface{}{hostID} + stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, baseArgs, &opts) var certs []*fleet.HostCertificateRecord if err := sqlx.SelectContext(ctx, tx, &certs, stmtPaged, args...); err != nil { @@ -314,7 +321,11 @@ WHERE var metaData *fleet.PaginationMetadata if opts.IncludeMetadata { - metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0} + var count uint + if err := sqlx.GetContext(ctx, tx, &count, countStmt, baseArgs...); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "counting host certificates") + } + metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0, TotalResults: count} if len(certs) > int(opts.PerPage) { //nolint:gosec // dismiss G115 metaData.HasNextResults = true certs = certs[:len(certs)-1] diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index f81904a47f..7357e07aee 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -32,6 +32,7 @@ func TestHostCertificates(t *testing.T) { {"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation}, {"Create certificates with long country code", testHostCertificateWithInvalidCountryCode}, {"Truncate long certificate fields", testTruncateLongCertificateFields}, + {"Count matches main query", testListHostCertificatesCountMatches}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -79,7 +80,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload)) // verify that we saved the records correctly - certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) + certs, meta, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", IncludeMetadata: true}) require.NoError(t, err) require.Len(t, certs, 2) require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName) @@ -88,6 +89,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName) require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName) require.Equal(t, fleet.SystemHostCertificate, certs[1].Source) + require.EqualValues(t, 2, meta.TotalResults) // order by not_valid_after descending certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "not_valid_after", OrderDirection: fleet.OrderAscending}) @@ -728,3 +730,67 @@ func testTruncateLongCertificateFields(t *testing.T, ds *Datastore) { assert.Equal(t, fleet.UserHostCertificate, savedCert.Source, "Source should not be changed") assert.Equal(t, host.ID, savedCert.HostID, "HostID should not be changed") } + +func testListHostCertificatesCountMatches(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create host + host, err := ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String("count-mismatch-host-osquery-id"), + NodeKey: ptr.String("count-mismatch-host-node-key"), + UUID: "count-mismatch-host-uuid", + Hostname: "count-mismatch-host", + }) + require.NoError(t, err) + + // create a cert template and record + certTemplate := x509.Certificate{ + Subject: pkix.Name{ + Country: []string{"US"}, + CommonName: "count.example.com", + Organization: []string{"Org"}, + OrganizationalUnit: []string{"Eng"}, + }, + Issuer: pkix.Name{ + Country: []string{"US"}, + CommonName: "issuer.example.com", + Organization: []string{"Issuer"}, + }, + SerialNumber: big.NewInt(424242), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + + SignatureAlgorithm: x509.SHA256WithRSA, + NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), + NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), + BasicConstraintsValid: true, + } + + certRec := generateTestHostCertificateRecord(t, host.ID, &certTemplate) + + // Update using ds.UpdateHostCertificates with two sources: system and user + certSys := *certRec + certSys.Source = fleet.SystemHostCertificate + certSys.Username = "" + + certUser := *certRec + certUser.Source = fleet.UserHostCertificate + certUser.Username = "alice" + + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser})) + + // Now list with metadata + certs, meta, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{IncludeMetadata: true}) + require.NoError(t, err) + + require.NotNil(t, meta) + + // We expect two returned rows (one per source) + require.Len(t, certs, 2) + + require.Equal(t, uint(len(certs)), meta.TotalResults, "expected total results to match returned rows") +} diff --git a/server/service/devices.go b/server/service/devices.go index f7c631a616..0e7c90e108 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -883,6 +883,7 @@ func (r *listDeviceCertificatesRequest) deviceAuthToken() string { type listDeviceCertificatesResponse struct { Certificates []*fleet.HostCertificatePayload `json:"certificates"` Meta *fleet.PaginationMetadata `json:"meta,omitempty"` + Count uint `json:"count"` Err error `json:"error,omitempty"` } @@ -903,7 +904,7 @@ func listDeviceCertificatesEndpoint(ctx context.Context, request interface{}, sv if res == nil { res = []*fleet.HostCertificatePayload{} } - return listDeviceCertificatesResponse{Certificates: res, Meta: meta}, nil + return listDeviceCertificatesResponse{Certificates: res, Meta: meta, Count: meta.TotalResults}, nil } ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/hosts.go b/server/service/hosts.go index 93832d1f5b..6478bbdc19 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2972,6 +2972,7 @@ func (r *listHostCertificatesRequest) ValidateRequest() error { type listHostCertificatesResponse struct { Certificates []*fleet.HostCertificatePayload `json:"certificates"` Meta *fleet.PaginationMetadata `json:"meta,omitempty"` + Count uint `json:"count"` Err error `json:"error,omitempty"` } @@ -2986,7 +2987,7 @@ func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc if res == nil { res = []*fleet.HostCertificatePayload{} } - return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil + return listHostCertificatesResponse{Certificates: res, Meta: meta, Count: meta.TotalResults}, nil } func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {