From b226eb56d0256cb363b14570330c455b33dd07d6 Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Wed, 18 Mar 2026 11:03:48 -0400 Subject: [PATCH] New API endpoint for host reports (41534) Resolves #41534 Added GET /api/v1/fleet/hosts/{id}/reports endpoint (also accessible as /hosts/{id}/queries) that lists the query reports associated with a specific host. --- .../41534-host-details-reports-api-end-point | 1 + server/datastore/mysql/query_results.go | 226 ++++++++++++ server/datastore/mysql/query_results_test.go | 328 ++++++++++++++++++ server/fleet/app.go | 10 + server/fleet/datastore.go | 6 + server/fleet/queries.go | 25 ++ server/fleet/service.go | 4 + server/mock/datastore_mock.go | 12 + server/mock/service/service_mock.go | 12 + server/service/handler.go | 1 + server/service/hosts.go | 122 +++++++ server/service/hosts_reports_test.go | 253 ++++++++++++++ server/service/integration_core_test.go | 327 +++++++++++++++++ 13 files changed, 1327 insertions(+) create mode 100644 changes/41534-host-details-reports-api-end-point create mode 100644 server/service/hosts_reports_test.go diff --git a/changes/41534-host-details-reports-api-end-point b/changes/41534-host-details-reports-api-end-point new file mode 100644 index 0000000000..473c86a4da --- /dev/null +++ b/changes/41534-host-details-reports-api-end-point @@ -0,0 +1 @@ +* Added GET /api/v1/fleet/hosts/{id}/reports endpoint (also accessible as /hosts/{id}/queries) that lists the query reports associated with a specific host. \ No newline at end of file diff --git a/server/datastore/mysql/query_results.go b/server/datastore/mysql/query_results.go index 1acd205ae0..41fbc4a00b 100644 --- a/server/datastore/mysql/query_results.go +++ b/server/datastore/mysql/query_results.go @@ -2,12 +2,16 @@ package mysql import ( "context" + "database/sql" + "encoding/json" "fmt" + "maps" "slices" "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" "github.com/jmoiron/sqlx" ) @@ -265,3 +269,225 @@ func (ds *Datastore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryR return queryCounts, nil } + +// hostReportAllowedOrderKeys defines the allowed order keys for ListHostReports. +// The last_fetched entry is overridden dynamically in ListHostReports with a +// direction-aware COALESCE sentinel so that NULLs sort last in both ASC and DESC +// and the expression remains a single column (required for cursor pagination). +var hostReportAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ + "name": "q.name", + "last_fetched": "qr_stats.last_result_fetched", +} + +// hostReportRow is a scan target for the paginated query list in ListHostReports. +type hostReportRow struct { + QueryID uint `db:"id"` + Name string `db:"name"` + Description string `db:"description"` + LastResultFetched sql.NullTime `db:"last_result_fetched"` + DiscardData bool `db:"discard_data"` + LoggingType string `db:"logging_type"` +} + +// ListHostReports returns reports associated with a host, applying +// the provided filtering, sorting, and pagination options. maxQueryReportRows +// is the configured report cap; a query whose total result count (across all +// hosts) meets or exceeds this value is considered clipped. +// +// The implementation uses three queries to avoid N correlated subqueries: +// 1. A paginated query list (no joins to query_results). +// 2. A single aggregation over query_results for the page's query IDs to +// compute per-query and per-host counts and the most recent fetch time. +// 3. A window-function query to fetch the most recent result row for +// each query on the page. +func (ds *Datastore) ListHostReports( + ctx context.Context, + hostID uint, + teamID *uint, + opts fleet.ListHostReportsOptions, + maxQueryReportRows int, +) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) { + // We only care about saved queries + whereClause := "WHERE q.saved = 1" + var whereArgs []any + + // We also want to show queries that have not run yet, so we need + // to figure out which queries are associated with the host based + // on Team membership. + switch { + case teamID != nil: + whereArgs = append(whereArgs, *teamID) + whereClause += " AND (q.team_id IS NULL OR q.team_id = ?)" + default: + whereClause += " AND q.team_id IS NULL" + } + + // By default, only include queries that store results (discard_data=0 AND + // logging_type='snapshot'). When IncludeReportsDontStoreResults is set, + // all queries are returned regardless of their storage settings. + if !opts.IncludeReportsDontStoreResults { + whereClause += " AND q.discard_data = 0 AND q.logging_type = 'snapshot'" + } + + matchQuery := strings.TrimSpace(opts.ListOptions.MatchQuery) + if matchQuery != "" { + whereClause, whereArgs = searchLike(whereClause, whereArgs, matchQuery, "q.name") + } + + countStmt := "SELECT COUNT(*) FROM queries q " + whereClause + + // The list query LEFT JOINs a per-host aggregation so that last_result_fetched + // is available for ORDER BY. The JOIN uses hostID as its only argument, which + // must be prepended before the WHERE-clause args. + listStmt := ` + SELECT q.id, q.name, q.description, q.discard_data, q.logging_type, qr_stats.last_result_fetched + FROM queries q + LEFT JOIN ( + SELECT query_id, MAX(last_fetched) AS last_result_fetched + FROM query_results + WHERE host_id = ? + GROUP BY query_id + ) qr_stats ON q.id = qr_stats.query_id + ` + whereClause + listArgs := append([]any{hostID}, whereArgs...) + + // For last_fetched, replace the static allowlist entry with a direction-aware + // COALESCE so that NULLs sort last in both ASC and DESC while keeping the + // expression as a single column (required for cursor WHERE comparison). + // A secondary sort by q.id breaks timestamp ties deterministically. + allowedKeys := hostReportAllowedOrderKeys + if opts.ListOptions.OrderKey == "last_fetched" { + sentinel := "'9999-12-31 23:59:59'" // NULLs → max, sort last in ASC + if opts.ListOptions.OrderDirection == fleet.OrderDescending { + sentinel = "'0001-01-01 00:00:00'" // NULLs → min, sort last in DESC + } + allowedKeys = make(common_mysql.OrderKeyAllowlist, len(hostReportAllowedOrderKeys)+1) + maps.Copy(allowedKeys, hostReportAllowedOrderKeys) + allowedKeys["last_fetched"] = fmt.Sprintf("COALESCE(qr_stats.last_result_fetched, %s)", sentinel) + allowedKeys["id"] = "q.id" + opts.ListOptions.TestSecondaryOrderKey = "id" + } + + pagedStmt, pagedArgs, err := appendListOptionsWithCursorToSQLSecure(listStmt, listArgs, &opts.ListOptions, allowedKeys) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "apply list options for host reports") + } + + dbReader := ds.reader(ctx) + + var queryRows []hostReportRow + if err := sqlx.SelectContext(ctx, dbReader, &queryRows, pagedStmt, pagedArgs...); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "listing host reports") + } + + var total int + if err := sqlx.GetContext(ctx, dbReader, &total, countStmt, whereArgs...); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "counting host reports") + } + + metadata := &fleet.PaginationMetadata{HasPreviousResults: opts.ListOptions.Page > 0} + if len(queryRows) > int(opts.ListOptions.PerPage) { //nolint:gosec // dismiss G115 + metadata.HasNextResults = true + queryRows = queryRows[:len(queryRows)-1] + } + + if len(queryRows) == 0 { + return []*fleet.HostReport{}, total, metadata, nil + } + + // Collect IDs for the current page. + queryIDs := make([]uint, 0, len(queryRows)) + for _, r := range queryRows { + queryIDs = append(queryIDs, r.QueryID) + } + + // Batch-fetch result stats for the page in a single aggregation query. + // n_query_results counts all hosts (used for report_clipped). + // n_host_results is scoped to the current host (used for has_more_results). + // last_result_fetched is already available from the list query LEFT JOIN. + type statsRow struct { + QueryID uint `db:"query_id"` + NQueryResults int `db:"n_query_results"` + NHostResults int `db:"n_host_results"` + } + statsStmt, statsArgs, err := sqlx.In(` + SELECT + query_id, + COUNT(data) AS n_query_results, + SUM(CASE WHEN host_id = ? AND data IS NOT NULL THEN 1 ELSE 0 END) AS n_host_results + FROM query_results + WHERE query_id IN (?) + GROUP BY query_id + `, hostID, queryIDs) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "building stats query for host reports") + } + var statsRows []statsRow + if err := sqlx.SelectContext(ctx, dbReader, &statsRows, dbReader.Rebind(statsStmt), statsArgs...); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching host report stats") + } + statsByQueryID := make(map[uint]*statsRow, len(statsRows)) + for i := range statsRows { + statsByQueryID[statsRows[i].QueryID] = &statsRows[i] + } + + // Batch-fetch the most recent result row for each query on the page. + type firstDataRow struct { + QueryID uint `db:"query_id"` + Data *json.RawMessage `db:"data"` + } + firstDataStmt, firstDataArgs, err := sqlx.In(` + SELECT query_id, data + FROM ( + SELECT + query_id, + data, + ROW_NUMBER() OVER (PARTITION BY query_id ORDER BY last_fetched DESC) AS rn + FROM query_results + WHERE query_id IN (?) AND host_id = ? AND data IS NOT NULL + ) ranked + WHERE rn = 1 + `, queryIDs, hostID) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "building first data query for host reports") + } + var firstDataRows []firstDataRow + if err := sqlx.SelectContext(ctx, dbReader, &firstDataRows, dbReader.Rebind(firstDataStmt), firstDataArgs...); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "fetching first result data for host reports") + } + firstDataByQueryID := make(map[uint]*json.RawMessage, len(firstDataRows)) + for i := range firstDataRows { + if firstDataRows[i].Data != nil { + firstDataByQueryID[firstDataRows[i].QueryID] = firstDataRows[i].Data + } + } + + // Map to HostReport structs, joining in the batch-fetched metadata. + reports := make([]*fleet.HostReport, 0, len(queryRows)) + for _, qr := range queryRows { + r := &fleet.HostReport{ + QueryID: qr.QueryID, + Name: qr.Name, + Description: qr.Description, + StoreResults: !qr.DiscardData && qr.LoggingType == fleet.LoggingSnapshot, + } + if qr.LastResultFetched.Valid { + t := qr.LastResultFetched.Time + r.LastFetched = &t + } + if stats, ok := statsByQueryID[qr.QueryID]; ok { + r.NHostResults = stats.NHostResults + r.ReportClipped = stats.NQueryResults >= maxQueryReportRows + } + if data, ok := firstDataByQueryID[qr.QueryID]; ok { + var cols map[string]string + if err := json.Unmarshal(*data, &cols); err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "unmarshal first result data") + } + r.FirstResult = cols + } + reports = append(reports, r) + } + + return reports, total, metadata, nil +} diff --git a/server/datastore/mysql/query_results_test.go b/server/datastore/mysql/query_results_test.go index bbf4fb32c2..7506af69db 100644 --- a/server/datastore/mysql/query_results_test.go +++ b/server/datastore/mysql/query_results_test.go @@ -32,6 +32,7 @@ func TestQueryResults(t *testing.T) { {"CleanupQueryResultRows", testCleanupQueryResultRows}, {"CleanupExcessQueryResultRows", testCleanupExcessQueryResultRows}, {"CleanupExcessQueryResultRowsManyQueries", testCleanupExcessQueryResultRowsManyQueries}, + {"ListHostReports", testListHostReports}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -816,3 +817,330 @@ func testCleanupExcessQueryResultRowsManyQueries(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, queryCounts, numQueries) } + +func testListHostReports(t *testing.T, ds *Datastore) { + ctx := t.Context() + user := test.NewUser(t, ds, "Test User", "list@example.com", true) + host := test.NewHost(t, ds, "host1", "192.168.1.1", "key1", "serial1", time.Now()) + + now := time.Now().UTC().Truncate(time.Second) + earlier := now.Add(-time.Hour) + + // Create queries: two that save results, one that discards them. + qSave1 := test.NewQuery(t, ds, nil, "Save Query Alpha", "SELECT 1", user.ID, true) + // Override DiscardData so it saves results (default is false, which is correct already). + + _ = test.NewQuery(t, ds, nil, "Save Query Beta", "SELECT 2", user.ID, true) + + // Create a query that discards results; excluded by default since it doesn't + // satisfy discard_data=0 AND logging_type='snapshot'. + qDiscard, err := ds.NewQuery(ctx, &fleet.Query{ + Name: "Discard Query Gamma", + Query: "SELECT 3", + AuthorID: &user.ID, + Saved: true, + DiscardData: true, + Logging: fleet.LoggingDifferential, + }) + require.NoError(t, err) + + // Create a query with discard_data=false but logging_type='differential'. + // This is the edge case fixed by the StoreResults check: even though + // discard_data=0, it does not store snapshot reports, so StoreResults must + // be false and it must be excluded by the default filter. + qDifferentialNoDiscard, err := ds.NewQuery(ctx, &fleet.Query{ + Name: "Differential No Discard Delta", + Query: "SELECT 4", + AuthorID: &user.ID, + Saved: true, + DiscardData: false, + Logging: fleet.LoggingDifferential, + }) + require.NoError(t, err) + + // Insert results for qSave1 on our host: two rows. + rows1 := []*fleet.ScheduledQueryResultRow{ + { + QueryID: qSave1.ID, + HostID: host.ID, + LastFetched: earlier, + Data: ptr.RawMessage([]byte(`{"col":"row1"}`)), + }, + { + QueryID: qSave1.ID, + HostID: host.ID, + LastFetched: now, + Data: ptr.RawMessage([]byte(`{"col":"row2"}`)), + }, + } + _, err = ds.OverwriteQueryResultRows(ctx, rows1, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + + // qSave2 has no results yet (should still be returned when SaveResults=true). + + // Insert a result for qDiscard (to confirm it's excluded by default). + rowsDiscard := []*fleet.ScheduledQueryResultRow{ + { + QueryID: qDiscard.ID, + HostID: host.ID, + LastFetched: now, + Data: ptr.RawMessage([]byte(`{"col":"discarded"}`)), + }, + } + _, err = ds.OverwriteQueryResultRows(ctx, rowsDiscard, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + + t.Run("default_excludes_dont_store_results_queries", func(t *testing.T) { + opts := fleet.ListHostReportsOptions{ + // IncludeReportsDontStoreResults defaults to false: only include discard_data=0 AND logging_type='snapshot'. + ListOptions: fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}, + } + reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + // Should get qSave1 and qSave2 but NOT qDiscard (doesn't satisfy discard_data=0 AND logging_type='snapshot'). + assert.Equal(t, 2, total) + require.Len(t, reports, 2) + assert.NotNil(t, meta) + // Sorted by name ASC: "Save Query Alpha", "Save Query Beta". + assert.Equal(t, "Save Query Alpha", reports[0].Name) + assert.Equal(t, "Save Query Beta", reports[1].Name) + }) + + t.Run("include_reports_dont_store_results_returns_all_queries", func(t *testing.T) { + opts := fleet.ListHostReportsOptions{ + IncludeReportsDontStoreResults: true, + ListOptions: fleet.ListOptions{OrderKey: "name", IncludeMetadata: true}, + } + reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + // All 4 queries are returned including both don't-store-results variants. + assert.Equal(t, 4, total) + require.Len(t, reports, 4) + assert.Equal(t, "Differential No Discard Delta", reports[0].Name) + assert.Equal(t, "Discard Query Gamma", reports[1].Name) + assert.Equal(t, "Save Query Alpha", reports[2].Name) + assert.Equal(t, "Save Query Beta", reports[3].Name) + }) + + t.Run("store_results_field_reflects_both_discard_data_and_logging_type", func(t *testing.T) { + // This subtest validates the fix: StoreResults must be true only when + // discard_data=0 AND logging_type='snapshot'. A query with discard_data=0 + // but logging_type='differential' must have StoreResults=false. + opts := fleet.ListHostReportsOptions{ + IncludeReportsDontStoreResults: true, + ListOptions: fleet.ListOptions{OrderKey: "name"}, + } + reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + require.Len(t, reports, 4) + + byName := make(map[string]*fleet.HostReport, len(reports)) + for _, r := range reports { + byName[r.Name] = r + } + + // discard_data=false, logging_type='snapshot' → StoreResults=true + require.Contains(t, byName, qSave1.Name) + assert.True(t, byName[qSave1.Name].StoreResults, "snapshot query should have StoreResults=true") + + // discard_data=true, logging_type='differential' → StoreResults=false + require.Contains(t, byName, qDiscard.Name) + assert.False(t, byName[qDiscard.Name].StoreResults, "discard query should have StoreResults=false") + + // discard_data=false, logging_type='differential' → StoreResults=false (the fixed edge case) + require.Contains(t, byName, qDifferentialNoDiscard.Name) + assert.False(t, byName[qDifferentialNoDiscard.Name].StoreResults, "differential query with discard_data=false should have StoreResults=false") + }) + + t.Run("first_result_is_most_recent_non_null_row", func(t *testing.T) { + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name"}, + } + reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + require.Len(t, reports, 2) + + // qSave1 has 2 results; first result should be the most recent one. + alpha := reports[0] + assert.Equal(t, "Save Query Alpha", alpha.Name) + require.NotNil(t, alpha.FirstResult) + assert.Equal(t, "row2", alpha.FirstResult["col"]) + // LastFetched should be MAX(last_fetched) = now. + require.NotNil(t, alpha.LastFetched) + assert.Equal(t, now.Unix(), alpha.LastFetched.Unix()) + assert.Equal(t, 2, alpha.NHostResults) + }) + + t.Run("query_with_no_results_has_nil_first_result", func(t *testing.T) { + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name"}, + } + reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + require.Len(t, reports, 2) + + // qSave2 has no results. + beta := reports[1] + assert.Equal(t, "Save Query Beta", beta.Name) + assert.Nil(t, beta.FirstResult) + assert.Nil(t, beta.LastFetched) + assert.Equal(t, 0, beta.NHostResults) + }) + + t.Run("name_filter", func(t *testing.T) { + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + MatchQuery: "Alpha", + }, + } + reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + assert.Equal(t, 1, total) + require.Len(t, reports, 1) + assert.Equal(t, "Save Query Alpha", reports[0].Name) + }) + + t.Run("order_by_last_fetched_nulls_last", func(t *testing.T) { + // qSave1 has results (non-null last_fetched), qSave2 has none (null). + // NULLs should sort last regardless of ASC/DESC direction. + + // ASC: non-null values first (oldest to newest), NULLs at bottom. + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "last_fetched", + OrderDirection: fleet.OrderAscending, + }, + } + reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + require.Len(t, reports, 2) + assert.Equal(t, "Save Query Alpha", reports[0].Name) // has results + assert.Equal(t, "Save Query Beta", reports[1].Name) // no results → NULL last + + // DESC: non-null values first (newest to oldest), NULLs at bottom. + opts.ListOptions.OrderDirection = fleet.OrderDescending + reports, _, _, err = ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + require.Len(t, reports, 2) + assert.Equal(t, "Save Query Alpha", reports[0].Name) // has results + assert.Equal(t, "Save Query Beta", reports[1].Name) // no results → NULL last + }) + + t.Run("pagination", func(t *testing.T) { + // Use PerPage:1 over the 2 save queries to exercise both pages. + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "name", + PerPage: 1, + Page: 0, + IncludeMetadata: true, + }, + } + reports, total, meta, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + assert.Equal(t, 2, total) + require.Len(t, reports, 1) + require.NotNil(t, meta) + assert.True(t, meta.HasNextResults) + assert.False(t, meta.HasPreviousResults) + + // Second page. + opts.ListOptions.Page = 1 + reports2, total2, meta2, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + assert.Equal(t, 2, total2) + require.Len(t, reports2, 1) + require.NotNil(t, meta2) + assert.False(t, meta2.HasNextResults) + assert.True(t, meta2.HasPreviousResults) + }) + + t.Run("team_scoping_excludes_other_team_queries", func(t *testing.T) { + // Create a team first, then a query for that team. + // Since host has no team, only global queries (team_id IS NULL) should be shown. + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "scoping-test-team"}) + require.NoError(t, err) + _, err = ds.NewQuery(ctx, &fleet.Query{ + Name: "Team-Only Query", + Query: "SELECT 4", + AuthorID: &user.ID, + Saved: true, + TeamID: &team.ID, + Logging: fleet.LoggingSnapshot, + }) + require.NoError(t, err) + + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name"}, + } + reports, total, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + // Team-Only query should not appear because host has no team. + assert.Equal(t, 2, total) + for _, r := range reports { + assert.NotEqual(t, "Team-Only Query", r.Name) + } + }) + + t.Run("team_host_sees_global_and_team_queries", func(t *testing.T) { + // Create a team, a team host, and a team-scoped query. + // The host should see both global queries and its own team's queries. + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team-host-test-team"}) + require.NoError(t, err) + teamHost := test.NewHost(t, ds, "team-host1", "10.0.0.1", "key-team1", "serial-team1", time.Now()) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{teamHost.ID})) + require.NoError(t, err) + + _ = test.NewQuery(t, ds, &team.ID, "Team Query Zeta", "SELECT 5", user.ID, true) + + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name"}, + } + reports, total, _, err := ds.ListHostReports(ctx, teamHost.ID, &team.ID, opts, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + // Should see the 2 global save queries plus the team-scoped query. + assert.Equal(t, 3, total) + names := make([]string, 0, len(reports)) + for _, r := range reports { + names = append(names, r.Name) + } + assert.Contains(t, names, "Save Query Alpha") + assert.Contains(t, names, "Save Query Beta") + assert.Contains(t, names, "Team Query Zeta") + }) + + t.Run("report_clipped_when_total_results_reach_cap", func(t *testing.T) { + // Insert exactly maxQueryReportRows results for qSave1 across multiple hosts + // so that n_query_results >= capacity, making report_clipped=true. + capacity := 3 + extraHost := test.NewHost(t, ds, "extra-host", "192.168.2.1", "key2", "serial2", time.Now()) + t.Cleanup(func() { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM query_results WHERE host_id = ?`, extraHost.ID) + return err + }) + }) + _, err := ds.OverwriteQueryResultRows(ctx, []*fleet.ScheduledQueryResultRow{ + {QueryID: qSave1.ID, HostID: extraHost.ID, LastFetched: now, Data: ptr.RawMessage([]byte(`{"col":"extra"}`))}, + }, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + // At this point qSave1 has 2 rows on host + 1 on extraHost = 3 total, which equals cap. + + opts := fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name"}, + } + reports, _, _, err := ds.ListHostReports(ctx, host.ID, nil, opts, capacity) + require.NoError(t, err) + require.Len(t, reports, 2) + + alpha := reports[0] + assert.Equal(t, "Save Query Alpha", alpha.Name) + assert.True(t, alpha.ReportClipped) + + // qSave2 has no results, so it should not be clipped. + beta := reports[1] + assert.Equal(t, "Save Query Beta", beta.Name) + assert.False(t, beta.ReportClipped) + }) +} diff --git a/server/fleet/app.go b/server/fleet/app.go index 503b6b0885..e29baae8ba 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -1367,6 +1367,16 @@ type ListQueryOptions struct { Platform *string } +// ListHostReportsOptions defines options for listing reports (queries) associated with a host. +type ListHostReportsOptions struct { + ListOptions + // IncludeReportsDontStoreResults controls whether queries that don't store + // results (discard_data=1 OR logging_type!='snapshot') are included. + // false (default): only queries with discard_data=0 AND logging_type='snapshot' are returned. + // true: all queries are returned, including ones that don't store results. + IncludeReportsDontStoreResults bool +} + // ApplySpecOptions are the options available when applying a YAML or JSON spec. type ApplySpecOptions struct { // Force indicates that any validation error in the incoming payload should diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 3686facbf5..8f7732e9f2 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -578,6 +578,12 @@ type Datastore interface { // Deletes are batched to avoid large binlogs and long lock times. This runs as a cron job. // Returns a map of query IDs to their current row count after cleanup (for syncing Redis counters). CleanupExcessQueryResultRows(ctx context.Context, maxQueryReportRows int, opts ...CleanupExcessQueryResultRowsOptions) (map[uint]int, error) + // ListHostReports returns the queries/reports associated with the given host, applying + // the provided options for filtering, sorting, and pagination. teamID is the team of the + // host (nil for global). maxQueryReportRows is the configured report cap used to determine + // whether each query's report has been clipped. It returns the list of reports, the total + // count (without pagination), optional pagination metadata, and any error. + ListHostReports(ctx context.Context, hostID uint, teamID *uint, opts ListHostReportsOptions, maxQueryReportRows int) ([]*HostReport, int, *PaginationMetadata, error) /////////////////////////////////////////////////////////////////////////////// // TeamStore diff --git a/server/fleet/queries.go b/server/fleet/queries.go index 249f2a94e5..cc76541e47 100644 --- a/server/fleet/queries.go +++ b/server/fleet/queries.go @@ -490,6 +490,31 @@ type HostQueryReportResult struct { Columns map[string]string `json:"columns"` } +// HostReport represents a query/report entry as returned by the list-reports-for-host endpoint. +type HostReport struct { + // QueryID is the unique identifier of the query. + QueryID uint `json:"query_id" renameto:"report_id"` + // Name is the name of the query. + Name string `json:"name"` + // Description is the description of the query. + Description string `json:"description"` + // LastFetched is the time the most recent result was received from the host. + // It is nil if no results have been received. + LastFetched *time.Time `json:"last_fetched"` + // FirstResult contains the column key-value pairs of the most recent result row + // for this host and query. It is nil if no results have been received. + FirstResult map[string]string `json:"first_result"` + // NHostResults is the number of non-null result rows stored for this host + // and query. + NHostResults int `json:"n_host_results"` + // ReportClipped indicates whether this query has hit the report cap and + // paused saving new results across all hosts. + ReportClipped bool `json:"report_clipped"` + // StoreResults indicates that the query is configured to store results. + // It is true only when discard_data=0 AND logging_type='snapshot'. + StoreResults bool `json:"store_results"` +} + // ScheduledQueryResult holds results of a scheduled query received from a osquery agent. type ScheduledQueryResult struct { // QueryName is the name of the query. diff --git a/server/fleet/service.go b/server/fleet/service.go index 57078b7610..a97a6c62de 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -346,6 +346,10 @@ type Service interface { GetHostQueryReportResults(ctx context.Context, hid uint, queryID uint) (rows []HostQueryReportResult, lastFetched *time.Time, err error) // QueryReportIsClipped returns true if the number of query report rows exceeds the maximum QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) + // ListHostReports returns the reports/queries associated with the given host, filtered, + // sorted, and paginated according to opts. The bool return value indicates whether + // query reports are globally disabled in the org settings. + ListHostReports(ctx context.Context, hostID uint, opts ListHostReportsOptions) (rows []*HostReport, total int, metadata *PaginationMetadata, savedReportsDisabled bool, err error) NewQuery(ctx context.Context, p QueryPayload) (*Query, error) ModifyQuery(ctx context.Context, id uint, p QueryPayload) (*Query, error) DeleteQuery(ctx context.Context, teamID *uint, name string) error diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 3dcaebdad7..7f961c259a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -443,6 +443,8 @@ type CleanupDiscardedQueryResultsFunc func(ctx context.Context) error type CleanupExcessQueryResultRowsFunc func(ctx context.Context, maxQueryReportRows int, opts ...fleet.CleanupExcessQueryResultRowsOptions) (map[uint]int, error) +type ListHostReportsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) + type NewTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) type SaveTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) @@ -2460,6 +2462,9 @@ type DataStore struct { CleanupExcessQueryResultRowsFunc CleanupExcessQueryResultRowsFunc CleanupExcessQueryResultRowsFuncInvoked bool + ListHostReportsFunc ListHostReportsFunc + ListHostReportsFuncInvoked bool + NewTeamFunc NewTeamFunc NewTeamFuncInvoked bool @@ -6012,6 +6017,13 @@ func (s *DataStore) CleanupExcessQueryResultRows(ctx context.Context, maxQueryRe return s.CleanupExcessQueryResultRowsFunc(ctx, maxQueryReportRows, opts...) } +func (s *DataStore) ListHostReports(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.ListHostReportsFuncInvoked = true + s.mu.Unlock() + return s.ListHostReportsFunc(ctx, hostID, teamID, opts, maxQueryReportRows) +} + func (s *DataStore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { s.mu.Lock() s.NewTeamFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index c2f6639eb2..6166530f8e 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -191,6 +191,8 @@ type GetHostQueryReportResultsFunc func(ctx context.Context, hid uint, queryID u type QueryReportIsClippedFunc func(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) +type ListHostReportsFunc func(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, savedReportsDisabled bool, err error) + type NewQueryFunc func(ctx context.Context, p fleet.QueryPayload) (*fleet.Query, error) type ModifyQueryFunc func(ctx context.Context, id uint, p fleet.QueryPayload) (*fleet.Query, error) @@ -1152,6 +1154,9 @@ type Service struct { QueryReportIsClippedFunc QueryReportIsClippedFunc QueryReportIsClippedFuncInvoked bool + ListHostReportsFunc ListHostReportsFunc + ListHostReportsFuncInvoked bool + NewQueryFunc NewQueryFunc NewQueryFuncInvoked bool @@ -2810,6 +2815,13 @@ func (s *Service) QueryReportIsClipped(ctx context.Context, queryID uint, maxQue return s.QueryReportIsClippedFunc(ctx, queryID, maxQueryReportRows) } +func (s *Service) ListHostReports(ctx context.Context, hostID uint, opts fleet.ListHostReportsOptions) (rows []*fleet.HostReport, total int, metadata *fleet.PaginationMetadata, savedReportsDisabled bool, err error) { + s.mu.Lock() + s.ListHostReportsFuncInvoked = true + s.mu.Unlock() + return s.ListHostReportsFunc(ctx, hostID, opts) +} + func (s *Service) NewQuery(ctx context.Context, p fleet.QueryPayload) (*fleet.Query, error) { s.mu.Lock() s.NewQueryFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index fa8a175dd2..e0584c18e7 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -472,6 +472,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/os_versions", osVersionsEndpoint, osVersionsRequest{}) ue.GET("/api/_version_/fleet/os_versions/{id:[0-9]+}", getOSVersionEndpoint, getOSVersionRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/reports/{report_id:[0-9]+}", getHostQueryReportEndpoint, getHostQueryReportRequest{}) + ue.WithAltPaths("/api/_version_/fleet/hosts/{id:[0-9]+}/queries").GET("/api/_version_/fleet/hosts/{id:[0-9]+}/reports", listHostReportsEndpoint, listHostReportsRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/health", getHostHealthEndpoint, getHostHealthRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{}) ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index a0a2c546d7..7370e74752 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1942,6 +1942,128 @@ func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint, return result, lastFetched, nil } +//////////////////////////////////////////////////////////////////////////////// +// List Host Reports +//////////////////////////////////////////////////////////////////////////////// + +type listHostReportsRequest struct { + ID uint `url:"id"` + ListOptions fleet.ListOptions `url:"list_options"` + // IncludeReportsDontStoreResults if true, include reports that don't store results + // (discard_data=1 AND logging_type != 'snapshot'). Defaults to false when omitted. + IncludeReportsDontStoreResults *bool `query:"include_reports_dont_store_results,optional"` +} + +type listHostReportsFeatures struct { + SavedReportsDisabled bool `json:"save_reports_disabled"` +} + +type listHostReportsResponse struct { + Reports []*fleet.HostReport `json:"reports"` + Count int `json:"count"` + Meta *fleet.PaginationMetadata `json:"meta,omitempty"` + Features listHostReportsFeatures `json:"features"` + Err error `json:"error,omitempty"` +} + +func (r listHostReportsResponse) Error() error { return r.Err } + +func listHostReportsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*listHostReportsRequest) + + var includeReportsDontStoreResults bool + if req.IncludeReportsDontStoreResults != nil { + includeReportsDontStoreResults = *req.IncludeReportsDontStoreResults + } + + opts := fleet.ListHostReportsOptions{ + ListOptions: req.ListOptions, + IncludeReportsDontStoreResults: includeReportsDontStoreResults, + } + + reports, count, meta, savedReportsDisabled, err := svc.ListHostReports(ctx, req.ID, opts) + if err != nil { + return listHostReportsResponse{Err: err}, nil + } + + return listHostReportsResponse{ + Reports: reports, + Count: count, + Meta: meta, + Features: listHostReportsFeatures{ + SavedReportsDisabled: savedReportsDisabled, + }, + }, nil +} + +func (svc *Service) ListHostReports( + ctx context.Context, + hostID uint, + opts fleet.ListHostReportsOptions, +) ( + []*fleet.HostReport, + int, + *fleet.PaginationMetadata, + bool, + error, +) { + // Load host to get team ID and authorize. + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + setAuthCheckedOnPreAuthErr(ctx) + return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "get host") + } + + // Verify the caller can read this specific host. + if err := svc.authz.Authorize(ctx, &fleet.Host{ID: host.ID, TeamID: host.TeamID}, fleet.ActionRead); err != nil { + return nil, 0, nil, false, err + } + + // Authorize against the host's team. Global queries (team_id IS NULL) are + // intentionally visible to all users who can read queries in this context — + // team-scoped users see global queries in addition to their own team's queries. + if err := svc.authz.Authorize(ctx, &fleet.Query{TeamID: host.TeamID}, fleet.ActionRead); err != nil { + return nil, 0, nil, false, err + } + + appConfig, err := svc.AppConfigObfuscated(ctx) + if err != nil { + return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "get app config") + } + maxQueryReportRows := appConfig.ServerSettings.GetQueryReportCap() + savedReportsDisabled := appConfig.ServerSettings.QueryReportsDisabled + + // This end-point is always paginated; metadata is required for HasNextResults. + opts.ListOptions.IncludeMetadata = true + // Default page size for this endpoint is 50 (not the global default). + if opts.ListOptions.PerPage == 0 { + opts.ListOptions.PerPage = 50 + } + // Validate the order key before it reaches the datastore allowlist, so that + // invalid values produce a clear 400 Bad Request instead of an internal error. + switch opts.ListOptions.OrderKey { + case "", "name", "last_fetched": + // valid + default: + return nil, 0, nil, false, fleet.NewInvalidArgumentError("order_key", "must be one of: name, last_fetched") + } + + // Default: sort by newest results first. Applies only when the caller has + // not specified an order key; explicit sorts (e.g. order_key=name) are + // passed through unchanged. + if opts.ListOptions.OrderKey == "" { + opts.ListOptions.OrderKey = "last_fetched" + opts.ListOptions.OrderDirection = fleet.OrderDescending + } + + reports, total, meta, err := svc.ds.ListHostReports(ctx, hostID, host.TeamID, opts, maxQueryReportRows) + if err != nil { + return nil, 0, nil, false, ctxerr.Wrap(ctx, err, "list host reports from datastore") + } + + return reports, total, meta, savedReportsDisabled, nil +} + func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, []*fleet.Host, error) { vc, ok := viewer.FromContext(ctx) if !ok { diff --git a/server/service/hosts_reports_test.go b/server/service/hosts_reports_test.go new file mode 100644 index 0000000000..b51785e225 --- /dev/null +++ b/server/service/hosts_reports_test.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListHostReports(t *testing.T) { + ds := new(mock.Store) + + now := time.Now().UTC().Truncate(time.Second) + + // global admin user + admin := &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleAdmin), + } + + // Host with no team + hostNoTeam := &fleet.Host{ID: 10, TeamID: nil} + // Host with team + teamID := uint(42) + hostWithTeam := &fleet.Host{ID: 20, TeamID: &teamID} + + sampleReports := []*fleet.HostReport{ + { + QueryID: 1, + Name: "Query Alpha", + Description: "desc alpha", + LastFetched: &now, + FirstResult: map[string]string{"col1": "val1"}, + NHostResults: 3, + }, + { + QueryID: 2, + Name: "Query Beta", + Description: "desc beta", + LastFetched: nil, + FirstResult: nil, + NHostResults: 0, + }, + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + if id == hostNoTeam.ID { + return hostNoTeam, nil + } + if id == hostWithTeam.ID { + return hostWithTeam, nil + } + return nil, errors.New("host not found") + } + + var capturedTeamID *uint + ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) { + capturedTeamID = tID + return sampleReports, len(sampleReports), nil, nil + } + + svc, ctx := newTestService(t, ds, nil, nil) + + t.Run("admin can list reports for host with no team", func(t *testing.T) { + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin}) + opts := fleet.ListHostReportsOptions{} + reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts) + require.NoError(t, err) + assert.Equal(t, 2, count) + assert.Len(t, reports, 2) + assert.Equal(t, "Query Alpha", reports[0].Name) + assert.Equal(t, "Query Beta", reports[1].Name) + assert.Equal(t, &now, reports[0].LastFetched) + assert.Nil(t, reports[1].LastFetched) + assert.Equal(t, 3, reports[0].NHostResults) + assert.Equal(t, 0, reports[1].NHostResults) + assert.Equal(t, map[string]string{"col1": "val1"}, reports[0].FirstResult) + assert.Nil(t, reports[1].FirstResult) + // host has no team, so nil teamID must be forwarded to the datastore. + assert.Nil(t, capturedTeamID) + }) + + t.Run("admin can list reports for host with team", func(t *testing.T) { + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin}) + opts := fleet.ListHostReportsOptions{} + reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, opts) + require.NoError(t, err) + assert.Equal(t, 2, count) + assert.Len(t, reports, 2) + // teamID must be forwarded to the datastore so it can scope queries correctly. + assert.Equal(t, &teamID, capturedTeamID) + }) + + t.Run("observer can list reports", func(t *testing.T) { + observer := &fleet.User{ + ID: 2, + GlobalRole: ptr.String(fleet.RoleObserver), + } + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: observer}) + opts := fleet.ListHostReportsOptions{} + reports, count, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, opts) + require.NoError(t, err) + assert.Equal(t, 2, count) + assert.Len(t, reports, 2) + }) + + t.Run("save_reports_disabled is forwarded from app config", func(t *testing.T) { + original := ds.AppConfigFunc + t.Cleanup(func() { ds.AppConfigFunc = original }) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + ServerSettings: fleet.ServerSettings{QueryReportsDisabled: true}, + }, nil + } + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin}) + _, _, _, disabled, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{}) + require.NoError(t, err) + assert.True(t, disabled) + }) + + t.Run("invalid order_key returns bad request", func(t *testing.T) { + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin}) + _, _, _, _, err := svc.ListHostReports(viewerCtx, hostNoTeam.ID, fleet.ListHostReportsOptions{ + ListOptions: fleet.ListOptions{OrderKey: "invalid_key"}, + }) + require.Error(t, err) + var invalidArgErr *fleet.InvalidArgumentError + require.ErrorAs(t, err, &invalidArgErr) + }) + + t.Run("unauthenticated gets error", func(t *testing.T) { + _, _, _, _, err := svc.ListHostReports(ctx, hostNoTeam.ID, fleet.ListHostReportsOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "forbidden") + }) + + t.Run("team observer cannot read host belonging to a different team", func(t *testing.T) { + otherTeamID := uint(99) + teamObserver := &fleet.User{ + ID: 3, + Teams: []fleet.UserTeam{ + {Team: fleet.Team{ID: otherTeamID}, Role: fleet.RoleObserver}, + }, + } + // hostWithTeam belongs to teamID=42; teamObserver only has access to teamID=99. + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: teamObserver}) + _, _, _, _, err := svc.ListHostReports(viewerCtx, hostWithTeam.ID, fleet.ListHostReportsOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "forbidden") + }) +} + +// TestListHostReportsDatastorePassthrough verifies the options are forwarded +// to the datastore correctly. +func TestListHostReportsDatastorePassthrough(t *testing.T) { + ds := new(mock.Store) + + teamID := uint(5) + host := &fleet.Host{ID: 7, TeamID: &teamID} + + admin := &fleet.User{ + ID: 1, + GlobalRole: ptr.String(fleet.RoleAdmin), + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return host, nil + } + + capturedHostID := uint(0) + capturedTeamID := (*uint)(nil) + capturedOpts := fleet.ListHostReportsOptions{} + + ds.ListHostReportsFunc = func(ctx context.Context, hostID uint, tID *uint, opts fleet.ListHostReportsOptions, maxQueryReportRows int) ([]*fleet.HostReport, int, *fleet.PaginationMetadata, error) { + capturedHostID = hostID + capturedTeamID = tID + capturedOpts = opts + return nil, 0, nil, nil + } + + svc, ctx := newTestService(t, ds, nil, nil) + viewerCtx := viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + opts := fleet.ListHostReportsOptions{ + IncludeReportsDontStoreResults: true, + ListOptions: fleet.ListOptions{ + Page: 1, + PerPage: 10, + OrderKey: "name", + OrderDirection: fleet.OrderAscending, + MatchQuery: "Alpha", + }, + } + + _, _, _, _, err := svc.ListHostReports(viewerCtx, host.ID, opts) + require.NoError(t, err) + + assert.Equal(t, host.ID, capturedHostID) + assert.Equal(t, &teamID, capturedTeamID) + assert.True(t, capturedOpts.IncludeReportsDontStoreResults) + assert.Equal(t, uint(1), capturedOpts.ListOptions.Page) + assert.Equal(t, uint(10), capturedOpts.ListOptions.PerPage) + assert.Equal(t, "name", capturedOpts.ListOptions.OrderKey) + assert.Equal(t, "Alpha", capturedOpts.ListOptions.MatchQuery) +} + +// TestHostReportJSONRoundTrip verifies that HostReport serializes and +// deserializes correctly, including the FirstResult and LastFetched fields. +func TestHostReportJSONRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + + // This test exercises the HostReport struct's FirstResult field to ensure + // the data mapping from query_results.data JSON is correct. + report := &fleet.HostReport{ + QueryID: 1, + Name: "USB Devices", + Description: "List USB devices", + LastFetched: &now, + FirstResult: map[string]string{"model": "USB Keyboard", "vendor": "Apple Inc."}, + NHostResults: 0, + } + + // Verify JSON serialization round-trip + b, err := json.Marshal(report) + require.NoError(t, err) + + var decoded fleet.HostReport + err = json.Unmarshal(b, &decoded) + require.NoError(t, err) + + assert.Equal(t, report.QueryID, decoded.QueryID) + assert.Equal(t, report.Name, decoded.Name) + assert.Equal(t, report.Description, decoded.Description) + assert.Equal(t, report.FirstResult, decoded.FirstResult) + assert.Equal(t, report.NHostResults, decoded.NHostResults) + assert.NotNil(t, decoded.LastFetched) +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 220fb2b257..d4af02b67b 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -16042,3 +16042,330 @@ func (s *integrationTestSuite) TestOsqueryBodySizeLimit() { ts.DoRawNoAuth("POST", "/api/osquery/distributed/write", withinLimitDist, http.StatusOK) }) } + +func (s *integrationTestSuite) TestListHostReports() { + t := s.T() + ctx := t.Context() + + now := time.Now().UTC().Truncate(time.Second) + earlier := now.Add(-time.Hour) + + // Create a global host (no team). + host, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(t.Name()), + UUID: uuid.New().String(), + Hostname: t.Name() + ".local", + Platform: "linux", + }) + require.NoError(t, err) + + // Create two global queries that save results and one that discards them. + admin := s.users[TestAdminUserEmail] + qAlpha, err := s.ds.NewQuery(ctx, &fleet.Query{ + Name: t.Name() + "_alpha", + Query: "SELECT 1", + AuthorID: &admin.ID, + Saved: true, + DiscardData: false, + Logging: fleet.LoggingSnapshot, + Description: "alpha description", + }) + require.NoError(t, err) + + qBeta, err := s.ds.NewQuery(ctx, &fleet.Query{ + Name: t.Name() + "_beta", + Query: "SELECT 2", + AuthorID: &admin.ID, + Saved: true, + DiscardData: false, + Logging: fleet.LoggingSnapshot, + Description: "beta description", + }) + require.NoError(t, err) + + qDiscard, err := s.ds.NewQuery(ctx, &fleet.Query{ + Name: t.Name() + "_discard", + Query: "SELECT 3", + AuthorID: &admin.ID, + Saved: true, + DiscardData: true, + Logging: fleet.LoggingDifferential, // non-snapshot + discard_data=1 → "don't store results" + }) + require.NoError(t, err) + + // Insert two result rows for qAlpha on the host (to test has_more_results and first_result). + _, err = s.ds.OverwriteQueryResultRows(ctx, []*fleet.ScheduledQueryResultRow{ + {QueryID: qAlpha.ID, HostID: host.ID, LastFetched: earlier, Data: ptr.RawMessage([]byte(`{"col":"older"}`))}, + {QueryID: qAlpha.ID, HostID: host.ID, LastFetched: now, Data: ptr.RawMessage([]byte(`{"col":"newest"}`))}, + }, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + + // Insert one result row for qDiscard (only appears when include_reports_dont_store_results=true). + _, err = s.ds.OverwriteQueryResultRows(ctx, []*fleet.ScheduledQueryResultRow{ + {QueryID: qDiscard.ID, HostID: host.ID, LastFetched: now, Data: ptr.RawMessage([]byte(`{"col":"discarded"}`))}, + }, fleet.DefaultMaxQueryReportRows) + require.NoError(t, err) + + url := fmt.Sprintf("/api/latest/fleet/hosts/%d/reports", host.ID) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSONWithoutAuth("GET", url, nil, http.StatusUnauthorized, &resp) + }) + + t.Run("observer can read reports", func(t *testing.T) { + s.setTokenForTest(t, TestObserverUserEmail, test.GoodPassword) + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp) + require.NoError(t, resp.Err) + }) + + t.Run("admin can read reports", func(t *testing.T) { + s.setTokenForTest(t, TestAdminUserEmail, test.GoodPassword) + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp) + require.NoError(t, resp.Err) + }) + + t.Run("nonexistent host returns 404", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/99999999/reports", nil, http.StatusNotFound, &resp) + }) + + t.Run("default excludes dont-store-results queries", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name") + require.NoError(t, resp.Err) + assert.Equal(t, 2, resp.Count) + require.Len(t, resp.Reports, 2) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + assert.Equal(t, qBeta.Name, resp.Reports[1].Name) + for _, r := range resp.Reports { + assert.NotEqual(t, qDiscard.Name, r.Name) + } + }) + + t.Run("include_reports_dont_store_results=true returns all queries", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "include_reports_dont_store_results", "true", "order_key", "name") + require.NoError(t, resp.Err) + assert.Equal(t, 3, resp.Count) + require.Len(t, resp.Reports, 3) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + assert.Equal(t, qBeta.Name, resp.Reports[1].Name) + assert.Equal(t, qDiscard.Name, resp.Reports[2].Name) + }) + + t.Run("response fields are populated correctly", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name") + require.NoError(t, resp.Err) + require.Len(t, resp.Reports, 2) + + alpha := resp.Reports[0] + assert.Equal(t, qAlpha.ID, alpha.QueryID) + assert.Equal(t, qAlpha.Name, alpha.Name) + assert.Equal(t, "alpha description", alpha.Description) + // first_result is the most recent row. + require.NotNil(t, alpha.FirstResult) + assert.Equal(t, "newest", alpha.FirstResult["col"]) + // last_fetched is the most recent timestamp. + require.NotNil(t, alpha.LastFetched) + assert.Equal(t, now.Unix(), alpha.LastFetched.Unix()) + // n_host_results is 2 because there are 2 rows. + assert.Equal(t, 2, alpha.NHostResults) + assert.False(t, alpha.ReportClipped) + // qAlpha stores results (discard_data=false). + assert.True(t, alpha.StoreResults) + + // qBeta has no results yet. + beta := resp.Reports[1] + assert.Equal(t, qBeta.ID, beta.QueryID) + assert.Nil(t, beta.FirstResult) + assert.Nil(t, beta.LastFetched) + assert.Equal(t, 0, beta.NHostResults) + // qBeta also stores results. + assert.True(t, beta.StoreResults) + }) + + t.Run("store_results is false for discard queries", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "include_reports_dont_store_results", "true", "order_key", "name") + require.NoError(t, resp.Err) + require.Len(t, resp.Reports, 3) + // qDiscard has discard_data=true → store_results must be false. + discard := resp.Reports[2] + assert.Equal(t, qDiscard.Name, discard.Name) + assert.False(t, discard.StoreResults) + }) + + t.Run("features.save_reports_disabled reflects app config", func(t *testing.T) { + // Default: query reports are enabled. + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp) + assert.False(t, resp.Features.SavedReportsDisabled) + + // Save the current value before mutating. + var originalConfig fleet.AppConfig + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &originalConfig) + origDisabled := originalConfig.ServerSettings.QueryReportsDisabled + + // Disable query reports. + s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{"server_settings":{"query_reports_disabled":true}}`), http.StatusOK) + t.Cleanup(func() { + s.DoRaw("PATCH", "/api/latest/fleet/config", + fmt.Appendf(nil, `{"server_settings":{"query_reports_disabled":%v}}`, origDisabled), + http.StatusOK) + }) + + s.DoJSON("GET", url, nil, http.StatusOK, &resp) + assert.True(t, resp.Features.SavedReportsDisabled) + }) + + t.Run("report_clipped when total results reach the cap", func(t *testing.T) { + // Save the current cap before mutating. + var originalConfig fleet.AppConfig + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &originalConfig) + origCap := originalConfig.ServerSettings.QueryReportCap + + // Set the report cap to 2, which equals the number of rows already stored for qAlpha. + s.DoRaw("PATCH", "/api/latest/fleet/config", []byte(`{"server_settings":{"query_report_cap":2}}`), http.StatusOK) + t.Cleanup(func() { + s.DoRaw("PATCH", "/api/latest/fleet/config", + fmt.Appendf(nil, `{"server_settings":{"query_report_cap":%d}}`, origCap), + http.StatusOK) + }) + + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name") + require.NoError(t, resp.Err) + require.Len(t, resp.Reports, 2) + assert.True(t, resp.Reports[0].ReportClipped) // qAlpha has 2 rows == cap of 2 + assert.False(t, resp.Reports[1].ReportClipped) // qBeta has 0 rows + }) + + t.Run("name search", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "query", "alpha") + require.NoError(t, resp.Err) + assert.Equal(t, 1, resp.Count) + require.Len(t, resp.Reports, 1) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + }) + + t.Run("sort by name ascending and descending", func(t *testing.T) { + var resp listHostReportsResponse + + // Ascending (A→Z). + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name", "order_direction", "asc") + require.Len(t, resp.Reports, 2) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + assert.Equal(t, qBeta.Name, resp.Reports[1].Name) + + // Descending (Z→A). + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "name", "order_direction", "desc") + require.Len(t, resp.Reports, 2) + assert.Equal(t, qBeta.Name, resp.Reports[0].Name) + assert.Equal(t, qAlpha.Name, resp.Reports[1].Name) + }) + + t.Run("sort by last_fetched puts nulls last", func(t *testing.T) { + var resp listHostReportsResponse + + // ASC: qAlpha (has results) before qBeta (no results / NULL). + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "last_fetched", "order_direction", "asc") + require.Len(t, resp.Reports, 2) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + assert.Equal(t, qBeta.Name, resp.Reports[1].Name) + + // DESC: same NULL-last behaviour. + s.DoJSON("GET", url, nil, http.StatusOK, &resp, "order_key", "last_fetched", "order_direction", "desc") + require.Len(t, resp.Reports, 2) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + assert.Equal(t, qBeta.Name, resp.Reports[1].Name) + }) + + t.Run("pagination", func(t *testing.T) { + var resp listHostReportsResponse + + // Page 0, 1 item per page. + s.DoJSON("GET", url, nil, http.StatusOK, &resp, + "order_key", "name", "per_page", "1", "page", "0") + require.NoError(t, resp.Err) + assert.Equal(t, 2, resp.Count) + require.Len(t, resp.Reports, 1) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + require.NotNil(t, resp.Meta) + assert.True(t, resp.Meta.HasNextResults) + assert.False(t, resp.Meta.HasPreviousResults) + + // Page 1, 1 item per page. + s.DoJSON("GET", url, nil, http.StatusOK, &resp, + "order_key", "name", "per_page", "1", "page", "1") + require.NoError(t, resp.Err) + assert.Equal(t, 2, resp.Count) + require.Len(t, resp.Reports, 1) + assert.Equal(t, qBeta.Name, resp.Reports[0].Name) + require.NotNil(t, resp.Meta) + assert.False(t, resp.Meta.HasNextResults) + assert.True(t, resp.Meta.HasPreviousResults) + }) + + t.Run("default page size is 50", func(t *testing.T) { + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp) + require.NoError(t, resp.Err) + require.NotNil(t, resp.Meta) + // With only 2 queries, both fit within the default page size of 50. + assert.False(t, resp.Meta.HasNextResults) + assert.Len(t, resp.Reports, 2) + }) + + t.Run("alt path /hosts/:id/queries works", func(t *testing.T) { + altURL := fmt.Sprintf("/api/latest/fleet/hosts/%d/queries", host.ID) + var resp listHostReportsResponse + s.DoJSON("GET", altURL, nil, http.StatusOK, &resp, "order_key", "name") + require.NoError(t, resp.Err) + assert.Equal(t, 2, resp.Count) + }) + + t.Run("default sort is last_fetched descending (newest first)", func(t *testing.T) { + // With no order params, the endpoint defaults to last_fetched DESC. + // qAlpha has results (non-NULL last_fetched) so it comes first; qBeta + // has no results (NULL) so it sorts last. + var resp listHostReportsResponse + s.DoJSON("GET", url, nil, http.StatusOK, &resp) + require.NoError(t, resp.Err) + require.Len(t, resp.Reports, 2) + assert.Equal(t, qAlpha.Name, resp.Reports[0].Name) + assert.Equal(t, qBeta.Name, resp.Reports[1].Name) + }) + + t.Run("report_id alias is present in JSON response", func(t *testing.T) { + // The HostReport struct uses `renameto:"report_id"` on the QueryID field, + // which causes the endpointer to duplicate the key in the response as + // both "query_id" (deprecated) and "report_id" (new name). + rawBody := s.DoRaw("GET", url, nil, http.StatusOK) + var raw map[string]any + require.NoError(t, json.NewDecoder(rawBody.Body).Decode(&raw)) + + reports, ok := raw["reports"].([]any) + require.True(t, ok) + require.NotEmpty(t, reports) + + firstReport, ok := reports[0].(map[string]any) + require.True(t, ok) + + // Both the deprecated key and the new alias must be present. + _, hasQueryID := firstReport["query_id"] + _, hasReportID := firstReport["report_id"] + assert.True(t, hasQueryID, "expected deprecated key 'query_id' in response") + assert.True(t, hasReportID, "expected alias key 'report_id' in response") + assert.Equal(t, firstReport["query_id"], firstReport["report_id"]) + }) +}