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) {