feat: deletion for query results (#14302)

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
This commit is contained in:
Jahziel Villasana-Espinoza 2023-10-09 17:43:17 -04:00 committed by GitHub
parent ccd6746633
commit 5c868c9d3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 168 additions and 55 deletions

View file

@ -4,7 +4,6 @@ import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
@ -1204,9 +1203,9 @@ spec:
// Apply queries.
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return nil, sql.ErrNoRows
return nil, &notFoundError{}
}
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query) error {
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error {
appliedQueries = queries
return nil
}
@ -1305,9 +1304,9 @@ func TestApplyQueries(t *testing.T) {
var appliedQueries []*fleet.Query
ds.QueryByNameFunc = func(ctx context.Context, teamID *uint, name string, opts ...fleet.OptionalArg) (*fleet.Query, error) {
return nil, sql.ErrNoRows
return nil, &notFoundError{}
}
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query) error {
ds.ApplyQueriesFunc = func(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error {
appliedQueries = queries
return nil
}

View file

@ -145,7 +145,7 @@ func setupPackSpecsTest(t *testing.T, ds fleet.Datastore) []*fleet.PackSpec {
{Name: "bar", Description: "do some bars", Query: "select baz from bar"},
}
// Zach creates some queries
err := ds.ApplyQueries(context.Background(), zwass.ID, queries)
err := ds.ApplyQueries(context.Background(), zwass.ID, queries, nil)
require.Nil(t, err)
labels := []*fleet.LabelSpec{

View file

@ -10,7 +10,7 @@ import (
"github.com/jmoiron/sqlx"
)
func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query) (err error) {
func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) (err error) {
tx, err := ds.writer(ctx).BeginTxx(ctx, nil)
if err != nil {
return ctxerr.Wrap(ctx, err, "begin ApplyQueries transaction")
@ -29,7 +29,7 @@ func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []
}
}()
sql := `
insertSql := `
INSERT INTO queries (
name,
description,
@ -60,12 +60,23 @@ func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []
automations_enabled = VALUES(automations_enabled),
logging_type = VALUES(logging_type)
`
stmt, err := tx.PrepareContext(ctx, sql)
stmt, err := tx.PrepareContext(ctx, insertSql)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare ApplyQueries insert")
}
defer stmt.Close()
var resultsStmt *sql.Stmt
if len(queriesToDiscardResults) > 0 {
resultsSql := `DELETE FROM query_results WHERE query_id = ?`
resultsStmt, err = tx.PrepareContext(ctx, resultsSql)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare ApplyQueries delete query results")
}
defer resultsStmt.Close()
}
for _, q := range queries {
if err := q.Verify(); err != nil {
return ctxerr.Wrap(ctx, err)
@ -90,6 +101,16 @@ func (ds *Datastore) ApplyQueries(ctx context.Context, authorID uint, queries []
}
}
for id := range queriesToDiscardResults {
_, err := resultsStmt.ExecContext(
ctx,
id,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "exec ApplyQueries delete query results")
}
}
err = tx.Commit()
return ctxerr.Wrap(ctx, err, "commit ApplyQueries transaction")
}
@ -164,8 +185,9 @@ func (ds *Datastore) NewQuery(
min_osquery_version,
schedule_interval,
automations_enabled,
logging_type
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
logging_type,
discard_data
) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
`
result, err := ds.writer(ctx).ExecContext(
ctx,
@ -183,6 +205,7 @@ func (ds *Datastore) NewQuery(
query.Interval,
query.AutomationsEnabled,
query.Logging,
query.DiscardData,
)
if err != nil && isDuplicate(err) {
@ -198,8 +221,26 @@ func (ds *Datastore) NewQuery(
}
// SaveQuery saves changes to a Query.
func (ds *Datastore) SaveQuery(ctx context.Context, q *fleet.Query) error {
sql := `
func (ds *Datastore) SaveQuery(ctx context.Context, q *fleet.Query, shouldDiscardResults bool) (err error) {
tx, err := ds.writer(ctx).BeginTxx(ctx, nil)
if err != nil {
return ctxerr.Wrap(ctx, err, "begin SaveQuery transaction")
}
defer func() {
if err != nil {
rbErr := tx.Rollback()
// It seems possible that there might be a case in
// which the error we are dealing with here was thrown
// by the call to tx.Commit(), and the docs suggest
// this call would then result in sql.ErrTxDone.
if rbErr != nil && rbErr != sql.ErrTxDone {
panic(fmt.Sprintf("got err '%s' rolling back after err '%s'", rbErr, err))
}
}
}()
updateSql := `
UPDATE queries
SET name = ?,
description = ?,
@ -213,12 +254,30 @@ func (ds *Datastore) SaveQuery(ctx context.Context, q *fleet.Query) error {
min_osquery_version = ?,
schedule_interval = ?,
automations_enabled = ?,
logging_type = ?
logging_type = ?,
discard_data = ?
WHERE id = ?
`
result, err := ds.writer(ctx).ExecContext(
stmt, err := tx.PrepareContext(ctx, updateSql)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare SaveQuery update")
}
defer stmt.Close()
var resultsStmt *sql.Stmt
if shouldDiscardResults {
resultsSql := `DELETE FROM query_results WHERE query_id = ?`
resultsStmt, err = tx.PrepareContext(ctx, resultsSql)
if err != nil {
return ctxerr.Wrap(ctx, err, "prepare SaveQuery delete query results")
}
defer resultsStmt.Close()
}
_, err = stmt.ExecContext(
ctx,
sql,
q.Name,
q.Description,
q.Query,
@ -232,19 +291,25 @@ func (ds *Datastore) SaveQuery(ctx context.Context, q *fleet.Query) error {
q.Interval,
q.AutomationsEnabled,
q.Logging,
q.ID)
q.DiscardData,
q.ID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating query")
}
rows, err := result.RowsAffected()
if err != nil {
return ctxerr.Wrap(ctx, err, "rows affected updating query")
}
if rows == 0 {
return ctxerr.Wrap(ctx, notFound("Query").WithID(q.ID))
return ctxerr.Wrap(ctx, err, "exec SaveQuery update")
}
return nil
if resultsStmt != nil {
_, err := resultsStmt.ExecContext(
ctx,
q.ID,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "exec SaveQuery delete query results")
}
}
err = tx.Commit()
return ctxerr.Wrap(ctx, err, "commit SaveQuery transaction")
}
func (ds *Datastore) DeleteQuery(
@ -301,6 +366,7 @@ func (ds *Datastore) Query(ctx context.Context, id uint) (*fleet.Query, error) {
q.logging_type,
q.created_at,
q.updated_at,
q.discard_data,
COALESCE(NULLIF(u.name, ''), u.email, '') AS author_name,
COALESCE(u.email, '') AS author_email,
JSON_EXTRACT(json_value, '$.user_time_p50') as user_time_p50,
@ -350,6 +416,7 @@ func (ds *Datastore) ListQueries(ctx context.Context, opt fleet.ListQueryOptions
q.logging_type,
q.created_at,
q.updated_at,
q.discard_data,
COALESCE(u.name, '<deleted>') AS author_name,
COALESCE(u.email, '') AS author_email,
JSON_EXTRACT(json_value, '$.user_time_p50') as user_time_p50,

View file

@ -59,16 +59,18 @@ func testQueriesApply(t *testing.T, ds *Datastore) {
MinOsqueryVersion: "5.2.1",
AutomationsEnabled: true,
Logging: "differential",
DiscardData: true,
},
{
Name: "bar",
Description: "do some bars",
Query: "select baz from bar",
DiscardData: true,
},
}
// Zach creates some queries
err := ds.ApplyQueries(context.Background(), zwass.ID, expectedQueries)
err := ds.ApplyQueries(context.Background(), zwass.ID, expectedQueries, nil)
require.NoError(t, err)
queries, err := ds.ListQueries(context.Background(), fleet.ListQueryOptions{})
@ -88,7 +90,7 @@ func testQueriesApply(t *testing.T, ds *Datastore) {
// Victor modifies a query (but also pushes the same version of the
// first query)
expectedQueries[1].Query = "not really a valid query ;)"
err = ds.ApplyQueries(context.Background(), groob.ID, expectedQueries)
err = ds.ApplyQueries(context.Background(), groob.ID, expectedQueries, nil)
require.NoError(t, err)
queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{})
@ -111,9 +113,10 @@ func testQueriesApply(t *testing.T, ds *Datastore) {
Name: "trouble",
Description: "Look out!",
Query: "select * from time",
DiscardData: true,
},
)
err = ds.ApplyQueries(context.Background(), zwass.ID, []*fleet.Query{expectedQueries[2]})
err = ds.ApplyQueries(context.Background(), zwass.ID, []*fleet.Query{expectedQueries[2]}, nil)
require.NoError(t, err)
queries, err = ds.ListQueries(context.Background(), fleet.ListQueryOptions{})
@ -150,7 +153,7 @@ func testQueriesApply(t *testing.T, ds *Datastore) {
Logging: "differential",
},
}
err = ds.ApplyQueries(context.Background(), zwass.ID, invalidQueries)
err = ds.ApplyQueries(context.Background(), zwass.ID, invalidQueries, nil)
require.ErrorIs(t, err, fleet.ErrQueryInvalidPlatform)
}
@ -276,8 +279,9 @@ func testQueriesSave(t *testing.T, ds *Datastore) {
query.MinOsqueryVersion = "5.2.1"
query.AutomationsEnabled = true
query.Logging = "differential"
query.DiscardData = true
err = ds.SaveQuery(context.Background(), query)
err = ds.SaveQuery(context.Background(), query, true)
require.NoError(t, err)
actual, err := ds.Query(context.Background(), query.ID)
@ -296,10 +300,11 @@ func testQueriesList(t *testing.T, ds *Datastore) {
for i := 0; i < 10; i++ {
_, err := ds.NewQuery(context.Background(), &fleet.Query{
Name: fmt.Sprintf("name%02d", i),
Query: fmt.Sprintf("query%02d", i),
Saved: true,
AuthorID: &user.ID,
Name: fmt.Sprintf("name%02d", i),
Query: fmt.Sprintf("query%02d", i),
Saved: true,
AuthorID: &user.ID,
DiscardData: true,
})
require.Nil(t, err)
}
@ -319,6 +324,7 @@ func testQueriesList(t *testing.T, ds *Datastore) {
require.Equal(t, 10, len(results))
require.Equal(t, "Zach", results[0].AuthorName)
require.Equal(t, "zwass@fleet.co", results[0].AuthorEmail)
require.True(t, results[0].DiscardData)
idWithAgg := results[0].ID
@ -351,7 +357,7 @@ func testQueriesLoadPacksForQueries(t *testing.T, ds *Datastore) {
{Name: "q1", Query: "select * from time"},
{Name: "q2", Query: "select * from osquery_info"},
}
err := ds.ApplyQueries(context.Background(), zwass.ID, queries)
err := ds.ApplyQueries(context.Background(), zwass.ID, queries, nil)
require.NoError(t, err)
specs := []*fleet.PackSpec{

View file

@ -44,7 +44,7 @@ func testScheduledQueriesListInPackWithStats(t *testing.T, ds *Datastore) {
{Name: "foo", Description: "get the foos", Query: "select * from foo"},
{Name: "bar", Description: "do some bars", Query: "select baz from bar"},
}
err := ds.ApplyQueries(context.Background(), zwass.ID, queries)
err := ds.ApplyQueries(context.Background(), zwass.ID, queries, nil)
require.NoError(t, err)
specs := []*fleet.PackSpec{
@ -134,7 +134,7 @@ func testScheduledQueriesListInPack(t *testing.T, ds *Datastore) {
{Name: "foo", Description: "get the foos", Query: "select * from foo"},
{Name: "bar", Description: "do some bars", Query: "select baz from bar"},
}
err := ds.ApplyQueries(context.Background(), zwass.ID, queries)
err := ds.ApplyQueries(context.Background(), zwass.ID, queries, nil)
require.NoError(t, err)
specs := []*fleet.PackSpec{
@ -327,7 +327,7 @@ func testScheduledQueriesCascadingDelete(t *testing.T, ds *Datastore) {
{Name: "foo", Description: "get the foos", Query: "select * from foo"},
{Name: "bar", Description: "do some bars", Query: "select baz from bar"},
}
err := ds.ApplyQueries(context.Background(), zwass.ID, queries)
err := ds.ApplyQueries(context.Background(), zwass.ID, queries, nil)
require.Nil(t, err)
specs := []*fleet.PackSpec{
@ -379,7 +379,7 @@ func testScheduledQueriesIDsByName(t *testing.T, ds *Datastore) {
{Name: "foo2", Description: "get the foos", Query: "select * from foo2"},
{Name: "bar2", Description: "do some bars", Query: "select * from bar2"},
}
err := ds.ApplyQueries(ctx, user.ID, queries)
err := ds.ApplyQueries(ctx, user.ID, queries, nil)
require.NoError(t, err)
specs := []*fleet.PackSpec{

View file

@ -66,11 +66,11 @@ type Datastore interface {
// ApplyQueries applies a list of queries (likely from a yaml file) to the datastore. Existing queries are updated,
// and new queries are created.
ApplyQueries(ctx context.Context, authorID uint, queries []*Query) error
ApplyQueries(ctx context.Context, authorID uint, queries []*Query, queriesToDiscardResults map[uint]bool) error
// NewQuery creates a new query object in thie datastore. The returned query should have the ID updated.
NewQuery(ctx context.Context, query *Query, opts ...OptionalArg) (*Query, error)
// SaveQuery saves changes to an existing query object.
SaveQuery(ctx context.Context, query *Query) error
SaveQuery(ctx context.Context, query *Query, shouldDiscardResults bool) error
// DeleteQuery deletes an existing query object on a team. If teamID is nil, then the query is
// looked up in the 'global' team.
DeleteQuery(ctx context.Context, teamID *uint, name string) error

View file

@ -36,6 +36,8 @@ type QueryPayload struct {
AutomationsEnabled *bool `json:"automations_enabled"`
// Logging is set to "snapshot" if not set when creating a query.
Logging *string `json:"logging"`
// DiscardData is set to false if not set when creating a query.
DiscardData *bool `json:"discard_data"`
}
// Query represents a osquery query to run on devices.

View file

@ -56,11 +56,11 @@ type PendingEmailChangeFunc func(ctx context.Context, userID uint, newEmail stri
type ConfirmPendingEmailChangeFunc func(ctx context.Context, userID uint, token string) (string, error)
type ApplyQueriesFunc func(ctx context.Context, authorID uint, queries []*fleet.Query) error
type ApplyQueriesFunc func(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error
type NewQueryFunc func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error)
type SaveQueryFunc func(ctx context.Context, query *fleet.Query) error
type SaveQueryFunc func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error
type DeleteQueryFunc func(ctx context.Context, teamID *uint, name string) error
@ -1815,11 +1815,11 @@ func (s *DataStore) ConfirmPendingEmailChange(ctx context.Context, userID uint,
return s.ConfirmPendingEmailChangeFunc(ctx, userID, token)
}
func (s *DataStore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query) error {
func (s *DataStore) ApplyQueries(ctx context.Context, authorID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error {
s.mu.Lock()
s.ApplyQueriesFuncInvoked = true
s.mu.Unlock()
return s.ApplyQueriesFunc(ctx, authorID, queries)
return s.ApplyQueriesFunc(ctx, authorID, queries, queriesToDiscardResults)
}
func (s *DataStore) NewQuery(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error) {
@ -1829,11 +1829,11 @@ func (s *DataStore) NewQuery(ctx context.Context, query *fleet.Query, opts ...fl
return s.NewQueryFunc(ctx, query, opts...)
}
func (s *DataStore) SaveQuery(ctx context.Context, query *fleet.Query) error {
func (s *DataStore) SaveQuery(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error {
s.mu.Lock()
s.SaveQueryFuncInvoked = true
s.mu.Unlock()
return s.SaveQueryFunc(ctx, query)
return s.SaveQueryFunc(ctx, query, shouldDiscardResults)
}
func (s *DataStore) DeleteQuery(ctx context.Context, teamID *uint, name string) error {

View file

@ -24,7 +24,7 @@ func TestGlobalScheduleAuth(t *testing.T) {
Query: "SELECT 1;",
}, nil
}
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query) error {
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {

View file

@ -2548,6 +2548,10 @@ func (s *integrationTestSuite) TestScheduledQueries() {
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", query.ID), fleet.QueryPayload{Description: ptr.String("updated")}, http.StatusOK, &modQryResp)
assert.Equal(t, "updated", modQryResp.Query.Description)
// TODO(jahziel): check that the query results were deleted
// TODO(jahziel): check that the query results were deleted after setting `discard_data`
// modify a non-existing query
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/queries/%d", query.ID+1), fleet.QueryPayload{Description: ptr.String("updated")}, http.StatusNotFound, &modQryResp)

View file

@ -250,6 +250,7 @@ func modifyQueryEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
func (svc *Service) ModifyQuery(ctx context.Context, id uint, p fleet.QueryPayload) (*fleet.Query, error) {
// Load query first to determine if the user can modify it.
query, err := svc.ds.Query(ctx, id)
shouldDiscardQueryResults := false
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, err
@ -271,6 +272,9 @@ func (svc *Service) ModifyQuery(ctx context.Context, id uint, p fleet.QueryPaylo
query.Description = *p.Description
}
if p.Query != nil {
if query.Query != *p.Query {
shouldDiscardQueryResults = true
}
query.Query = *p.Query
}
if p.Interval != nil {
@ -286,15 +290,24 @@ func (svc *Service) ModifyQuery(ctx context.Context, id uint, p fleet.QueryPaylo
query.AutomationsEnabled = *p.AutomationsEnabled
}
if p.Logging != nil {
if query.Logging != *p.Logging && *p.Logging != fleet.LoggingSnapshot {
shouldDiscardQueryResults = true
}
query.Logging = *p.Logging
}
if p.ObserverCanRun != nil {
query.ObserverCanRun = *p.ObserverCanRun
}
if p.DiscardData != nil {
if *p.DiscardData && *p.DiscardData != query.DiscardData {
shouldDiscardQueryResults = true
}
query.DiscardData = *p.DiscardData
}
logging.WithExtras(ctx, "name", query.Name, "sql", query.Query)
if err := svc.ds.SaveQuery(ctx, query); err != nil {
if err := svc.ds.SaveQuery(ctx, query, shouldDiscardQueryResults); err != nil {
return nil, err
}
@ -518,11 +531,32 @@ func (svc *Service) ApplyQuerySpecs(ctx context.Context, specs []*fleet.QuerySpe
}
}
// 3. Apply the queries.
// first, find out if we should delete query results
queriesToDiscardResults := make(map[uint]bool)
for _, query := range queries {
dbQuery, err := svc.ds.QueryByName(ctx, query.TeamID, query.Name)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "fetching saved query")
}
if dbQuery == nil {
// then we're creating a new query, so move on.
continue
}
if (query.DiscardData && query.DiscardData != dbQuery.DiscardData) ||
(query.Logging != dbQuery.Logging && query.Logging != fleet.LoggingSnapshot) ||
query.Query != dbQuery.Query {
queriesToDiscardResults[dbQuery.ID] = true
}
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return ctxerr.New(ctx, "user must be authenticated to apply queries")
}
err := svc.ds.ApplyQueries(ctx, vc.UserID(), queries)
err := svc.ds.ApplyQueries(ctx, vc.UserID(), queries, queriesToDiscardResults)
if err != nil {
return ctxerr.Wrap(ctx, err, "applying queries")
}

View file

@ -239,7 +239,7 @@ func TestQueryPayloadValidationModify(t *testing.T) {
ObserverCanRun: false,
}, nil
}
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query) error {
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error {
assert.NotEmpty(t, query)
return nil
}
@ -250,6 +250,7 @@ func TestQueryPayloadValidationModify(t *testing.T) {
assert.NotEmpty(t, act.Name)
return nil
}
svc, ctx := newTestService(t, ds, nil, nil)
testCases := []struct {
@ -446,7 +447,7 @@ func TestQueryAuth(t *testing.T) {
}
return nil, newNotFoundError()
}
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query) error {
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error {
return nil
}
ds.DeleteQueryFunc = func(ctx context.Context, teamID *uint, name string) error {
@ -458,7 +459,7 @@ func TestQueryAuth(t *testing.T) {
ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) {
return nil, nil
}
ds.ApplyQueriesFunc = func(ctx context.Context, authID uint, queries []*fleet.Query) error {
ds.ApplyQueriesFunc = func(ctx context.Context, authID uint, queries []*fleet.Query, queriesToDiscardResults map[uint]bool) error {
return nil
}

View file

@ -33,7 +33,7 @@ func TestTeamScheduleAuth(t *testing.T) {
TeamID: nil,
}, nil
}
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query) error {
ds.SaveQueryFunc = func(ctx context.Context, query *fleet.Query, shouldDiscardResults bool) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {