diff --git a/changes/44170-list-mdm-commands-perf b/changes/44170-list-mdm-commands-perf new file mode 100644 index 0000000000..ede914716d --- /dev/null +++ b/changes/44170-list-mdm-commands-perf @@ -0,0 +1 @@ +- Fixed slow load times and timeouts on the list MDM commands API (`GET /api/v1/fleet/commands`) on Fleet deployments with many Windows hosts. The endpoint now caps `per_page` at 1,000 (default 10) and `page` at 100; requests above either limit return HTTP 400. To traverse beyond 100 pages, use cursor pagination via the `after` query parameter. diff --git a/changes/44422-list-mdm-commands-host-identifier-after b/changes/44422-list-mdm-commands-host-identifier-after new file mode 100644 index 0000000000..5e0df6d0fc --- /dev/null +++ b/changes/44422-list-mdm-commands-host-identifier-after @@ -0,0 +1 @@ +- Fixed `GET /api/v1/fleet/commands` returning a SQL error when called with `host_identifier` and the `after` cursor parameter, particularly with `order_key=command_uuid` or `order_key=hostname`. diff --git a/ee/server/service/orbit.go b/ee/server/service/orbit.go index a30be399e4..f68ba3a53e 100644 --- a/ee/server/service/orbit.go +++ b/ee/server/service/orbit.go @@ -108,6 +108,8 @@ func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNode User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, } acctCmds, _, _, err := svc.ds.ListMDMCommands(ctx, adminTeamFilter, &fleet.MDMCommandListOptions{ + // PerPage 1: only acctCmds[0] is read below. + ListOptions: fleet.ListOptions{PerPage: 1}, Filters: fleet.MDMCommandFilters{ HostIdentifier: host.UUID, RequestType: "AccountConfiguration", diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index d9431465a0..2cd330514d 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "time" @@ -29,16 +30,6 @@ import ( // failures don't churn nano commands and profile renders hourly. See issue #44111. const renewalFailedRetryBackoff = 24 * time.Hour -var mdmCommandsAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ - "command_uuid": "command_uuid", - "request_type": "request_type", - "status": "status", - "updated_at": "updated_at", - "hostname": "hostname", - "host_uuid": "host_uuid", - "name": "name", -} - func (ds *Datastore) GetMDMCommandPlatform(ctx context.Context, commandUUID string) (string, error) { stmt := ` SELECT CASE @@ -60,8 +51,17 @@ END AS platform return p, nil } -func getCombinedMDMCommandsQuery(ds *Datastore, hostFilter string) (string, []interface{}) { - appleStmt := ` +// getMDMCommandsSubqueries returns the Apple and Windows command-list +// sub-statements separately. The caller is responsible for wrapping each +// branch with the per-branch pagination (team filter, request_type filter, +// cursor predicate, ORDER BY, inner LIMIT) before merging them with +// UNION ALL. Paginating inside each branch keeps work per branch at +// O(page_size) instead of O(total commands). +// +// These subqueries are only used for the all-hosts listing; host-scoped +// requests go through listMDMCommandsByHostIdentifier instead. +func getMDMCommandsSubqueries() (appleStmt, windowsStmt string) { + appleStmt = ` SELECT nvq.id as host_uuid, nvq.command_uuid, @@ -81,34 +81,77 @@ WHERE nvq.active = 1 ` - windowsStmt := ` + // The Windows sub-statement is itself a UNION ALL of two branches: one + // driven by windows_mdm_command_queue (any command pending or in + // flight), the other driven by windows_mdm_command_results (any + // command that produced a result). Branch B's NOT EXISTS clause + // excludes (command_uuid, enrollment_id) pairs already covered by + // branch A so the union does not double-count a single command/host + // pair. + windowsStmt = ` SELECT mwe.host_uuid, wmc.command_uuid, COALESCE(NULLIF(wmcr.status_code, ''), '101') as status, - COALESCE(wmc.updated_at, wmc.created_at) as updated_at, + COALESCE(wmcr.updated_at, wmc.updated_at, wmc.created_at) as updated_at, wmc.target_loc_uri as request_type, h.hostname, h.team_id, NULL as name FROM windows_mdm_commands wmc -LEFT JOIN windows_mdm_command_queue wmcq ON wmcq.command_uuid = wmc.command_uuid -LEFT JOIN windows_mdm_command_results wmcr ON wmc.command_uuid = wmcr.command_uuid -INNER JOIN mdm_windows_enrollments mwe ON wmcq.enrollment_id = mwe.id OR wmcr.enrollment_id = mwe.id +INNER JOIN windows_mdm_command_queue wmcq ON wmcq.command_uuid = wmc.command_uuid +INNER JOIN mdm_windows_enrollments mwe ON wmcq.enrollment_id = mwe.id INNER JOIN hosts h ON h.uuid = mwe.host_uuid +LEFT JOIN windows_mdm_command_results wmcr + ON wmcr.command_uuid = wmc.command_uuid AND wmcr.enrollment_id = mwe.id WHERE TRUE + +UNION ALL + +SELECT + mwe.host_uuid, + wmc.command_uuid, + COALESCE(NULLIF(wmcr.status_code, ''), '101') as status, + COALESCE(wmcr.updated_at, wmc.updated_at, wmc.created_at) as updated_at, + wmc.target_loc_uri as request_type, + h.hostname, + h.team_id, + NULL as name +FROM windows_mdm_commands wmc +INNER JOIN windows_mdm_command_results wmcr ON wmcr.command_uuid = wmc.command_uuid +INNER JOIN mdm_windows_enrollments mwe ON wmcr.enrollment_id = mwe.id +INNER JOIN hosts h ON h.uuid = mwe.host_uuid +WHERE NOT EXISTS ( + SELECT 1 FROM windows_mdm_command_queue wmcq2 + WHERE wmcq2.command_uuid = wmc.command_uuid AND wmcq2.enrollment_id = mwe.id +) ` - var params []interface{} - appleStmtWithFilter, params := ds.whereFilterHostsByIdentifier(hostFilter, appleStmt, params) - windowsStmtWithFilter, params := ds.whereFilterHostsByIdentifier(hostFilter, windowsStmt, params) + return appleStmt, windowsStmt +} - stmt := fmt.Sprintf( +// mdmCommandsOrderAllowlist is the closed set of order_key values accepted +// by GET /api/v1/fleet/commands and GET /api/v1/fleet/mdm/commands. +var mdmCommandsOrderAllowlist = common_mysql.OrderKeyAllowlist{ + "host_uuid": "host_uuid", + "command_uuid": "command_uuid", + "status": "status", + "updated_at": "updated_at", + "request_type": "request_type", + "hostname": "hostname", + "name": "name", +} + +// getCombinedMDMCommandsQuery returns the legacy combined statement +// (Apple UNION ALL Windows) ending in `WHERE `. Used by getMDMCommand for +// single-command lookups; the list-commands path builds its own form +// (see getMDMCommandsSubqueries). +func getCombinedMDMCommandsQuery() string { + appleStmt, windowsStmt := getMDMCommandsSubqueries() + return fmt.Sprintf( `SELECT * FROM ((%s) UNION ALL (%s)) as combined_commands WHERE `, - appleStmtWithFilter, windowsStmtWithFilter, + appleStmt, windowsStmt, ) - - return stmt, params } func (ds *Datastore) ListMDMCommands( @@ -116,20 +159,78 @@ func (ds *Datastore) ListMDMCommands( tmFilter fleet.TeamFilter, listOpts *fleet.MDMCommandListOptions, ) ([]*fleet.MDMCommand, *int64, *fleet.PaginationMetadata, error) { - if listOpts != nil && listOpts.Filters.HostIdentifier != "" { + if listOpts == nil || listOpts.PerPage == 0 { + return nil, nil, nil, ctxerr.Wrap(ctx, errors.New("ListMDMCommands requires listOpts.PerPage > 0")) + } + if listOpts.Filters.HostIdentifier != "" { // separate codepath for more performant query by host identifier return ds.listMDMCommandsByHostIdentifier(ctx, tmFilter, listOpts) } - jointStmt, params := getCombinedMDMCommandsQuery(ds, listOpts.Filters.HostIdentifier) - jointStmt += ds.whereFilterHostsByTeams(tmFilter, "combined_commands") - jointStmt, params = addRequestTypeFilter(jointStmt, &listOpts.Filters, params) - jointStmt, params, err := appendListOptionsWithCursorToSQLSecure(jointStmt, params, &listOpts.ListOptions, mdmCommandsAllowedOrderKeys) - if err != nil { - return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands") + if listOpts.OrderKey == "" { + listOpts.OrderKey = "updated_at" + listOpts.OrderDirection = fleet.OrderDescending } + + appleStmt, windowsStmt := getMDMCommandsSubqueries() + + // Per-branch pagination: without this, the UNION ALL would materialize every command on + // both sides before pagination, which times out at scale (#44170). + innerOpts := listOpts.ListOptions + // For page-based pagination, inner LIMIT = page*per_page + per_page; + // the secure helper adds +1 because IncludeMetadata is true. For + // cursor-based pagination (After != ""), the helper ignores Page, so + // don't inflate the inner LIMIT (per_page+1 is sufficient). Inner + // Page=0 suppresses the inner OFFSET; the outer wrapper handles + // offset slicing. + if innerOpts.After == "" { + innerOpts.PerPage = innerOpts.PerPage*innerOpts.Page + innerOpts.PerPage + } + innerOpts.Page = 0 + innerOpts.IncludeMetadata = true + + paginateBranch := func(branch string, params []any) (string, []any, error) { + wrapped := fmt.Sprintf("SELECT * FROM (%s) AS branch WHERE ", branch) + wrapped += ds.whereFilterHostsByTeams(tmFilter, "branch") + wrapped, params = addRequestTypeFilter(wrapped, &listOpts.Filters, params) + return appendListOptionsWithCursorToSQLSecure(wrapped, params, &innerOpts, mdmCommandsOrderAllowlist) + } + + // Each branch needs its own params slice; sqlx.SelectContext binds + // placeholders left-to-right across the merged statement. + var appleParams, windowsParams []any + var err error + if appleStmt, appleParams, err = paginateBranch(appleStmt, appleParams); err != nil { + return nil, nil, nil, ctxerr.Wrap(ctx, err, "paginate apple commands branch") + } + if windowsStmt, windowsParams, err = paginateBranch(windowsStmt, windowsParams); err != nil { + return nil, nil, nil, ctxerr.Wrap(ctx, err, "paginate windows commands branch") + } + + mergedStmt := fmt.Sprintf( + "SELECT * FROM ((%s) UNION ALL (%s)) AS combined_commands", + appleStmt, windowsStmt, + ) + mergedParams := append([]any{}, appleParams...) + mergedParams = append(mergedParams, windowsParams...) + + // Outer pagination: ORDER BY + LIMIT + OFFSET only. The cursor + // predicate is already applied inside each branch, so clear After + // here. If the original request used cursor pagination, also clear + // Page so the outer query does not apply an OFFSET on top of the + // per-branch cursor filtering. + outerOpts := listOpts.ListOptions + if outerOpts.After != "" { + outerOpts.After = "" + outerOpts.Page = 0 + } + mergedStmt, mergedParams, err = appendListOptionsWithCursorToSQLSecure(mergedStmt, mergedParams, &outerOpts, mdmCommandsOrderAllowlist) + if err != nil { + return nil, nil, nil, ctxerr.Wrap(ctx, err, "merge mdm commands pagination") + } + var results []*fleet.MDMCommand - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, jointStmt, params...); err != nil { + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, mergedStmt, mergedParams...); err != nil { return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands") } @@ -163,9 +264,9 @@ func (ds *Datastore) listMDMCommandsByHostIdentifier( // First, search for host by identifier (hostname, uuid, or hardware_serial). // - // NOTE: We're not using existing methods like ds.whereFilterHostsByIdentifier, - // ds.HostIDsByIdentifier, ds.HostLiteByIdentifier because those methods are poorly - // optimized for the indexes we currently have on the hosts table. + // NOTE: We're not using existing methods like ds.HostIDsByIdentifier or + // ds.HostLiteByIdentifier because those methods are poorly optimized for + // the indexes we currently have on the hosts table. // They filter with disjunctive conditions like `hostname = ? OR uuid = ?` as well as // `? IN(hostname, uuid)`. These existing queries aren't really suited for either composite // indexes or indexes on individual columns, and the optimizer ends up with executions that @@ -252,10 +353,12 @@ SELECT ELSE 'pending' END AS command_status, request_type, - nc.name + nc.name, + h.hostname FROM nano_enrollment_queue nq JOIN nano_commands nc ON nq.command_uuid = nc.command_uuid + JOIN hosts h ON h.uuid = nq.id LEFT JOIN nano_command_results ncr ON nq.id = ncr.id AND nc.command_uuid = ncr.command_uuid WHERE @@ -279,11 +382,13 @@ WHERE '101' AS status, 'pending' AS command_status, wc.target_loc_uri AS request_type, - NULL AS name + NULL AS name, + h.hostname FROM windows_mdm_command_queue wq JOIN mdm_windows_enrollments mwe ON mwe.id = wq.enrollment_id JOIN windows_mdm_commands wc ON wc.command_uuid = wq.command_uuid + JOIN hosts h ON h.uuid = mwe.host_uuid WHERE mwe.host_uuid IN (?) @@ -316,11 +421,13 @@ WHERE ) >= 400 THEN 'failed' END AS command_status, wc.target_loc_uri AS request_type, - NULL AS name + NULL AS name, + h.hostname FROM windows_mdm_command_results wcr JOIN mdm_windows_enrollments mwe ON mwe.id = wcr.enrollment_id JOIN windows_mdm_commands wc ON wc.command_uuid = wcr.command_uuid + JOIN hosts h ON h.uuid = mwe.host_uuid WHERE mwe.host_uuid IN (?) @@ -346,19 +453,25 @@ WHERE var listStmt, countStmt string var params []any + // Wrap in `SELECT * FROM (...) u WHERE TRUE` so the cursor and ORDER BY + // predicates resolve against the unambiguous `u` projection — the inner + // branches join multiple tables that all expose `command_uuid` / `updated_at`. + // `WHERE TRUE` is required because the cursor helper picks AND vs WHERE by + // substring-matching "where", picks AND from the inner branches, and would + // otherwise emit a dangling `AND`. See https://github.com/fleetdm/fleet/issues/44422. switch { case len(appleUUIDs) > 0 && len(winUUIDs) > 0: - listStmt = fmt.Sprintf(`SELECT * FROM ((%s) UNION ALL (%s)) u`, + listStmt = fmt.Sprintf(`SELECT * FROM ((%s) UNION ALL (%s)) u WHERE TRUE`, appleStmt, winStmt) countStmt = fmt.Sprintf(`SELECT COUNT(1) FROM ((%s) UNION ALL (%s)) u`, appleStmt, winStmt) params = append(params, appleParams...) params = append(params, winParams...) case len(appleUUIDs) > 0: - listStmt = appleStmt + listStmt = `SELECT * FROM (` + appleStmt + `) u WHERE TRUE` countStmt = `SELECT COUNT(1) FROM (` + appleStmt + `) u` params = appleParams case len(winUUIDs) > 0: - listStmt = winStmt + listStmt = `SELECT * FROM (` + winStmt + `) u WHERE TRUE` countStmt = `SELECT COUNT(1) FROM (` + winStmt + `) u` params = winParams } @@ -378,12 +491,15 @@ WHERE // if listOpts.OrderDirection == "" { // listOpts.OrderDirection = fleet.OrderDescending // } - if listOpts.PerPage == 0 { - listOpts.PerPage = 10 - } - listStmt, params, err = appendListOptionsWithCursorToSQLSecure(listStmt, params, &listOpts.ListOptions, mdmCommandsAllowedOrderKeys) + // Snapshot the params before the cursor helper appends to them. countStmt + // has no cursor placeholder, so it must run with the pre-cursor args. + countParams := slices.Clone(params) + // Validate order_key against the closed allowlist before it reaches + // ORDER BY (defense against SQL injection / information disclosure + // via arbitrary column references). + listStmt, params, err = appendListOptionsWithCursorToSQLSecure(listStmt, params, &listOpts.ListOptions, mdmCommandsOrderAllowlist) if err != nil { - return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands") + return nil, nil, nil, ctxerr.Wrap(ctx, err, "list commands pagination") } var results []*fleet.MDMCommand @@ -394,15 +510,15 @@ WHERE var total *int64 if len(listOpts.Filters.CommandStatuses) == 1 && listOpts.Filters.CommandStatuses[0] == fleet.MDMCommandStatusFilterPending { // Only get count if we only filter by pending - if err := sqlx.GetContext(ctx, ds.reader(ctx), &total, countStmt, params...); err != nil { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &total, countStmt, countParams...); err != nil { return nil, nil, nil, ctxerr.Wrap(ctx, err, "count commands") } } - // Add hostname and team info to the results based on the host UUIDs. + // Hostname is now projected in SQL on both branches; only team_id + // still needs to be merged from the prefetched host lookup. for i := range results { if host, ok := byUUID[results[i].HostUUID]; ok { - results[i].Hostname = host.Hostname results[i].TeamID = host.TeamID } } @@ -451,8 +567,7 @@ func addAppleCommandStatusFilter(stmt string, filter *fleet.MDMCommandFilters, p } func (ds *Datastore) getMDMCommand(ctx context.Context, q sqlx.QueryerContext, cmdUUID string) (*fleet.MDMCommand, error) { - stmt, _ := getCombinedMDMCommandsQuery(ds, "") - stmt += "command_uuid = ?" + stmt := getCombinedMDMCommandsQuery() + "command_uuid = ?" var cmd fleet.MDMCommand if err := sqlx.GetContext(ctx, q, &cmd, stmt, cmdUUID); err != nil { diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 35ab1ed255..8f03a49b37 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -19,6 +19,7 @@ import ( microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth" + common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" @@ -39,6 +40,8 @@ func TestMDMShared(t *testing.T) { {"TestListMDMCommandsWithTeamFilter", testListMDMCommandsWithTeamFilter}, {"TestListMDMCommandsOrderKeys", testListMDMCommandsOrderKeys}, {"TestListMDMAppleCommandsOrderKeys", testListMDMAppleCommandsOrderKeys}, + {"TestListMDMCommandsRequiresPerPage", testListMDMCommandsRequiresPerPage}, + {"TestListMDMCommandsPagination", testListMDMCommandsPagination}, {"TestBatchSetMDMProfiles", testBatchSetMDMProfiles}, {"TestListMDMConfigProfiles", testListMDMConfigProfiles}, {"TestBulkSetPendingMDMHostProfiles", testBulkSetPendingMDMHostProfiles}, @@ -76,7 +79,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx := context.Background() // no commands or devices enrolled => no results - cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{}, &fleet.MDMCommandListOptions{}) + cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}) require.NoError(t, err) require.Empty(t, cmds) @@ -134,7 +137,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { cmds, _, _, err = ds.ListMDMCommands( ctx, fleet.TeamFilter{User: test.UserAdmin}, - &fleet.MDMCommandListOptions{}, + &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}, ) require.NoError(t, err) require.Empty(t, cmds) @@ -152,7 +155,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { cmds, total, _, err := ds.ListMDMCommands( ctx, fleet.TeamFilter{User: test.UserAdmin}, - &fleet.MDMCommandListOptions{}, + &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}, ) require.NoError(t, err) require.Len(t, cmds, 1) @@ -172,7 +175,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ - ListOptions: fleet.ListOptions{OrderKey: "hostname"}, + ListOptions: fleet.ListOptions{OrderKey: "hostname", PerPage: 100}, }) require.NoError(t, err) require.Len(t, cmds, 2) @@ -211,7 +214,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ - ListOptions: fleet.ListOptions{OrderKey: "hostname"}, + ListOptions: fleet.ListOptions{OrderKey: "hostname", PerPage: 100}, }) require.NoError(t, err) require.Len(t, cmds, 2) @@ -255,6 +258,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{ HostIdentifier: "non-existent", }, @@ -268,6 +272,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{ RequestType: "non-existent", }, @@ -336,6 +341,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{ HostIdentifier: tc.identifier, CommandStatuses: commandStatuses, @@ -383,10 +389,10 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "hostname", OrderDirection: fleet.OrderAscending, PerPage: 100}, Filters: fleet.MDMCommandFilters{ RequestType: "InstallProfile", }, - ListOptions: fleet.ListOptions{OrderKey: "hostname", OrderDirection: fleet.OrderAscending}, }, ) require.NoError(t, err) @@ -399,6 +405,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{ RequestType: "InstallProfile", HostIdentifier: macH.UUID, @@ -414,6 +421,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{ HostIdentifier: "123456", CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterPending}, @@ -455,6 +463,7 @@ func testMDMCommands(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{ HostIdentifier: macH.UUID, CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterRan, fleet.MDMCommandStatusFilterFailed}, @@ -486,6 +495,213 @@ func testMDMCommands(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, true, meta.HasNextResults) require.Equal(t, false, meta.HasPreviousResults) + + // Cursor pagination on the host-scoped path. Regression test for + // https://github.com/fleetdm/fleet/issues/44422: + // "Error 1052 (23000): Column '' in where clause is ambiguous". + // Covers all three Path A SQL shapes: Apple-only, Windows-only, and + // multi-platform (host_identifier resolves to both an Apple and a + // Windows host via the shared hostname "test-host"). + t.Run("cursor_pagination_44422", func(t *testing.T) { + for _, tc := range []struct { + name string + hostIdentifier string + wantPlatforms []string + }{ + {"apple_only", macH.UUID, []string{"darwin"}}, + {"multi_platform", "test-host", []string{"darwin", "windows"}}, + {"windows_only", windowsH.UUID, []string{"windows"}}, + } { + t.Run(tc.name, func(t *testing.T) { + filters := fleet.MDMCommandFilters{HostIdentifier: tc.hostIdentifier} + + // Sanity: confirm the dispatch reaches the expected branch(es) + // and there is data to paginate over. + all, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{PerPage: 100}, + Filters: filters, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, all) + platforms := map[string]struct{}{} + for _, c := range all { + switch c.HostUUID { + case macH.UUID: + platforms["darwin"] = struct{}{} + case windowsH.UUID: + platforms["windows"] = struct{}{} + } + } + for _, p := range tc.wantPlatforms { + _, ok := platforms[p] + require.True(t, ok, "expected commands from platform %s", p) + } + + // Cursor on updated_at — `updated_at` is exposed by multiple + // inner-FROM tables on every Path A branch, so this would fail + // with "Column 'updated_at' ... is ambiguous" without the wrap. + page1, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 1, + OrderKey: "updated_at", + OrderDirection: fleet.OrderDescending, + }, + Filters: filters, + }, + ) + require.NoError(t, err) + require.Len(t, page1, 1) + + page2, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 1, + OrderKey: "updated_at", + OrderDirection: fleet.OrderDescending, + After: page1[0].UpdatedAt.Format(time.RFC3339Nano), + }, + Filters: filters, + }, + ) + // require.NoError is the regression guard. The cursor predicate + // is strict (`updated_at < ?`, no tiebreaker), so page2 may be + // empty when adjacent commands share an updated_at second — that + // is expected, not a regression. Only assert distinctness when + // a row did come back. + require.NoError(t, err) + if len(page2) > 0 { + require.NotEqual(t, page1[0].CommandUUID, page2[0].CommandUUID) + } + + // Cursor on command_uuid — separate ambiguity manifestation; + // `command_uuid` is the join column on every inner table. + first, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 1, + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + }, + Filters: filters, + }, + ) + require.NoError(t, err) + require.Len(t, first, 1) + + next, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 1, + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + After: first[0].CommandUUID, + }, + Filters: filters, + }, + ) + require.NoError(t, err) + require.NotEmpty(t, next) + require.NotEqual(t, first[0].CommandUUID, next[0].CommandUUID) + + // Cursor on hostname. All hosts in this test share hostname + // "test-host", and the helper's cursor predicate is a strict + // `hostname > 'test-host'` (no tiebreaker), so the result is + // expected to be empty. The bug would surface as a SQL error + // before the empty check. + afterHostname, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 5, + OrderKey: "hostname", + OrderDirection: fleet.OrderAscending, + After: "test-host", + }, + Filters: filters, + }, + ) + require.NoError(t, err) + require.Empty(t, afterHostname) + + // Pending-only count + cursor + if tc.name == "apple_only" { + pendingFilters := fleet.MDMCommandFilters{ + HostIdentifier: tc.hostIdentifier, + CommandStatuses: []fleet.MDMCommandStatusFilter{fleet.MDMCommandStatusFilterPending}, + } + // Establish the unfiltered pending baseline so the post-cursor + // assertions don't depend on UUID lexicographic ordering. + allPending, totalPending, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 100, + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + IncludeMetadata: true, + }, + Filters: pendingFilters, + }, + ) + require.NoError(t, err) + require.NotNil(t, totalPending) + require.Equal(t, int64(len(allPending)), *totalPending) + require.Greater(t, len(allPending), 1, "test setup expects multiple pending Apple commands") + + // Drive the cursor from a known pending UUID so the post-cursor + // expectation is deterministic. + cursorUUID := allPending[0].CommandUUID + expectedAfter := allPending[1:] + + afterPending, totalAfter, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + PerPage: 100, + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + After: cursorUUID, + IncludeMetadata: true, + }, + Filters: pendingFilters, + }, + ) + require.NoError(t, err) + // total comes from countStmt which has no cursor, so it + // must report the full pending count regardless of After. + require.NotNil(t, totalAfter) + require.Equal(t, *totalPending, *totalAfter) + require.Len(t, afterPending, len(expectedAfter)) + gotUUIDs := make([]string, len(afterPending)) + for i, c := range afterPending { + gotUUIDs[i] = c.CommandUUID + } + expectedUUIDs := make([]string, len(expectedAfter)) + for i, c := range expectedAfter { + expectedUUIDs[i] = c.CommandUUID + } + require.Equal(t, expectedUUIDs, gotUUIDs) + } + }) + } + }) + } // testListMDMCommandsWithTeamFilter tests listing MDM commands with team filters @@ -528,7 +744,7 @@ func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) { cmds, _, _, err := ds.ListMDMCommands( ctx, fleet.TeamFilter{User: teamUser}, - &fleet.MDMCommandListOptions{}, + &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}, ) require.NoError(t, err) require.Len(t, cmds, 1) @@ -538,7 +754,7 @@ func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) { cmds, _, _, err = ds.ListMDMCommands( ctx, fleet.TeamFilter{User: teamUser, TeamID: &team.ID}, - &fleet.MDMCommandListOptions{}, + &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}, ) require.NoError(t, err) require.Len(t, cmds, 1) @@ -566,7 +782,7 @@ func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) { cmds, _, _, err = ds.ListMDMCommands( ctx, fleet.TeamFilter{User: teamUser}, - &fleet.MDMCommandListOptions{}, + &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}, ) require.NoError(t, err) require.Len(t, cmds, 1) @@ -577,7 +793,7 @@ func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) { cmds, _, _, err = ds.ListMDMCommands( ctx, fleet.TeamFilter{User: adminUser}, - &fleet.MDMCommandListOptions{}, + &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}, ) require.NoError(t, err) require.Len(t, cmds, 2) @@ -588,6 +804,178 @@ func testListMDMCommandsWithTeamFilter(t *testing.T, ds *Datastore) { require.ElementsMatch(t, []string{teamCmdUUID, globalCmdUUID}, got) } +func testListMDMCommandsRequiresPerPage(t *testing.T, ds *Datastore) { + ctx := t.Context() + + _, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "PerPage") + + _, _, _, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "PerPage") +} + +// testListMDMCommandsPagination exercises the all-hosts per-branch +// pagination across both Apple and Windows branches. The path inflates the +// inner LIMIT to per_page*page+per_page so that page N is correct even if all +// matching rows came from a single branch, and the outer wrap clears +// After/Page when cursor pagination is used. +func testListMDMCommandsPagination(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Enroll one Windows host and one macOS host. + winHost := test.NewHost(t, ds, "paginate-win", "1.2.3.4", "paginate-node-win", uuid.NewString(), time.Now(), test.WithPlatform("windows")) + winDeviceID := windowsEnroll(t, ds, winHost) + winEnrollment, err := ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, winDeviceID) + require.NoError(t, err) + + macHost := test.NewHost(t, ds, "paginate-mac", "1.2.3.5", "paginate-node-mac", uuid.NewString(), time.Now()) + nanoEnroll(t, ds, macHost, false) + + // Insert 3 Apple commands and 3 Windows commands so both branches contribute. + commander, _ := createMDMAppleCommanderAndStorage(t, ds) + const totalApple = 3 + const totalWin = 3 + allUUIDs := make([]string, 0, totalApple+totalWin) + for range totalApple { + cmdUUID := uuid.NewString() + raw := createRawAppleCmd("ProfileList", cmdUUID) + require.NoError(t, commander.EnqueueCommand(ctx, []string{macHost.UUID}, raw)) + allUUIDs = append(allUUIDs, cmdUUID) + } + winUUIDs := make([]string, 0, totalWin) + for range totalWin { + cmdUUID := uuid.NewString() + require.NoError(t, ds.MDMWindowsInsertCommandForHosts(ctx, []string{winDeviceID}, &fleet.MDMWindowsCommand{ + CommandUUID: cmdUUID, + RawCommand: []byte(""), + TargetLocURI: "./test/uri", + })) + winUUIDs = append(winUUIDs, cmdUUID) + allUUIDs = append(allUUIDs, cmdUUID) + } + + // Mark one Windows command as responded so the results-backed branch of the + // internal Windows UNION ALL is exercised. The dedupe NOT EXISTS clause + // must still keep the command from appearing twice across the pagination. + respondedWinUUID := winUUIDs[0] + _, err = ds.MDMWindowsSaveResponse(ctx, winEnrollment, fleet.EnrichedSyncML{ + SyncML: &fleet.SyncML{Raw: []byte("")}, + CmdRefUUIDToStatus: map[string]fleet.SyncMLCmd{ + respondedWinUUID: {Data: ptr.String("200")}, + }, + CmdRefUUIDs: []string{respondedWinUUID}, + }, []string{}) + require.NoError(t, err) + + sort.Strings(allUUIDs) + totalCount := len(allUUIDs) + + t.Run("page-based across branches", func(t *testing.T) { + // PerPage=2 across 6 commands => 3 pages of 2 each. + const perPage = 2 + seen := make(map[string]bool) + for page := uint(0); page*perPage < uint(totalCount); page++ { //nolint:gosec + cmds, _, meta, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + Page: page, + PerPage: perPage, + IncludeMetadata: true, + }, + }, + ) + require.NoError(t, err) + require.LessOrEqual(t, len(cmds), perPage) + + start := int(page * perPage) + end := min(start+perPage, totalCount) + expected := allUUIDs[start:end] + got := make([]string, 0, len(cmds)) + for _, c := range cmds { + require.False(t, seen[c.CommandUUID], "duplicate command UUID across pages: %s", c.CommandUUID) + seen[c.CommandUUID] = true + got = append(got, c.CommandUUID) + } + require.Equal(t, expected, got, "page %d", page) + + require.Equal(t, page > 0, meta.HasPreviousResults, "page %d HasPreviousResults", page) + require.Equal(t, end < totalCount, meta.HasNextResults, "page %d HasNextResults", page) + } + require.Len(t, seen, totalCount) + }) + + t.Run("cursor walks all rows without overlap", func(t *testing.T) { + const perPage = 2 + seen := make(map[string]bool) + var after string + for { + cmds, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + PerPage: perPage, + After: after, + IncludeMetadata: true, + }, + }, + ) + require.NoError(t, err) + if len(cmds) == 0 { + break + } + for _, c := range cmds { + require.False(t, seen[c.CommandUUID], "duplicate command UUID across cursor pages: %s", c.CommandUUID) + seen[c.CommandUUID] = true + } + after = cmds[len(cmds)-1].CommandUUID + if len(cmds) < perPage { + break + } + } + require.Len(t, seen, totalCount) + }) + + t.Run("outer clears Page when After is set", func(t *testing.T) { + // Without the outer-wrap clearing of Page, Page=99 with PerPage=2 would + // add OFFSET 198 on top of the cursor filter and return zero rows even + // though plenty of rows follow allUUIDs[0]. With the clearing, the call + // must return the page-sized slice immediately after the cursor. + cmds, _, _, err := ds.ListMDMCommands( + ctx, + fleet.TeamFilter{User: test.UserAdmin}, + &fleet.MDMCommandListOptions{ + ListOptions: fleet.ListOptions{ + OrderKey: "command_uuid", + OrderDirection: fleet.OrderAscending, + PerPage: 2, + Page: 99, + After: allUUIDs[0], + IncludeMetadata: true, + }, + }, + ) + require.NoError(t, err) + require.Equal(t, allUUIDs[1:3], extractCommandUUIDs(cmds)) + }) +} + +func extractCommandUUIDs(cmds []*fleet.MDMCommand) []string { + out := make([]string, 0, len(cmds)) + for _, c := range cmds { + out = append(out, c.CommandUUID) + } + return out +} + func testListMDMCommandsOrderKeys(t *testing.T, ds *Datastore) { ctx := t.Context() @@ -627,10 +1015,12 @@ func testListMDMCommandsOrderKeys(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ - ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column"}, + ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column", PerPage: 5}, }, ) require.Error(t, err) + var invalidKeyErr common_mysql.InvalidOrderKeyError + require.ErrorAs(t, err, &invalidKeyErr) }) // the host-identifier branch uses a separate query; confirm it shares the allowlist @@ -639,11 +1029,13 @@ func testListMDMCommandsOrderKeys(t *testing.T, ds *Datastore) { ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ - ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column"}, + ListOptions: fleet.ListOptions{OrderKey: "not_a_real_column", PerPage: 5}, Filters: fleet.MDMCommandFilters{HostIdentifier: macH.UUID}, }, ) require.Error(t, err) + var invalidKeyErr common_mysql.InvalidOrderKeyError + require.ErrorAs(t, err, &invalidKeyErr) }) t.Run("after_pagination_with_allowed_key", func(t *testing.T) { @@ -9566,7 +9958,7 @@ func testDeleteMDMProfilesCancelsInstalls(t *testing.T, ds *Datastore) { cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{ User: test.UserAdmin, IncludeObserver: true, - }, &fleet.MDMCommandListOptions{Filters: fleet.MDMCommandFilters{HostIdentifier: host1.UUID}}) + }, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}, Filters: fleet.MDMCommandFilters{HostIdentifier: host1.UUID}}) require.NoError(t, err) require.Len(t, cmds, 0) @@ -9832,7 +10224,7 @@ func testEnqueueCommandWithName(t *testing.T, ds *Datastore) { require.Equal(t, "Test Profile Name", storedName.String) // Also verify via ListMDMCommands - cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{}) + cmds, _, _, err := ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}) require.NoError(t, err) require.Len(t, cmds, 1) require.NotNil(t, cmds[0].Name) @@ -9853,7 +10245,7 @@ func testEnqueueCommandWithName(t *testing.T, ds *Datastore) { // Verify name is null in the API // Verify ListMDMCommands also exposes nil Name for unnamed commands - cmds, _, _, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{}) + cmds, _, _, err = ds.ListMDMCommands(ctx, fleet.TeamFilter{User: test.UserAdmin}, &fleet.MDMCommandListOptions{ListOptions: fleet.ListOptions{PerPage: 100}}) require.NoError(t, err) require.Len(t, cmds, 2) diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index a30cdf061f..efac0d8236 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1110,17 +1110,6 @@ func (ds *Datastore) whereOmitIDs(colName string, omit []uint) string { return fmt.Sprintf("%s NOT IN (%s)", colName, strings.Join(idStrs, ",")) } -func (ds *Datastore) whereFilterHostsByIdentifier(identifier, stmt string, params []interface{}) (string, []interface{}) { - if identifier == "" { - return stmt, params - } - - stmt += " AND ? IN (h.hostname, h.osquery_host_id, h.node_key, h.uuid, h.hardware_serial)" - params = append(params, identifier) - - return stmt, params -} - // registerTLS adds client certificate configuration to the mysql connection. func registerTLS(conf config.MysqlConfig) error { tlsCfg := config.TLS{ diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 9f23c370f7..25ea4f2a3e 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -423,6 +423,18 @@ type MDMCommandListOptions struct { Filters MDMCommandFilters } +// Pagination bounds for the list-MDM-commands endpoints (GET /api/v1/fleet/commands and GET /api/v1/fleet/mdm/commands). +const ( + // DefaultMDMCommandsPerPage is the per_page value used when none is specified on the request. + DefaultMDMCommandsPerPage uint = 10 + // MaxMDMCommandsPerPage caps per_page so a single request can't scan an unbounded number of command rows. + MaxMDMCommandsPerPage uint = 1000 + // MaxMDMCommandsPage caps the offset (page * per_page) so deep + // traversal can't cause a timeout issue. Clients that need to walk the full set + // should use cursor pagination via the after query parameter. + MaxMDMCommandsPage uint = 100 +) + type MDMCommandStatusFilter string const ( diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 654ade7e84..cb61ce6836 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -4017,6 +4017,40 @@ func (s *integrationMDMTestSuite) TestListMDMCommands() { res = s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/mdm/commands?host_identifier=%s&command_status=ran", h.UUID), nil, http.StatusBadRequest) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, `Currently, "command_status" filter is only available for macOS, iOS, and iPadOS hosts.`) + + // per_page above the documented maximum is rejected with a clear message. + res = s.DoRaw("GET", "/api/latest/fleet/mdm/commands?per_page=1001", nil, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Please set a per_page limit of 1000 or less") + + // per_page at the cap is accepted. + s.DoRaw("GET", "/api/latest/fleet/mdm/commands?per_page=1000", nil, http.StatusOK) + + // page above the cap is rejected so the inner LIMIT (page*per_page+per_page+1) + // stays bounded. + res = s.DoRaw("GET", "/api/latest/fleet/mdm/commands?page=101", nil, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Please set page to 100 or less") + + // page at the cap is accepted. + s.DoRaw("GET", "/api/latest/fleet/mdm/commands?page=100", nil, http.StatusOK) + + // order_key not in the allowlist is rejected by the secure list-options + // helper (defense against SQL injection via crafted ORDER BY). The + // helper's InvalidOrderKeyError implements the validation-error + // interface, so the response is a 422. Both the unscoped path and + // the host-scoped path enforce the same allowlist. + res = s.DoRaw("GET", "/api/latest/fleet/mdm/commands?order_key=team_id", nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "team_id") + + res = s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/mdm/commands?host_identifier=%s&order_key=team_id", h.UUID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "team_id") + + // order_key=hostname is supported on both paths. + s.DoRaw("GET", "/api/latest/fleet/mdm/commands?order_key=hostname", nil, http.StatusOK) + s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/mdm/commands?host_identifier=%s&order_key=hostname", h.UUID), nil, http.StatusOK) } func (s *integrationMDMTestSuite) TestMDMWindowsCommandResults() { diff --git a/server/service/mdm.go b/server/service/mdm.go index a6787abdaf..be1e01201d 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1047,6 +1047,18 @@ func (req listMDMCommandsRequest) DecodeBody(ctx context.Context, r io.Reader, u } } + if req.ListOptions.PerPage > fleet.MaxMDMCommandsPerPage { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Request could not be processed. Please set a per_page limit of %d or less.", fleet.MaxMDMCommandsPerPage), + } + } + + if req.ListOptions.Page > fleet.MaxMDMCommandsPage { + return &fleet.BadRequestError{ + Message: fmt.Sprintf("Request could not be processed. Please set page to %d or less, or use cursor pagination via the after parameter for deep traversal.", fleet.MaxMDMCommandsPage), + } + } + return nil } @@ -1061,6 +1073,9 @@ func listMDMCommandsEndpoint(ctx context.Context, request interface{}, svc fleet } } + if req.ListOptions.PerPage == 0 { + req.ListOptions.PerPage = fleet.DefaultMDMCommandsPerPage + } req.ListOptions.IncludeMetadata = true results, total, meta, err := svc.ListMDMCommands(ctx, &fleet.MDMCommandListOptions{ diff --git a/server/service/orbit.go b/server/service/orbit.go index ee99b25bf8..c7f3549ea5 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -687,6 +687,8 @@ func (svc *Service) processReleaseDeviceForOldFleetd(ctx context.Context, host * User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, } acctCmds, _, _, err := svc.ds.ListMDMCommands(ctx, adminTeamFilter, &fleet.MDMCommandListOptions{ + // PerPage 1: only acctCmds[0] is read below. + ListOptions: fleet.ListOptions{PerPage: 1}, Filters: fleet.MDMCommandFilters{ HostIdentifier: host.UUID, RequestType: "AccountConfiguration",