From ca435eb2444d0cb95d7fec6fb94cea46af73ec41 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 29 Jan 2024 09:37:54 -0500 Subject: [PATCH] Queued scripts feature (#16300) This is the feature branch for the [queued scripts](https://github.com/fleetdm/fleet/issues/15529) story. --------- Co-authored-by: Jahziel Villasana-Espinoza Co-authored-by: Gabriel Hernandez Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Roberto Dip --- changes/15957-queued-scripts-db-changes | 1 + changes/15959-run-script-modal | 2 + .../15960-queued-scripts-create-host-activity | 1 + changes/issue-15959-add-host-details-activity | 1 + changes/issue-15959-make-scripts-free | 1 + changes/mna-15958-queued-scripts-api | 2 + cmd/fleetctl/scripts_test.go | 6 +- ee/server/service/orbit.go | 44 -- ee/server/service/scripts.go | 462 ------------------ frontend/__mocks__/scriptMock.ts | 8 +- frontend/components/Modal/Modal.tsx | 18 +- frontend/components/Modal/_styles.scss | 4 + .../components/TabsWrapper/TabsWrapper.tsx | 11 +- frontend/context/notification.tsx | 32 +- frontend/interfaces/activity.ts | 1 + frontend/interfaces/config.ts | 18 +- frontend/interfaces/script.ts | 23 + .../ActivityItem/ActivityItem.tsx | 11 +- .../ManageControlsPage/Scripts/Scripts.tsx | 13 +- .../ScriptListItem/ScriptListItem.tests.tsx | 2 +- .../ScriptListItem/ScriptListItem.tsx | 4 +- .../ScriptUploader/ScriptUploader.tsx | 2 +- .../details/DeviceUserPage/DeviceUserPage.tsx | 2 +- .../HostActionsDropdown.tsx | 4 + .../HostActionsDropdown/helpers.tsx | 39 ++ .../HostDetailsPage/HostDetailsPage.tsx | 234 ++++++--- .../details/HostDetailsPage/_styles.scss | 44 +- .../modals/RunScriptModal/RunScriptModal.tsx | 176 +++++++ .../RunScriptModal}/ScriptsTableConfig.tsx | 153 +++--- .../modals/RunScriptModal/_styles.scss | 30 ++ .../ScriptStatusCell/ScriptStatusCell.tsx | 8 +- .../components/ScriptStatusCell/index.ts | 0 .../modals/RunScriptModal/index.ts | 1 + frontend/pages/hosts/details/_styles.scss | 25 +- .../hosts/details/cards/Activity/Activity.tsx | 111 +++++ .../cards/Activity/EmptyFeed/EmptyFeed.tsx | 23 + .../cards/Activity/EmptyFeed/_styles.scss | 13 + .../details/cards/Activity/EmptyFeed/index.ts | 0 .../Activity/PastActivity/PastActivity.tsx | 90 ++++ .../cards/Activity/PastActivity/_styles.scss | 65 +++ .../cards/Activity/PastActivity/index.ts | 1 + .../PastActivityFeed/PastActivityFeed.tsx | 85 ++++ .../Activity/PastActivityFeed/_styles.scss | 58 +++ .../cards/Activity/PastActivityFeed/index.ts | 1 + .../UpcomingActivity/UpcomingActivity.tsx | 94 ++++ .../Activity/UpcomingActivity/_styles.scss | 65 +++ .../cards/Activity/UpcomingActivity/index.ts | 1 + .../UpcomingActivityFeed.tsx | 88 ++++ .../UpcomingActivityFeed/_styles.scss | 58 +++ .../Activity/UpcomingActivityFeed/index.ts | 1 + .../hosts/details/cards/Activity/_styles.scss | 37 ++ .../hosts/details/cards/Activity/index.ts | 1 + .../cards/AgentOptions/AgentOptions.tsx | 5 +- .../hosts/details/cards/Labels/Labels.tsx | 7 +- .../hosts/details/cards/Scripts/Scripts.tsx | 145 ------ .../hosts/details/cards/Scripts/_styles.scss | 44 -- .../components/ScriptStatusCell/_styles.scss | 3 - .../hosts/details/cards/Scripts/index.ts | 1 - frontend/services/entities/activities.ts | 40 +- frontend/services/entities/scripts.ts | 39 +- frontend/utilities/endpoints.ts | 8 + frontend/utilities/helpers.tsx | 11 + orbit/changes/15963-fleetd-agent | 2 + orbit/pkg/scripts/scripts.go | 33 +- orbit/pkg/scripts/scripts_test.go | 37 +- server/datastore/mysql/activities.go | 130 ++++- server/datastore/mysql/activities_test.go | 264 ++++++++++ server/datastore/mysql/hosts.go | 1 + server/datastore/mysql/hosts_test.go | 10 + .../20240126020643_AddHostActivities.go | 57 +++ .../20240126020643_AddHostActivities_test.go | 76 +++ server/datastore/mysql/schema.sql | 20 +- server/datastore/mysql/scripts.go | 69 ++- server/datastore/mysql/scripts_test.go | 42 +- server/fleet/activities.go | 26 +- server/fleet/datastore.go | 16 +- server/fleet/scripts.go | 15 + server/fleet/service.go | 9 + server/mock/datastore_mock.go | 46 +- server/service/activities.go | 97 ++++ server/service/handler.go | 2 + server/service/hosts_test.go | 9 + server/service/integration_core_test.go | 280 +++++++++-- server/service/integration_enterprise_test.go | 110 +++-- server/service/orbit.go | 72 ++- server/service/orbit_test.go | 7 +- server/service/scripts.go | 454 +++++++++++++++-- server/service/scripts_test.go | 7 +- 88 files changed, 3218 insertions(+), 1151 deletions(-) create mode 100644 changes/15957-queued-scripts-db-changes create mode 100644 changes/15959-run-script-modal create mode 100644 changes/15960-queued-scripts-create-host-activity create mode 100644 changes/issue-15959-add-host-details-activity create mode 100644 changes/issue-15959-make-scripts-free create mode 100644 changes/mna-15958-queued-scripts-api delete mode 100644 ee/server/service/orbit.go delete mode 100644 ee/server/service/scripts.go create mode 100644 frontend/interfaces/script.ts create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/RunScriptModal.tsx rename frontend/pages/hosts/details/{cards/Scripts => HostDetailsPage/modals/RunScriptModal}/ScriptsTableConfig.tsx (64%) create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss rename frontend/pages/hosts/details/{cards/Scripts => HostDetailsPage/modals/RunScriptModal}/components/ScriptStatusCell/ScriptStatusCell.tsx (89%) rename frontend/pages/hosts/details/{cards/Scripts => HostDetailsPage/modals/RunScriptModal}/components/ScriptStatusCell/index.ts (100%) create mode 100644 frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/Activity.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/EmptyFeed/EmptyFeed.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/EmptyFeed/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Activity/EmptyFeed/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/PastActivityFeed/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Activity/PastActivityFeed/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx create mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/index.ts create mode 100644 frontend/pages/hosts/details/cards/Activity/_styles.scss create mode 100644 frontend/pages/hosts/details/cards/Activity/index.ts delete mode 100644 frontend/pages/hosts/details/cards/Scripts/Scripts.tsx delete mode 100644 frontend/pages/hosts/details/cards/Scripts/_styles.scss delete mode 100644 frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss delete mode 100644 frontend/pages/hosts/details/cards/Scripts/index.ts create mode 100644 orbit/changes/15963-fleetd-agent create mode 100644 server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go create mode 100644 server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go diff --git a/changes/15957-queued-scripts-db-changes b/changes/15957-queued-scripts-db-changes new file mode 100644 index 0000000000..a1d6c10a07 --- /dev/null +++ b/changes/15957-queued-scripts-db-changes @@ -0,0 +1 @@ +* Added database migration to record the user that requests a script execution and to create the `host_activities` table to associate activities to specific hosts. diff --git a/changes/15959-run-script-modal b/changes/15959-run-script-modal new file mode 100644 index 0000000000..70395f385c --- /dev/null +++ b/changes/15959-run-script-modal @@ -0,0 +1,2 @@ +- Added "Run script" action to host details page, which relocates functionality from the "Scripts" + tab into a new modal UI. diff --git a/changes/15960-queued-scripts-create-host-activity b/changes/15960-queued-scripts-create-host-activity new file mode 100644 index 0000000000..754b8f0faf --- /dev/null +++ b/changes/15960-queued-scripts-create-host-activity @@ -0,0 +1 @@ +* Created the "script ran" activity linked to its host so the script executions can be listed per host. diff --git a/changes/issue-15959-add-host-details-activity b/changes/issue-15959-add-host-details-activity new file mode 100644 index 0000000000..5bc1a952d3 --- /dev/null +++ b/changes/issue-15959-add-host-details-activity @@ -0,0 +1 @@ +- add UI for host details activity card diff --git a/changes/issue-15959-make-scripts-free b/changes/issue-15959-make-scripts-free new file mode 100644 index 0000000000..08f7a48856 --- /dev/null +++ b/changes/issue-15959-make-scripts-free @@ -0,0 +1 @@ +- removes the premium tier check for scripts feature on the controls page. diff --git a/changes/mna-15958-queued-scripts-api b/changes/mna-15958-queued-scripts-api new file mode 100644 index 0000000000..611b0d6ff8 --- /dev/null +++ b/changes/mna-15958-queued-scripts-api @@ -0,0 +1,2 @@ +- Adds 2 new scripts related endpoints (`/hosts/:id/activity` and `/hosts/:id/activity/upcoming`) as + well as validation and functionality changes for enqueuing scripts. \ No newline at end of file diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index 9dae72be14..d137e62522 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -42,10 +42,6 @@ func TestRunScriptCommand(t *testing.T) { ds.ListHostBatteriesFunc = func(ctx context.Context, hid uint) ([]*fleet.HostBattery, error) { return nil, nil } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { - require.IsType(t, fleet.ActivityTypeRanScript{}, activity) - return nil - } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ScriptsDisabled: false}}, nil } @@ -231,7 +227,7 @@ Fleet records the last 10,000 characters to prevent downtime. } return &h, nil } - ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hid uint, maxAge time.Duration) ([]*fleet.HostScriptResult, error) { + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hid uint) ([]*fleet.HostScriptResult, error) { require.Equal(t, uint(42), hid) if c.expectPending { return []*fleet.HostScriptResult{{HostID: uint(42)}}, nil diff --git a/ee/server/service/orbit.go b/ee/server/service/orbit.go deleted file mode 100644 index 46f64db56a..0000000000 --- a/ee/server/service/orbit.go +++ /dev/null @@ -1,44 +0,0 @@ -package service - -import ( - "context" - - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" - "github.com/fleetdm/fleet/v4/server/fleet" -) - -func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { - // this is not a user-authenticated endpoint - svc.authz.SkipAuthorization(ctx) - - host, ok := hostctx.FromContext(ctx) - if !ok { - return nil, fleet.OrbitError{Message: "internal error: missing host from request context"} - } - - // get the script's details - script, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) - if err != nil { - return nil, err - } - // ensure it cannot get access to a different host's script - if script.HostID != host.ID { - return nil, ctxerr.Wrap(ctx, notFoundError{}, "no script found for this host") - } - return script, nil -} - -func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { - // this is not a user-authenticated endpoint - svc.authz.SkipAuthorization(ctx) - - host, ok := hostctx.FromContext(ctx) - if !ok { - return fleet.OrbitError{Message: "internal error: missing host from request context"} - } - - // always use the authenticated host's ID as host_id - result.HostID = host.ID - return svc.ds.SetHostScriptExecutionResult(ctx, result) -} diff --git a/ee/server/service/scripts.go b/ee/server/service/scripts.go deleted file mode 100644 index 72386aaebf..0000000000 --- a/ee/server/service/scripts.go +++ /dev/null @@ -1,462 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "time" - - "github.com/fleetdm/fleet/v4/pkg/scripts" - "github.com/fleetdm/fleet/v4/server/authz" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/go-kit/kit/log/level" -) - -func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) { - // First check if scripts are disabled globally. If so, no need for further processing. - cfg, err := svc.ds.AppConfig(ctx) - if err != nil { - svc.authz.SkipAuthorization(ctx) - return nil, err - } - - if cfg.ServerSettings.ScriptsDisabled { - svc.authz.SkipAuthorization(ctx) - return nil, fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden) - } - - // must load the host to get the team (cannot use lite, the last seen time is - // required to check if it is online) to authorize with the proper team id. - // We cannot first authorize if the user can list hosts, in case we - // eventually allow a write-only role (e.g. gitops). - host, err := svc.ds.Host(ctx, request.HostID) - if err != nil { - // if error is because the host does not exist, check first if the user - // had access to run a script (to prevent leaking valid host ids). - if fleet.IsNotFound(err) { - if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil { - return nil, err - } - } - svc.authz.SkipAuthorization(ctx) - return nil, ctxerr.Wrap(ctx, err, "get host lite") - } - - // must check that only one of script id or contents is provided before - // authorization, as the permissions are not the same if a script id is - // provided. There's no harm in returning the error if this validation fails, - // since both values are user-provided it doesn't leak any internal - // information. - if request.ScriptID != nil && request.ScriptContents != "" { - svc.authz.SkipAuthorization(ctx) - return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`) - } - - // authorize with the host's team and the script id provided, as both affect - // the permissions. - if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil { - return nil, err - } - - if request.ScriptID != nil { - script, err := svc.ds.Script(ctx, *request.ScriptID) - if err != nil { - if fleet.IsNotFound(err) { - return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`). - WithStatus(http.StatusNotFound) - } - return nil, err - } - var scriptTmID, hostTmID uint - if script.TeamID != nil { - scriptTmID = *script.TeamID - } - if host.TeamID != nil { - hostTmID = *host.TeamID - } - if scriptTmID != hostTmID { - return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`) - } - - contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID) - if err != nil { - if fleet.IsNotFound(err) { - return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`). - WithStatus(http.StatusNotFound) - } - return nil, err - } - request.ScriptContents = string(contents) - } - - if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil { - return nil, fleet.NewInvalidArgumentError("script_contents", err.Error()) - } - - // host must be online - if host.Status(time.Now()) != fleet.StatusOnline { - return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg) - } - - // it is important that the "ignoreOlder" parameter in this call is the same - // everywhere (which is here and in the "get orbit config" endpoint to send - // the notification of scripts pending execution to the host). - pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID, scripts.MaxServerWaitTime) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "list host pending script executions") - } - if len(pending) > 0 { - return nil, fleet.NewInvalidArgumentError( - "script_contents", fleet.RunScriptAlreadyRunningErrMsg, - ).WithStatus(http.StatusConflict) - } - - // create the script execution request, the host will be notified of the - // script execution request via the orbit config's Notifications mechanism. - script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "create script execution request") - } - script.Hostname = host.DisplayName() - - asyncExecution := waitForResult <= 0 - - err = svc.ds.NewActivity( - ctx, - authz.UserFromContext(ctx), - fleet.ActivityTypeRanScript{ - HostID: host.ID, - HostDisplayName: host.DisplayName(), - ScriptExecutionID: script.ExecutionID, - Async: asyncExecution, - }, - ) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for script execution request") - } - - if asyncExecution { - // async execution, return - return script, nil - } - - ctx, cancel := context.WithTimeout(ctx, waitForResult) - defer cancel() - - // if waiting for a result times out, we still want to return the script's - // execution request information along with the error, so that the caller can - // use the execution id for later checks. - timeoutResult := script - checkInterval := time.Second - after := time.NewTimer(checkInterval) - for { - select { - case <-ctx.Done(): - return timeoutResult, ctx.Err() - case <-after.C: - result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID) - if err != nil { - // is that due to the context being canceled during the DB access? - if ctxErr := ctx.Err(); ctxErr != nil { - return timeoutResult, ctxErr - } - return nil, ctxerr.Wrap(ctx, err, "get script execution result") - } - if result.ExitCode != nil { - // a result was received from the host, return - result.Hostname = host.DisplayName() - return result, nil - } - - // at a second to every attempt, until it reaches 5s (then check every 5s) - if checkInterval < 5*time.Second { - checkInterval += time.Second - } - after.Reset(checkInterval) - } - } -} - -func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { - scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) - if err != nil { - if fleet.IsNotFound(err) { - if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil { - return nil, err - } - } - svc.authz.SkipAuthorization(ctx) - return nil, ctxerr.Wrap(ctx, err, "get script result") - } - - host, err := svc.ds.HostLite(ctx, scriptResult.HostID) - if err != nil { - // if error is because the host does not exist, check first if the user - // had access to run a script (to prevent leaking valid host ids). - if fleet.IsNotFound(err) { - if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil { - return nil, err - } - } - svc.authz.SkipAuthorization(ctx) - return nil, ctxerr.Wrap(ctx, err, "get host lite") - } - if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionRead); err != nil { - return nil, err - } - - scriptResult.Hostname = host.DisplayName() - - return scriptResult, nil -} - -func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) { - if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { - return nil, err - } - - b, err := io.ReadAll(r) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "read script contents") - } - - script := &fleet.Script{ - TeamID: teamID, - Name: name, - ScriptContents: string(b), - } - if err := script.Validate(); err != nil { - return nil, fleet.NewInvalidArgumentError("script", err.Error()) - } - - savedScript, err := svc.ds.NewScript(ctx, script) - if err != nil { - var ( - existsErr fleet.AlreadyExistsError - fkErr fleet.ForeignKeyError - ) - if errors.As(err, &existsErr) { - err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict) - } else if errors.As(err, &fkErr) { - err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound) - } - return nil, ctxerr.Wrap(ctx, err, "create script") - } - - var teamName *string - if teamID != nil && *teamID != 0 { - tm, err := svc.teamByIDOrName(ctx, teamID, nil) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity") - } - teamName = &tm.Name - } - - if err := svc.ds.NewActivity( - ctx, - authz.UserFromContext(ctx), - fleet.ActivityTypeAddedScript{ - TeamID: teamID, - TeamName: teamName, - ScriptName: script.Name, - }, - ); err != nil { - return nil, ctxerr.Wrap(ctx, err, "new activity for create script") - } - - return savedScript, nil -} - -func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error { - script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite) - if err != nil { - return err - } - - if err := svc.ds.DeleteScript(ctx, script.ID); err != nil { - return ctxerr.Wrap(ctx, err, "delete script") - } - - var teamName *string - if script.TeamID != nil && *script.TeamID != 0 { - tm, err := svc.teamByIDOrName(ctx, script.TeamID, nil) - if err != nil { - return ctxerr.Wrap(ctx, err, "get team name for delete script activity") - } - teamName = &tm.Name - } - - if err := svc.ds.NewActivity( - ctx, - authz.UserFromContext(ctx), - fleet.ActivityTypeDeletedScript{ - TeamID: script.TeamID, - TeamName: teamName, - ScriptName: script.Name, - }, - ); err != nil { - return ctxerr.Wrap(ctx, err, "new activity for delete script") - } - - return nil -} - -func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { - if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil { - return nil, nil, err - } - - // cursor-based pagination is not supported for scripts - opt.After = "" - // custom ordering is not supported, always by name - opt.OrderKey = "name" - opt.OrderDirection = fleet.OrderAscending - // no matching query support - opt.MatchQuery = "" - // always include metadata for scripts - opt.IncludeMetadata = true - - return svc.ds.ListScripts(ctx, teamID, opt) -} - -func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) { - script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead) - if err != nil { - return nil, nil, err - } - - var content []byte - if withContent { - content, err = svc.ds.GetScriptContents(ctx, scriptID) - if err != nil { - return nil, nil, err - } - } - return script, content, nil -} - -func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) { - // first, get the script because we don't know which team id it is for. - script, err := svc.ds.Script(ctx, scriptID) - if err != nil { - if fleet.IsNotFound(err) { - // couldn't get the script to have its team, authorize with a no-team - // script as a fallback - the requested script does not exist so there's - // no way to know what team it would be for, and returning a 404 without - // authorization would leak the existing/non existing ids. - if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil { - return nil, err - } - } - svc.authz.SkipAuthorization(ctx) - return nil, ctxerr.Wrap(ctx, err, "get script") - } - - // do the actual authorization with the script's team id - if err := svc.authz.Authorize(ctx, script, authzAction); err != nil { - return nil, err - } - return script, nil -} - -func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { - h, err := svc.ds.HostLite(ctx, hostID) - if err != nil { - if fleet.IsNotFound(err) { - // if error is because the host does not exist, check first if the user - // had global access (to prevent leaking valid host ids). - if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil { - return nil, nil, err - } - } - return nil, nil, err - } - - if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil { - return nil, nil, err - } - - if h.Platform != "darwin" && h.Platform != "windows" { - // darwin and windows are supported for now, all other platforms return empty results - level.Debug(svc.logger).Log("msg", "unsupported platform for host script details", "platform", h.Platform, "host_id", h.ID) - return []*fleet.HostScriptDetail{}, &fleet.PaginationMetadata{}, nil - } - - // cursor-based pagination is not supported for scripts - opt.After = "" - // custom ordering is not supported, always by name - opt.OrderKey = "name" - opt.OrderDirection = fleet.OrderAscending - // no matching query support - opt.MatchQuery = "" - // always include metadata for scripts - opt.IncludeMetadata = true - - return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt, h.Platform) -} - -func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error { - if maybeTmID != nil && maybeTmName != nil { - svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name")) - } - - var teamID *uint - var teamName *string - - if maybeTmID != nil || maybeTmName != nil { - team, err := svc.teamByIDOrName(ctx, maybeTmID, maybeTmName) - if err != nil { - return err - } - teamID = &team.ID - teamName = &team.Name - } - - if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { - return ctxerr.Wrap(ctx, err) - } - - // any duplicate name in the provided set results in an error - scripts := make([]*fleet.Script, 0, len(payloads)) - byName := make(map[string]bool, len(payloads)) - for i, p := range payloads { - script := &fleet.Script{ - ScriptContents: string(p.ScriptContents), - Name: p.Name, - TeamID: teamID, - } - - if err := script.Validate(); err != nil { - return ctxerr.Wrap(ctx, - fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error())) - } - - if byName[script.Name] { - return ctxerr.Wrap(ctx, - fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldn’t edit scripts. More than one script has the same file name: %q", script.Name)), - "duplicate script by name") - } - byName[script.Name] = true - scripts = append(scripts, script) - } - - if dryRun { - return nil - } - - if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil { - return ctxerr.Wrap(ctx, err, "batch saving scripts") - } - - if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{ - TeamID: teamID, - TeamName: teamName, - }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited scripts") - } - return nil -} diff --git a/frontend/__mocks__/scriptMock.ts b/frontend/__mocks__/scriptMock.ts index f193b3d43e..bb57b11857 100644 --- a/frontend/__mocks__/scriptMock.ts +++ b/frontend/__mocks__/scriptMock.ts @@ -1,8 +1,5 @@ -import { - IHostScript, - IScript, - IScriptResultResponse, -} from "services/entities/scripts"; +import { IScriptResultResponse } from "services/entities/scripts"; +import { IScript, IHostScript } from "interfaces/script"; const DEFAULT_SCRIPT_MOCK: IScript = { id: 1, @@ -26,6 +23,7 @@ const DEFAULT_SCRIPT_RESULT_MOCK: IScriptResultResponse = { message: "", runtime: 0, host_timeout: false, + script_id: 1, }; export const createMockScriptResult = ( diff --git a/frontend/components/Modal/Modal.tsx b/frontend/components/Modal/Modal.tsx index 99913e353f..280f66e491 100644 --- a/frontend/components/Modal/Modal.tsx +++ b/frontend/components/Modal/Modal.tsx @@ -15,6 +15,10 @@ export interface IModalProps { onEnter?: () => void; /** default 650px, large 800px, xlarge 850px, auto auto-width */ width?: ModalWidth; + /** isHidden can be set true to hide the modal when opening another modal */ + isHidden?: boolean; + /** isLoading can be set true to enable targeting elements by loading state */ + isLoading?: boolean; className?: string; } @@ -24,6 +28,8 @@ const Modal = ({ onExit, onEnter, width = "medium", + isHidden = false, + isLoading = false, className, }: IModalProps): JSX.Element => { const { hideFlash } = useContext(NotificationContext); @@ -69,8 +75,16 @@ const Modal = ({ ); return ( -
-
+
+
{title}
diff --git a/frontend/components/Modal/_styles.scss b/frontend/components/Modal/_styles.scss index bb15b6b3b3..7169f0ff4c 100644 --- a/frontend/components/Modal/_styles.scss +++ b/frontend/components/Modal/_styles.scss @@ -9,6 +9,10 @@ animation: fade-in 150ms ease-out; } + &__hidden { + visibility: hidden; + } + &__content { margin-top: $pad-large; font-size: $x-small; diff --git a/frontend/components/TabsWrapper/TabsWrapper.tsx b/frontend/components/TabsWrapper/TabsWrapper.tsx index aa88715ac9..d788fb4d4e 100644 --- a/frontend/components/TabsWrapper/TabsWrapper.tsx +++ b/frontend/components/TabsWrapper/TabsWrapper.tsx @@ -1,7 +1,9 @@ import React from "react"; +import classnames from "classnames"; interface ITabsWrapperProps { children: React.ReactChild | React.ReactChild[]; + className?: string; } /* @@ -10,8 +12,13 @@ interface ITabsWrapperProps { */ const baseClass = "component__tabs-wrapper"; -const TabsWrapper = ({ children }: ITabsWrapperProps): JSX.Element => { - return
{children}
; +const TabsWrapper = ({ + children, + className, +}: ITabsWrapperProps): JSX.Element => { + const classNames = classnames(baseClass, className); + + return
{children}
; }; export default TabsWrapper; diff --git a/frontend/context/notification.tsx b/frontend/context/notification.tsx index 199ce195aa..7b79a25382 100644 --- a/frontend/context/notification.tsx +++ b/frontend/context/notification.tsx @@ -1,4 +1,10 @@ -import React, { createContext, useReducer, ReactNode } from "react"; +import React, { + createContext, + useReducer, + ReactNode, + useCallback, + useMemo, +} from "react"; import { INotification } from "interfaces/notification"; import { noop } from "lodash"; @@ -55,9 +61,8 @@ export const NotificationContext = createContext( const NotificationProvider = ({ children }: Props) => { const [state, dispatch] = useReducer(reducer, initialState); - const value = { - notification: state.notification, - renderFlash: ( + const renderFlash = useCallback( + ( alertType: "success" | "error" | "warning-filled" | null, message: JSX.Element | string | null, undoAction?: (evt: React.MouseEvent) => void @@ -69,10 +74,21 @@ const NotificationProvider = ({ children }: Props) => { undoAction, }); }, - hideFlash: () => { - dispatch({ type: actions.HIDE_FLASH }); - }, - }; + [] + ); + + const hideFlash = useCallback(() => { + dispatch({ type: actions.HIDE_FLASH }); + }, []); + + const value = useMemo( + () => ({ + notification: state.notification, + renderFlash, + hideFlash, + }), + [state.notification, renderFlash, hideFlash] + ); return ( diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 858023f517..bc6a91e8cc 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -106,4 +106,5 @@ export interface IActivityDetails { deadline_days?: number; grace_period_days?: number; stats?: IScheduledQueryStats; + host_id?: number; } diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index e34992fdcc..d39e567260 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -104,6 +104,15 @@ export interface IConfigFeatures { enable_software_inventory: boolean; } +export interface IConfigServerSettings { + server_url: string; + live_query_disabled: boolean; + enable_analytics: boolean; + deferred_save_host: boolean; + query_reports_disabled: boolean; + scripts_disabled: boolean; +} + export interface IConfig { org_info: { org_name: string; @@ -112,14 +121,7 @@ export interface IConfig { contact_url: string; }; sandbox_enabled: boolean; - server_settings: { - server_url: string; - live_query_disabled: boolean; - enable_analytics: boolean; - deferred_save_host: boolean; - query_reports_disabled: boolean; - scripts_disabled: boolean; - }; + server_settings: IConfigServerSettings; smtp_settings: { enable_smtp: boolean; configured: boolean; diff --git a/frontend/interfaces/script.ts b/frontend/interfaces/script.ts new file mode 100644 index 0000000000..82eb450f33 --- /dev/null +++ b/frontend/interfaces/script.ts @@ -0,0 +1,23 @@ +export interface IScript { + id: number; + team_id: number | null; + name: string; + created_at: string; + updated_at: string; +} + +export const SCRIPT_SUPPORTED_PLATFORMS = ["darwin", "windows"] as const; // TODO: revisit this approach to white-list supported platforms (which would require a more robust approach to identifying linux flavors) + +export type IScriptExecutionStatus = "ran" | "pending" | "error"; + +export interface ILastExecution { + execution_id: string; + executed_at: string; + status: IScriptExecutionStatus; +} + +export interface IHostScript { + script_id: number; + name: string; + last_execution: ILastExecution | null; +} diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 79d20feeff..6b8ab46aca 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -5,6 +5,7 @@ import { formatDistanceToNowStrict } from "date-fns"; import { ActivityType, IActivity, IActivityDetails } from "interfaces/activity"; import { addGravatarUrlToResource, + formatScriptNameForActivityItem, getPerformanceImpactDescription, internationalTimeFormat, } from "utilities/helpers"; @@ -610,20 +611,26 @@ const TAGGED_TEMPLATES = { disabledWindowsMdm: (activity: IActivity) => { return <> told Fleet to turn off Windows MDM features.; }, + // TODO: Combine ranScript template with host details page templates + // frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx and + // frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx ranScript: ( activity: IActivity, onDetailsClick?: (type: ActivityType, details: IActivityDetails) => void ) => { + const { script_name, host_display_name, script_execution_id } = + activity.details || {}; return ( <> {" "} - ran a script on {activity.details?.host_display_name}.{" "} + ran {formatScriptNameForActivityItem(script_name)} on{" "} + {host_display_name}.{" "} +
+ + + ); +}; + +export default React.memo(RunScriptModal); diff --git a/frontend/pages/hosts/details/cards/Scripts/ScriptsTableConfig.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx similarity index 64% rename from frontend/pages/hosts/details/cards/Scripts/ScriptsTableConfig.tsx rename to frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx index ec24c041f4..dbb294079e 100644 --- a/frontend/pages/hosts/details/cards/Scripts/ScriptsTableConfig.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/ScriptsTableConfig.tsx @@ -1,14 +1,14 @@ import React from "react"; import ReactTooltip from "react-tooltip"; +import { noop } from "lodash"; import { COLORS } from "styles/var/colors"; import { IDropdownOption } from "interfaces/dropdownOption"; -import { IHostScript, ILastExecution } from "services/entities/scripts"; +import { IHostScript, ILastExecution } from "interfaces/script"; import { IUser } from "interfaces/user"; import DropdownCell from "components/TableContainer/DataTable/DropdownCell"; -import { IHost } from "interfaces/host"; import { isGlobalAdmin, isTeamMaintainer, @@ -58,7 +58,7 @@ const ScriptRunActionDropdownLabel = ({ delayHide={100} delayUpdate={500} > - You can only run the script when the host is online. + Script is already running. ) : ( @@ -66,10 +66,47 @@ const ScriptRunActionDropdownLabel = ({ ); }; +const generateActionDropdownOptions = ( + currentUser: IUser | null, + teamId: number | null, + { script_id, last_execution }: IHostScript +): IDropdownOption[] => { + const hasRunPermission = + !!currentUser && + (isGlobalAdmin(currentUser) || + isTeamAdmin(currentUser, teamId) || + isGlobalMaintainer(currentUser) || + isTeamMaintainer(currentUser, teamId) || + // TODO - refactor all permissions to be clear and granular + // each of these (confusingly) cover both observer and observer+ + isGlobalObserver(currentUser) || + isTeamObserver(currentUser, teamId)); + const options: IDropdownOption[] = [ + { + label: "Show details", + disabled: last_execution === null, + value: "showDetails", + }, + { + label: ( + + ), + disabled: last_execution?.status === "pending", + value: "run", + }, + ]; + return hasRunPermission ? options : options.slice(0, 1); +}; + // eslint-disable-next-line import/prefer-default-export export const generateTableColumnConfigs = ( - actionSelectHandler: (value: string, script: IHostScript) => void, - disableActions = false + currentUser: IUser | null, + hostTeamId: number | null, + scriptsDisabled: boolean, + onSelectAction: (value: string, script: IHostScript) => void ) => { return [ { @@ -92,85 +129,45 @@ export const generateTableColumnConfigs = ( Header: "", disableSortBy: true, accessor: "actions", - Cell: (cellProps: IDropdownCellProps) => - disableActions ? ( - - Running scripts is disabled in organization settings
- } - > - - actionSelectHandler(value, cellProps.row.original) + Cell: (cellProps: IDropdownCellProps) => { + if (scriptsDisabled) { + return ( + + + Running scripts is disabled in organization settings +
} - placeholder={"Actions"} - disabled={disableActions} - /> - - - ) : ( + > + + + + ); + } + + const opts = generateActionDropdownOptions( + currentUser, + hostTeamId, + cellProps.row.original + ); + return ( - actionSelectHandler(value, cellProps.row.original) + onSelectAction(value, cellProps.row.original) } placeholder={"Actions"} - disabled={disableActions} + disabled={scriptsDisabled} /> - ), + ); + }, }, ]; }; - -const generateActionDropdownOptions = ( - currentUser: IUser | null, - host: IHost, - { script_id, last_execution }: IHostScript -): IDropdownOption[] => { - const [hostTeamId, isHostOnline] = [host.team_id, host.status === "online"]; - - const hasRunPermission = - !!currentUser && - (isGlobalAdmin(currentUser) || - isTeamAdmin(currentUser, hostTeamId) || - isGlobalMaintainer(currentUser) || - isTeamMaintainer(currentUser, hostTeamId) || - // TODO - refactor all permissions to be clear and granular - // each of these (confusingly) cover both observer and observer+ - isGlobalObserver(currentUser) || - isTeamObserver(currentUser, hostTeamId)); - const options: IDropdownOption[] = [ - { - label: "Show details", - disabled: last_execution === null, - value: "showDetails", - }, - { - label: ( - - ), - disabled: !isHostOnline, - value: "run", - }, - ]; - return hasRunPermission ? options : options.slice(0, 1); -}; - -export const generateDataSet = ( - currentUser: IUser | null, - host: IHost, - scripts: IHostScript[] -) => { - return scripts.map((script) => { - return { - ...script, - actions: generateActionDropdownOptions(currentUser, host, script), - }; - }); -}; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss new file mode 100644 index 0000000000..c8f03ea6f3 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/_styles.scss @@ -0,0 +1,30 @@ +.run-script-modal { + box-sizing: border-box; + width: 811px; + + &__modal-content { + display: flex; + flex-direction: column; + gap: $pad-xlarge; + } + + .modal-cta-wrap { + margin: $pad-xsmall 0 0 0; + } + + &__loading { + .modal__header, + .modal-cta-wrap { + opacity: 0.6; + } + } + + .Select-option { + .dropdown__option { + [data-id="tooltip"] { + font-size: $xx-small; + font-style: normal; + } + } + } +} diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/ScriptStatusCell.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/components/ScriptStatusCell/ScriptStatusCell.tsx similarity index 89% rename from frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/ScriptStatusCell.tsx rename to frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/components/ScriptStatusCell/ScriptStatusCell.tsx index 0f8dbfd535..26d8467ae1 100644 --- a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/ScriptStatusCell.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/components/ScriptStatusCell/ScriptStatusCell.tsx @@ -1,10 +1,7 @@ import React from "react"; import { formatDistanceToNow } from "date-fns"; -import { - ILastExecution, - IScriptExecutionStatus, -} from "services/entities/scripts"; +import { ILastExecution, IScriptExecutionStatus } from "interfaces/script"; import StatusIndicatorWithIcon, { IndicatorStatus, @@ -30,8 +27,7 @@ const STATUS_DISPLAY_CONFIG: Record< pending: { displayText: "Pending", iconStatus: "pendingPartial", - tooltip: () => - "Script is running. To see if the script finished, refresh the page.", + tooltip: () => "Script is running or will run when the host comes online.", }, error: { displayText: "Error", diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/components/ScriptStatusCell/index.ts similarity index 100% rename from frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/index.ts rename to frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/components/ScriptStatusCell/index.ts diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/index.ts new file mode 100644 index 0000000000..28a090f29a --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/RunScriptModal/index.ts @@ -0,0 +1 @@ +export { default } from "./RunScriptModal"; diff --git a/frontend/pages/hosts/details/_styles.scss b/frontend/pages/hosts/details/_styles.scss index 4db893e04d..cfcf5314ad 100644 --- a/frontend/pages/hosts/details/_styles.scss +++ b/frontend/pages/hosts/details/_styles.scss @@ -222,16 +222,20 @@ } } } - .component__tabs-wrapper { + &__tabs-wrapper { background-color: $ui-off-white; width: 100%; - .react-tabs__tab { - padding: 6px 0px 16px 0px; - margin-right: $pad-xxlarge; - } - .react-tabs__tab--selected { - background-color: $ui-off-white; + // direct descendant of selector allows us to only change the first level of + // tab styling and not change the tabs inside the cards. + > .react-tabs > .react-tabs__tab-list { + .react-tabs__tab { + padding: 6px 0px 16px 0px; + margin-right: $pad-xxlarge; + } + .react-tabs__tab--selected { + background-color: $ui-off-white; + } } .focus-visible { @@ -242,6 +246,7 @@ margin-top: $pad-medium; } } + .col-50 { flex: 2; } @@ -306,3 +311,9 @@ } } } + +// we dont need the margin on the host details page as we are not using grid css +// for the spacing. +.host-details__tabs-wrapper .section { + margin-top: 0; +} diff --git a/frontend/pages/hosts/details/cards/Activity/Activity.tsx b/frontend/pages/hosts/details/cards/Activity/Activity.tsx new file mode 100644 index 0000000000..356f4da7b2 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/Activity.tsx @@ -0,0 +1,111 @@ +import React, { useRef, useState } from "react"; +import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; + +import ScriptDetailsModal from "pages/DashboardPage/cards/ActivityFeed/components/ScriptDetailsModal"; +import { IActivityDetails } from "interfaces/activity"; +import { IActivitiesResponse } from "services/entities/activities"; + +import Card from "components/Card"; +import TabsWrapper from "components/TabsWrapper"; +import Spinner from "components/Spinner"; +import TooltipWrapper from "components/TooltipWrapper"; + +import PastActivityFeed from "./PastActivityFeed"; +import UpcomingActivityFeed from "./UpcomingActivityFeed"; + +const baseClass = "activity-card"; + +export interface IShowActivityDetailsData { + type: string; + details?: IActivityDetails; +} + +export type ShowActivityDetailsHandler = ( + data: IShowActivityDetailsData +) => void; + +const UpcomingTooltip = () => { + return ( + + Upcoming activities will run as listed. Failure of one activity won’t + cancel other activities. +
+
+ Currently, only scripts are guaranteed to run in order. + + } + className={`${baseClass}__upcoming-tooltip`} + > + Activities run as listed +
+ ); +}; + +interface IActivityProps { + activeTab: "past" | "upcoming"; + activities?: IActivitiesResponse; // TODO: type + isLoading?: boolean; + isError?: boolean; + onChangeTab: (index: number, last: number, event: Event) => void; + onNextPage: () => void; + onPreviousPage: () => void; + onShowDetails: ShowActivityDetailsHandler; +} + +const Activity = ({ + activeTab, + activities, + isLoading, + isError, + onChangeTab, + onNextPage, + onPreviousPage, + onShowDetails, +}: IActivityProps) => { + // TODO: add count to upcoming activities tab when available via API + return ( + + {isLoading && ( +
+ +
+ )} +

Activity

+ + + + Past + Upcoming + + + + + + + + + + +
+ ); +}; + +export default Activity; diff --git a/frontend/pages/hosts/details/cards/Activity/EmptyFeed/EmptyFeed.tsx b/frontend/pages/hosts/details/cards/Activity/EmptyFeed/EmptyFeed.tsx new file mode 100644 index 0000000000..4f2bb3ad45 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/EmptyFeed/EmptyFeed.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import classnames from "classnames"; + +const baseClass = "empty-feed"; + +interface IEmptyFeedProps { + title: string; + message: string; + className?: string; +} + +const EmptyFeed = ({ title, message, className }: IEmptyFeedProps) => { + const classNames = classnames(baseClass, className); + + return ( +
+

{title}

+

{message}

+
+ ); +}; + +export default EmptyFeed; diff --git a/frontend/pages/hosts/details/cards/Activity/EmptyFeed/_styles.scss b/frontend/pages/hosts/details/cards/Activity/EmptyFeed/_styles.scss new file mode 100644 index 0000000000..dd0365ead9 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/EmptyFeed/_styles.scss @@ -0,0 +1,13 @@ +.empty-feed { + margin-top: $pad-xxlarge; + + p { + font-size: $x-small; + margin: $pad-medium 0; + } + + &__title { + font-weight: $bold; + font-size: $small; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/EmptyFeed/index.ts b/frontend/pages/hosts/details/cards/Activity/EmptyFeed/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx new file mode 100644 index 0000000000..9a5944eaac --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; +import { formatDistanceToNowStrict } from "date-fns"; + +import Avatar from "components/Avatar"; +import Icon from "components/Icon"; +import Button from "components/buttons/Button"; + +import { COLORS } from "styles/var/colors"; +import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; +import { + addGravatarUrlToResource, + formatScriptNameForActivityItem, + internationalTimeFormat, +} from "utilities/helpers"; +import { IActivity } from "interfaces/activity"; +import { ShowActivityDetailsHandler } from "../Activity"; + +const baseClass = "past-activity"; + +interface IPastActivityProps { + activity: IActivity; + onDetailsClick: ShowActivityDetailsHandler; +} +// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and +// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +const PastActivity = ({ activity, onDetailsClick }: IPastActivityProps) => { + const { actor_email } = activity; + const { gravatar_url } = actor_email + ? addGravatarUrlToResource({ email: actor_email }) + : { gravatar_url: DEFAULT_GRAVATAR_LINK }; + const activityCreatedAt = new Date(activity.created_at); + + return ( +
+ +
+
+ + {activity.actor_full_name} + <> + {" "} + ran{" "} + {formatScriptNameForActivityItem( + activity.details?.script_name + )}{" "} + on this host.{" "} + + + +
+ + {formatDistanceToNowStrict(activityCreatedAt, { + addSuffix: true, + })} + + + {internationalTimeFormat(activityCreatedAt)} + +
+
+
+
+ ); +}; + +export default PastActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss new file mode 100644 index 0000000000..04173fdab9 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss @@ -0,0 +1,65 @@ +.past-activity { + display: grid; // Grid system is used to create variable dashed line lengths + grid-template-columns: 16px 16px 1fr; + grid-template-rows: 32px max-content; + + .avatar-wrapper { + grid-column-start: 1; + width: 32px; + height: 32px; + } + + &__dash { + border-right: 1px dashed $ui-fleet-black-10; + grid-column-start: 1; + grid-row-start: 2; + grid-row-end: 3; + } + + &__details-wrapper { + grid-column-start: 3; + grid-row-start: 1; + grid-row-end: 3; + padding-left: $pad-large; + padding-bottom: $pad-large; + + .premium-icon-tip { + position: relative; + top: 4px; + padding-right: $pad-xsmall; + } + + .activity-details { + margin: 0; + line-height: 16px; + } + } + + &__details-topline { + font-size: $x-small; + overflow-wrap: anywhere; + } + + &__details-content { + margin-right: $pad-xsmall; + } + + &__details-bottomline { + font-size: $xx-small; + color: $ui-fleet-black-25; + } + + &__show-query-icon { + margin-left: $pad-xsmall; + } + + &:last-child { + .past-activity__dash { + border-right: none; + } + + .past-activity__details { + padding-bottom: $pad-xxlarge; + } + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts new file mode 100644 index 0000000000..363a39834c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts @@ -0,0 +1 @@ +export { default } from "./PastActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx new file mode 100644 index 0000000000..50a8ca248b --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -0,0 +1,85 @@ +import React from "react"; + +import { IActivity, IActivityDetails } from "interfaces/activity"; +import { IActivitiesResponse } from "services/entities/activities"; + +// @ts-ignore +import FleetIcon from "components/icons/FleetIcon"; +import Button from "components/buttons/Button"; +import DataError from "components/DataError"; + +import EmptyFeed from "../EmptyFeed/EmptyFeed"; +import PastActivity from "../PastActivity/PastActivity"; +import { ShowActivityDetailsHandler } from "../Activity"; + +const baseClass = "past-activity-feed"; + +interface IPastActivityFeedProps { + activities?: IActivitiesResponse; + isError?: boolean; + onDetailsClick: ShowActivityDetailsHandler; + onNextPage: () => void; + onPreviousPage: () => void; +} + +const PastActivityFeed = ({ + activities, + isError = false, + onDetailsClick, + onNextPage, + onPreviousPage, +}: IPastActivityFeedProps) => { + if (isError) { + return ; + } + + if (!activities) { + return null; + } + + const { activities: activitiesList, meta } = activities; + + if (activitiesList === null || activitiesList.length === 0) { + return ( + + ); + } + + return ( +
+
+ {activitiesList.map((activity: IActivity) => ( + + ))} +
+
+ + +
+
+ ); +}; + +export default PastActivityFeed; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/_styles.scss b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/_styles.scss new file mode 100644 index 0000000000..1bd505eab6 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/_styles.scss @@ -0,0 +1,58 @@ +.past-activity-feed { + margin-top: $pad-large; + display: flex; + flex-direction: column; + position: relative; + min-height: 500px; + + &__empty-feed { + min-height: 484px; + } + + &__pagination { + position: absolute; + bottom: 0px; + right: 0px; + + .button { + font-weight: $bold; + } + } + + &__load-activities-button { + color: $core-vibrant-blue; + vertical-align: bottom; + padding: 6px; + + .fleeticon-chevronleft, + .fleeticon-chevronright { + &:before { + font-size: 0.5rem; + font-weight: $bold; + position: relative; + top: -2px; + } + } + + .fleeticon-chevronleft { + margin-right: $pad-small; + } + + .fleeticon-chevronright { + margin-left: $pad-small; + } + + &:first-of-type { + margin-right: $pad-large; + } + + &:hover:not(.button--disabled), + &:focus { + background-color: $ui-vibrant-blue-10; + } + } + + &__error { + margin: $pad-xlarge 0; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/index.ts b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/index.ts new file mode 100644 index 0000000000..7070ce1266 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/index.ts @@ -0,0 +1 @@ +export { default } from "./PastActivityFeed"; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx new file mode 100644 index 0000000000..5457e581e2 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; +import { formatDistanceToNowStrict } from "date-fns"; + +import { IActivity } from "interfaces/activity"; +import { COLORS } from "styles/var/colors"; +import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; +import { + addGravatarUrlToResource, + formatScriptNameForActivityItem, + internationalTimeFormat, +} from "utilities/helpers"; + +import Avatar from "components/Avatar"; +import Icon from "components/Icon"; +import Button from "components/buttons/Button"; +import { ShowActivityDetailsHandler } from "../Activity"; + +const baseClass = "upcoming-activity"; + +interface IUpcomingActivityProps { + activity: IActivity; + onDetailsClick: ShowActivityDetailsHandler; +} + +// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and +// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +const UpcomingActivity = ({ + activity, + onDetailsClick, +}: IUpcomingActivityProps) => { + const { actor_email } = activity; + const { gravatar_url } = actor_email + ? addGravatarUrlToResource({ email: actor_email }) + : { gravatar_url: DEFAULT_GRAVATAR_LINK }; + const activityCreatedAt = new Date(activity.created_at); + + return ( +
+ +
+
+ + {activity.actor_full_name} + <> + {" "} + told Fleet to run{" "} + {formatScriptNameForActivityItem( + activity.details?.script_name + )}{" "} + on this host.{" "} + + + +
+ + {formatDistanceToNowStrict(activityCreatedAt, { + addSuffix: true, + })} + + + {internationalTimeFormat(activityCreatedAt)} + +
+
+
+
+ ); +}; + +export default UpcomingActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss new file mode 100644 index 0000000000..afaa538a71 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss @@ -0,0 +1,65 @@ +.upcoming-activity { + display: grid; // Grid system is used to create variable dashed line lengths + grid-template-columns: 16px 16px 1fr; + grid-template-rows: 32px max-content; + + .avatar-wrapper { + grid-column-start: 1; + width: 32px; + height: 32px; + } + + &__dash { + border-right: 1px dashed $ui-fleet-black-10; + grid-column-start: 1; + grid-row-start: 2; + grid-row-end: 3; + } + + &__details-wrapper { + grid-column-start: 3; + grid-row-start: 1; + grid-row-end: 3; + padding-left: $pad-large; + padding-bottom: $pad-large; + + .premium-icon-tip { + position: relative; + top: 4px; + padding-right: $pad-xsmall; + } + + .activity-details { + margin: 0; + line-height: 16px; + } + } + + &__details-topline { + font-size: $x-small; + overflow-wrap: anywhere; + } + + &__details-content { + margin-right: $pad-xsmall; + } + + &__details-bottomline { + font-size: $xx-small; + color: $ui-fleet-black-25; + } + + &__show-query-icon { + margin-left: $pad-xsmall; + } + + &:last-child { + .upcoming-activity__dash { + border-right: none; + } + + .upcoming-activity__details { + padding-bottom: $pad-xxlarge; + } + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts new file mode 100644 index 0000000000..413a03e29a --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts @@ -0,0 +1 @@ +export { default } from "./UpcomingActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx new file mode 100644 index 0000000000..94f1d547cd --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx @@ -0,0 +1,88 @@ +import React from "react"; + +import { IActivity, IActivityDetails } from "interfaces/activity"; +import { IActivitiesResponse } from "services/entities/activities"; + +// @ts-ignore +import FleetIcon from "components/icons/FleetIcon"; +import DataError from "components/DataError"; +import Button from "components/buttons/Button"; + +import EmptyFeed from "../EmptyFeed/EmptyFeed"; +import UpcomingActivity from "../UpcomingActivity/UpcomingActivity"; +import { ShowActivityDetailsHandler } from "../Activity"; + +const baseClass = "upcoming-activity-feed"; + +interface IUpcomingActivityFeedProps { + activities?: IActivitiesResponse; + isError?: boolean; + onDetailsClick: ShowActivityDetailsHandler; + onNextPage: () => void; + onPreviousPage: () => void; +} + +const UpcomingActivityFeed = ({ + activities, + isError = false, + onDetailsClick, + onNextPage, + onPreviousPage, +}: IUpcomingActivityFeedProps) => { + if (isError) { + return ; + } + + if (!activities) { + return null; + } + + const { activities: activitiesList, meta } = activities; + + if (activitiesList === null || activitiesList.length === 0) { + return ( + + ); + } + + return ( +
+
+ {activitiesList.map((activity: IActivity) => ( + + ))} +
+
+ + +
+
+ ); +}; + +export default UpcomingActivityFeed; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/_styles.scss b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/_styles.scss new file mode 100644 index 0000000000..50bfcbb840 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/_styles.scss @@ -0,0 +1,58 @@ +.upcoming-activity-feed { + margin-top: $pad-large; + display: flex; + flex-direction: column; + position: relative; + min-height: 500px; + + &__empty-feed { + min-height: 484px; + } + + &__pagination { + position: absolute; + bottom: 0px; + right: 0px; + + .button { + font-weight: $bold; + } + } + + &__load-activities-button { + color: $core-vibrant-blue; + vertical-align: bottom; + padding: 6px; + + .fleeticon-chevronleft, + .fleeticon-chevronright { + &:before { + font-size: 0.5rem; + font-weight: $bold; + position: relative; + top: -2px; + } + } + + .fleeticon-chevronleft { + margin-right: $pad-small; + } + + .fleeticon-chevronright { + margin-left: $pad-small; + } + + &:first-of-type { + margin-right: $pad-large; + } + + &:hover:not(.button--disabled), + &:focus { + background-color: $ui-vibrant-blue-10; + } + } + + &__error { + margin: $pad-xlarge 0; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/index.ts b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/index.ts new file mode 100644 index 0000000000..5c1d33fde2 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/index.ts @@ -0,0 +1 @@ +export { default } from "./UpcomingActivityFeed"; diff --git a/frontend/pages/hosts/details/cards/Activity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/_styles.scss new file mode 100644 index 0000000000..b843f4a4ea --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/_styles.scss @@ -0,0 +1,37 @@ +.activity-card { + padding: $pad-xxlarge; + position: relative; + + h2 { + font-size: 20px; + margin: 0 0 $pad-large; + } + + &__upcoming-count { + padding: $pad-xxsmall $pad-xsmall; + color: $core-white; + background-color: $core-vibrant-blue; + border-radius: $border-radius; + font-weight: $bold; + margin-left: $pad-small; + } + + &__loading-overlay { + height: 100%; + width: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 3; + border-radius: 16px; + background-color: rgba(255, 255, 255, 0.8); + } + + // styles to render the tooltip outside of the content area. + &__upcoming-tooltip { + font-size: $xx-small; + position: absolute; + top: 14px; + right: 0; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/index.ts b/frontend/pages/hosts/details/cards/Activity/index.ts new file mode 100644 index 0000000000..a94a0fb85c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/index.ts @@ -0,0 +1 @@ +export { default } from "./Activity"; diff --git a/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx b/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx index 6bb1932c29..c6a05c8ce2 100644 --- a/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx +++ b/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx @@ -1,3 +1,4 @@ +import classnames from "classnames"; import TooltipWrapper from "components/TooltipWrapper"; import React from "react"; @@ -18,6 +19,8 @@ const AgentOptions = ({ wrapFleetHelper, isChromeOS = false, }: IAgentOptionsProps): JSX.Element => { + const classNames = classnames(baseClass, "section", "osquery"); + let configTLSRefresh; let loggerTLSPeriod; let distributedInterval; @@ -44,7 +47,7 @@ const AgentOptions = ({ } return ( -
+
{isChromeOS ? ( void; @@ -10,6 +13,8 @@ interface ILabelsProps { } const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => { + const classNames = classnames(baseClass, "section", "labels"); + const labelItems = labels.map((label: ILabel) => { return (
  • @@ -25,7 +30,7 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => { }); return ( -
    +

    Labels

    {labels.length === 0 ? (

    diff --git a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx b/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx deleted file mode 100644 index 717f1fb107..0000000000 --- a/frontend/pages/hosts/details/cards/Scripts/Scripts.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { useContext, useState } from "react"; -import { useQuery } from "react-query"; -import { AxiosResponse } from "axios"; -import { InjectedRouter } from "react-router"; - -import PATHS from "router/paths"; -import scriptsAPI, { - IHostScript, - IHostScriptsResponse, -} from "services/entities/scripts"; -import { IApiError } from "interfaces/errors"; -import { NotificationContext } from "context/notification"; - -import Card from "components/Card"; -import TableContainer from "components/TableContainer"; -import EmptyTable from "components/EmptyTable"; -import DataError from "components/DataError"; -import Spinner from "components/Spinner"; -import { ITableQueryData } from "components/TableContainer/TableContainer"; -import { IHost } from "interfaces/host"; -import { IUser } from "interfaces/user"; -import { AppContext } from "context/app"; - -import { - generateDataSet, - generateTableColumnConfigs, -} from "./ScriptsTableConfig"; - -const baseClass = "host-scripts-section"; - -interface IScriptsProps { - currentUser: IUser | null; - host?: IHost; - router: InjectedRouter; - page?: number; - onShowDetails: (scriptExecutionId: string) => void; -} - -const Scripts = ({ - currentUser, - host, - page = 0, - router, - onShowDetails, -}: IScriptsProps) => { - const [isScriptRunning, setIsScriptRunning] = useState(false); - const { renderFlash } = useContext(NotificationContext); - - const hostId = host?.id; - - const { - data: hostScriptResponse, - isLoading: isLoadingScriptData, - isError: isErrorScriptData, - refetch: refetchScriptsData, - } = useQuery( - ["scripts", hostId, page], - () => scriptsAPI.getHostScripts(hostId as number, page), - { - refetchOnWindowFocus: false, - retry: false, - enabled: Boolean(hostId), - onSuccess: () => { - setIsScriptRunning(false); - }, - } - ); - - const { config } = useContext(AppContext); - if (!config) return null; - - if (!host) return null; - - const onQueryChange = (data: ITableQueryData) => { - router.push(`${PATHS.HOST_SCRIPTS(host.id)}?page=${data.pageIndex}`); - }; - - const onActionSelection = async (action: string, script: IHostScript) => { - switch (action) { - case "showDetails": - if (!script.last_execution) return; - onShowDetails(script.last_execution.execution_id); - break; - case "run": - try { - setIsScriptRunning(true); - await scriptsAPI.runScript({ - host_id: host.id, - script_id: script.script_id, - }); - refetchScriptsData(); - } catch (e) { - const error = e as AxiosResponse; - renderFlash("error", error.data.errors[0].reason); - setIsScriptRunning(false); - } - break; - default: - break; - } - }; - - if (isErrorScriptData) { - return ; - } - const scriptColumnConfigs = generateTableColumnConfigs( - onActionSelection, - config.server_settings.scripts_disabled - ); - const data = generateDataSet( - currentUser, - host, - hostScriptResponse?.scripts || [] - ); - - return ( - -

    Scripts

    - {isLoadingScriptData && } - {!isLoadingScriptData && data && data.length === 0 && ( - - )} - {!isLoadingScriptData && data && data.length > 0 && ( - <>} - showMarkAllPages={false} - isAllPagesSelected={false} - columnConfigs={scriptColumnConfigs} - data={data} - isLoading={isScriptRunning} - onQueryChange={onQueryChange} - disableNextPage={hostScriptResponse?.meta.has_next_results} - defaultPageIndex={page} - disableCount - /> - )} - - ); -}; - -export default Scripts; diff --git a/frontend/pages/hosts/details/cards/Scripts/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/_styles.scss deleted file mode 100644 index 90784836ae..0000000000 --- a/frontend/pages/hosts/details/cards/Scripts/_styles.scss +++ /dev/null @@ -1,44 +0,0 @@ -.host-scripts-section { - margin-top: $pad-medium; - padding: $pad-xxlarge; - - h2 { - font-size: $medium; - font-weight: $bold; - margin: 0 0 $pad-medium 0; - line-height: 1.5; - } - - .table-container { - .name__header { - width: 50%; - } - .last_execution__header { - width: 25%; - } - .actions__header { - width: 25%; - } - } - - .data-table-block .data-table__wrapper { - box-sizing: border-box; - } - - .table-container__header-left { - display: block; - } - - .dropdown__option { - [data-id="tooltip"][id^="run-script"] { - font-size: $xx-small; - font-style: normal; - } - } - - .status-indicator-with-icon__value { - display: initial; - align-items: initial; - vertical-align: initial; - } -} diff --git a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss b/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss deleted file mode 100644 index 2b0b194bf0..0000000000 --- a/frontend/pages/hosts/details/cards/Scripts/components/ScriptStatusCell/_styles.scss +++ /dev/null @@ -1,3 +0,0 @@ -.script-status-cell { - -} diff --git a/frontend/pages/hosts/details/cards/Scripts/index.ts b/frontend/pages/hosts/details/cards/Scripts/index.ts deleted file mode 100644 index ee228e7774..0000000000 --- a/frontend/pages/hosts/details/cards/Scripts/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./Scripts"; diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts index fa36ab809d..befbb47587 100644 --- a/frontend/services/entities/activities.ts +++ b/frontend/services/entities/activities.ts @@ -9,7 +9,7 @@ const ORDER_KEY = "created_at"; const ORDER_DIRECTION = "desc"; export interface IActivitiesResponse { - activities: IActivity[]; + activities: IActivity[] | null; meta: { has_next_results: boolean; has_previous_results: boolean; @@ -36,4 +36,42 @@ export default { return sendRequest("GET", path); }, + + getHostPastActivities: ( + id: number, + page = DEFAULT_PAGE, + perPage = DEFAULT_PAGE_SIZE + ): Promise => { + const { HOST_PAST_ACTIVITIES } = endpoints; + + const queryParams = { + page, + per_page: perPage, + }; + + const queryString = buildQueryStringFromParams(queryParams); + + const path = `${HOST_PAST_ACTIVITIES(id)}?${queryString}`; + + return sendRequest("GET", path); + }, + + getHostUpcomingActivities: ( + id: number, + page = DEFAULT_PAGE, + perPage = DEFAULT_PAGE_SIZE + ): Promise => { + const { HOST_UPCOMING_ACTIVITIES } = endpoints; + + const queryParams = { + page, + per_page: perPage, + }; + + const queryString = buildQueryStringFromParams(queryParams); + + const path = `${HOST_UPCOMING_ACTIVITIES(id)}?${queryString}`; + + return sendRequest("GET", path); + }, }; diff --git a/frontend/services/entities/scripts.ts b/frontend/services/entities/scripts.ts index 2dfe585793..8ed35f9a17 100644 --- a/frontend/services/entities/scripts.ts +++ b/frontend/services/entities/scripts.ts @@ -1,15 +1,8 @@ +import { IScript, IHostScript } from "interfaces/script"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; -export interface IScript { - id: number; - team_id: number | null; - name: string; - created_at: string; - updated_at: string; -} - /** Single script response from GET /script/:id */ export type IScriptResponse = IScript; @@ -40,6 +33,7 @@ export interface IScriptResultResponse { host_id: number; execution_id: string; script_contents: string; + script_id: number; exit_code: number | null; output: string; message: string; @@ -47,18 +41,17 @@ export interface IScriptResultResponse { host_timeout: boolean; } -export type IScriptExecutionStatus = "ran" | "pending" | "error"; - -export interface ILastExecution { - execution_id: string; - executed_at: string; - status: IScriptExecutionStatus; +/** + * Request params for for GET /hosts/:id/scripts + */ +export interface IHostScriptsRequestParams { + host_id: number; + page?: number; + per_page?: number; } -export interface IHostScript { - script_id: number; - name: string; - last_execution: ILastExecution | null; +export interface IHostScriptsQueryKey extends IHostScriptsRequestParams { + scope: "host_scripts"; } /** @@ -94,13 +87,13 @@ export interface IScriptRunResponse { } export default { - getHostScripts(id: number, page?: number) { + getHostScripts({ host_id, page, per_page }: IHostScriptsRequestParams) { const { HOST_SCRIPTS } = endpoints; + const path = `${HOST_SCRIPTS(host_id)}?${buildQueryStringFromParams({ + page, + per_page, + })}`; - let path = HOST_SCRIPTS(id); - if (page) { - path = `${path}?${buildQueryStringFromParams({ page })}`; - } return sendRequest("GET", path); }, diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 988d168717..3e63cdf85a 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -1,7 +1,15 @@ const API_VERSION = "latest"; export default { + // activities ACTIVITIES: `/${API_VERSION}/fleet/activities`, + HOST_PAST_ACTIVITIES: (id: number): string => { + return `/${API_VERSION}/fleet/hosts/${id}/activities`; + }, + HOST_UPCOMING_ACTIVITIES: (id: number): string => { + return `/${API_VERSION}/fleet/hosts/${id}/activities/upcoming`; + }, + CHANGE_PASSWORD: `/${API_VERSION}/fleet/change_password`, CONFIG: `/${API_VERSION}/fleet/config`, CONFIRM_EMAIL_CHANGE: (token: string): string => { diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index d8d0a1e2c7..ec9b2d8afd 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -480,6 +480,16 @@ export const formatPackForClient = (pack: IPack): IPack => { return pack; }; +export const formatScriptNameForActivityItem = (name: string | undefined) => { + return name ? ( + <> + the {name} script + + ) : ( + "a script" + ); +}; + export const generateRole = ( teams: ITeam[], globalRole: UserRole | null @@ -876,6 +886,7 @@ export default { formatFloatAsPercentage, formatScheduledQueryForClient, formatScheduledQueryForServer, + formatScriptNameForActivityItem, formatGlobalScheduledQueryForClient, formatGlobalScheduledQueryForServer, formatTeamScheduledQueryForClient, diff --git a/orbit/changes/15963-fleetd-agent b/orbit/changes/15963-fleetd-agent new file mode 100644 index 0000000000..35e04a023a --- /dev/null +++ b/orbit/changes/15963-fleetd-agent @@ -0,0 +1,2 @@ +- Updated script running logic to stop running scripts if the script content can't be fetched from +Fleet, which will preserve the order in which the scripts are queued. \ No newline at end of file diff --git a/orbit/pkg/scripts/scripts.go b/orbit/pkg/scripts/scripts.go index bbd0f972b2..51efeb9c22 100644 --- a/orbit/pkg/scripts/scripts.go +++ b/orbit/pkg/scripts/scripts.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "runtime" - "strings" "time" "unicode/utf8" @@ -59,42 +58,34 @@ func (r *Runner) Run(execIDs []string) error { continue } - if err := r.runOne(execID); err != nil { + script, err := r.Client.GetHostScript(execID) + if err != nil { + errs = append(errs, fmt.Errorf("get host script: %w", err)) + // Stop here since we want to preserve the order in which scripts are queued. + break + } + + if err := r.runOne(script); err != nil { errs = append(errs, err) } } if len(errs) > 0 { - // NOTE: when we upgrade to Go1.20, we can use errors.Join, but for now we - // just concatenate the error messages in a single error that will be logged - // by orbit. - var sb strings.Builder - for i, e := range errs { - if i > 0 { - sb.WriteString("\n") - } - sb.WriteString(e.Error()) - } - return errors.New(sb.String()) + return errors.Join(errs...) } return nil } -func (r *Runner) runOne(execID string) (finalErr error) { +func (r *Runner) runOne(script *fleet.HostScriptResult) (finalErr error) { const maxOutputRuneLen = 10000 - script, err := r.Client.GetHostScript(execID) - if err != nil { - return fmt.Errorf("get host script: %w", err) - } - if script.ExitCode != nil { // already a result stored for this execution, skip, it shouldn't be sent // again by Fleet. return nil } - runDir, err := r.createRunDir(execID) + runDir, err := r.createRunDir(script.ExecutionID) if err != nil { return fmt.Errorf("create run directory: %w", err) } @@ -147,7 +138,7 @@ func (r *Runner) runOne(execID string) (finalErr error) { } err = r.Client.SaveHostScriptResult(&fleet.HostScriptResultPayload{ - ExecutionID: execID, + ExecutionID: script.ExecutionID, Output: string(output), Runtime: int(duration.Seconds()), ExitCode: exitCode, diff --git a/orbit/pkg/scripts/scripts_test.go b/orbit/pkg/scripts/scripts_test.go index c5ed0f7c9b..e7eb22f4f8 100644 --- a/orbit/pkg/scripts/scripts_test.go +++ b/orbit/pkg/scripts/scripts_test.go @@ -132,13 +132,22 @@ func TestRunner(t *testing.T) { errContains: "", // no errors reported, script is just skipped }, { - desc: "multiple errors reported, one get fails, one non-existing", + desc: "first script get error", client: &mockClient{getErr: errFailOnce, scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: ptr.Int64(0)}}}, execer: &mockExecCmd{}, enabled: true, execIDs: []string{"a", "b"}, execCalls: 0, - errContains: "get host script: fail once\nget host script: no such script: b", + errContains: "get host script: fail once", + }, + { + desc: "middle script get error", + client: &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ExitCode: ptr.Int64(0)}, "b": {}, "c": {}}, erroredScripts: map[string]error{"b": errFailOnce}}, + execer: &mockExecCmd{}, + enabled: true, + execIDs: []string{"a", "b", "c"}, + execCalls: 0, + errContains: "get host script: fail once", }, } for _, c := range cases { @@ -164,7 +173,7 @@ func TestRunnerTempDir(t *testing.T) { t.Run("deletes temp dir", func(t *testing.T) { tempDir := t.TempDir() - client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}} + client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'", ExecutionID: "a"}}} execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil} runner := &Runner{ Client: client, @@ -228,7 +237,7 @@ func TestRunnerTempDir(t *testing.T) { tempDir := t.TempDir() t.Setenv("FLEET_PREVENT_SCRIPT_TEMPDIR_DELETION", "1") - client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}} + client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'", ExecutionID: "a"}}} execer := &mockExecCmd{output: []byte("output"), exitCode: 0, err: nil} runner := &Runner{ Client: client, @@ -315,7 +324,7 @@ func TestRunnerResults(t *testing.T) { } for _, c := range cases { t.Run(c.desc, func(t *testing.T) { - client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'"}}} + client := &mockClient{scripts: map[string]*fleet.HostScriptResult{"a": {ScriptContents: "echo 'Hi'", ExecutionID: "a"}}} execer := &mockExecCmd{output: []byte(c.output), exitCode: c.exitCode, err: c.runErr} runner := &Runner{ Client: client, @@ -351,10 +360,11 @@ func (m *mockExecCmd) run(ctx context.Context, scriptPath string) ([]byte, int, var errFailOnce = errors.New("fail once") type mockClient struct { - scripts map[string]*fleet.HostScriptResult - results map[string]*fleet.HostScriptResultPayload - getErr error - saveErr error + scripts map[string]*fleet.HostScriptResult + results map[string]*fleet.HostScriptResultPayload + getErr error + saveErr error + erroredScripts map[string]error } func (m *mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) { @@ -366,10 +376,19 @@ func (m *mockClient) GetHostScript(execID string) (*fleet.HostScriptResult, erro return nil, err } + if m.erroredScripts == nil { + m.erroredScripts = make(map[string]error) + } + script := m.scripts[execID] if script == nil { return nil, fmt.Errorf("no such script: %s", execID) } + + if err, ok := m.erroredScripts[execID]; ok { + return nil, err + } + return script, nil } diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index 85cc629cd1..e73b443196 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -40,16 +40,35 @@ func (ds *Datastore) NewActivity(ctx context.Context, user *fleet.User, activity cols = append(cols, "user_email") } - insertStmt := `INSERT INTO activities (%s) VALUES (%s)` - sql := fmt.Sprintf(insertStmt, strings.Join(cols, ","), strings.Repeat("?,", len(cols)-1)+"?") - _, err = ds.writer(ctx).ExecContext(ctx, - sql, - args..., - ) - if err != nil { - return ctxerr.Wrap(ctx, err, "new activity") - } - return nil + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + const insertActStmt = `INSERT INTO activities (%s) VALUES (%s)` + sql := fmt.Sprintf(insertActStmt, strings.Join(cols, ","), strings.Repeat("?,", len(cols)-1)+"?") + res, err := tx.ExecContext(ctx, sql, args...) + if err != nil { + return ctxerr.Wrap(ctx, err, "new activity") + } + + // this supposes a reasonable amount of hosts per activity, to revisit if we + // get in the 10K+. + if ah, ok := activity.(fleet.ActivityHosts); ok { + const insertActHostStmt = `INSERT INTO host_activities (host_id, activity_id) VALUES ` + + var sb strings.Builder + if hostIDs := ah.HostIDs(); len(hostIDs) > 0 { + sb.WriteString(insertActHostStmt) + actID, _ := res.LastInsertId() + for _, hid := range hostIDs { + sb.WriteString(fmt.Sprintf("(%d, %d),", hid, actID)) + } + + stmt := strings.TrimSuffix(sb.String(), ",") + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return ctxerr.Wrap(ctx, err, "insert host activity") + } + } + } + return nil + }) } // ListActivities returns a slice of activities performed across the organization @@ -164,3 +183,94 @@ func (ds *Datastore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs [ } return nil } + +func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + const listStmt = ` + SELECT + hsr.execution_id as uuid, + u.name as name, + u.id as user_id, + u.gravatar_url as gravatar_url, + u.email as user_email, + ? as activity_type, + hsr.created_at as created_at, + JSON_OBJECT( + 'host_id', hsr.host_id, + 'host_display_name', COALESCE(hdn.display_name, ''), + 'script_name', COALESCE(scr.name, ''), + 'script_execution_id', hsr.execution_id, + 'async', NOT hsr.sync_request + ) as details + FROM + host_script_results hsr + LEFT OUTER JOIN + users u ON u.id = hsr.user_id + LEFT OUTER JOIN + host_display_names hdn ON hdn.host_id = hsr.host_id + LEFT OUTER JOIN + scripts scr ON scr.id = hsr.script_id + WHERE + hsr.host_id = ? AND + hsr.exit_code IS NULL +` + + args := []any{fleet.ActivityTypeRanScript{}.ActivityName(), hostID} + stmt, args := appendListOptionsWithCursorToSQL(listStmt, args, &opt) + + var activities []*fleet.Activity + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, stmt, args...); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "select upcoming activities") + } + + var metaData *fleet.PaginationMetadata + if opt.IncludeMetadata { + metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0} + if len(activities) > int(opt.PerPage) { + metaData.HasNextResults = true + activities = activities[:len(activities)-1] + } + } + + return activities, metaData, nil +} + +func (ds *Datastore) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + const listStmt = ` + SELECT + ha.activity_id as id, + a.user_email as user_email, + a.user_name as name, + a.activity_type as activity_type, + a.details as details, + u.gravatar_url as gravatar_url, + a.created_at as created_at, + u.id as user_id + FROM + host_activities ha + JOIN activities a + ON ha.activity_id = a.id + LEFT OUTER JOIN + users u ON u.id = a.user_id + WHERE + ha.host_id = ? + ` + + args := []any{hostID} + stmt, args := appendListOptionsWithCursorToSQL(listStmt, args, &opt) + + var activities []*fleet.Activity + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &activities, stmt, args...); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "select upcoming activities") + } + + var metaData *fleet.PaginationMetadata + if opt.IncludeMetadata { + metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0} + if len(activities) > int(opt.PerPage) { + metaData.HasNextResults = true + activities = activities[:len(activities)-1] + } + } + + return activities, metaData, nil +} diff --git a/server/datastore/mysql/activities_test.go b/server/datastore/mysql/activities_test.go index 9f0938bb05..a7c12715af 100644 --- a/server/datastore/mysql/activities_test.go +++ b/server/datastore/mysql/activities_test.go @@ -6,9 +6,11 @@ import ( "fmt" "sort" "testing" + "time" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -25,6 +27,8 @@ func TestActivity(t *testing.T) { {"ListActivitiesStreamed", testListActivitiesStreamed}, {"EmptyUser", testActivityEmptyUser}, {"PaginationMetadata", testActivityPaginationMetadata}, + {"ListHostUpcomingActivities", testListHostUpcomingActivities}, + {"ListHostPastActivities", testListHostPastActivities}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -37,6 +41,7 @@ func TestActivity(t *testing.T) { type dummyActivity struct { name string `json:"-"` details map[string]interface{} + hostIDs []uint } func (d dummyActivity) MarshalJSON() ([]byte, error) { @@ -55,6 +60,10 @@ func (d dummyActivity) Documentation() (activity string, details string, details return "", "", "" } +func (d dummyActivity) HostIDs() []uint { + return d.hostIDs +} + func testActivityUsernameChange(t *testing.T, ds *Datastore) { u := &fleet.User{ Password: []byte("asd"), @@ -289,3 +298,258 @@ func testActivityPaginationMetadata(t *testing.T, ds *Datastore) { }) } } + +func testListHostUpcomingActivities(t *testing.T, ds *Datastore) { + ctx := context.Background() + + u := test.NewUser(t, ds, "user1", "user1@example.com", false) + + // create three hosts + h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now()) + h2 := test.NewHost(t, ds, "h2.local", "10.10.10.2", "2", "2", time.Now()) + h3 := test.NewHost(t, ds, "h3.local", "10.10.10.3", "3", "3", time.Now()) + + // create a couple of named scripts + scr1, err := ds.NewScript(ctx, &fleet.Script{ + Name: "A", + ScriptContents: "A", + }) + require.NoError(t, err) + scr2, err := ds.NewScript(ctx, &fleet.Script{ + Name: "B", + ScriptContents: "B", + }) + require.NoError(t, err) + + // create some script requests for h1 + hsr, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID}) + require.NoError(t, err) + h1A := hsr.ExecutionID + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptID: &scr2.ID, ScriptContents: scr2.ScriptContents, UserID: &u.ID}) + require.NoError(t, err) + h1B := hsr.ExecutionID + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "C", UserID: &u.ID}) + require.NoError(t, err) + h1C := hsr.ExecutionID + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "D"}) + require.NoError(t, err) + h1D := hsr.ExecutionID + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h1.ID, ScriptContents: "E"}) + require.NoError(t, err) + h1E := hsr.ExecutionID + + // create a single pending request for h2, as well as a non-pending one + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptID: &scr1.ID, ScriptContents: scr1.ScriptContents, UserID: &u.ID}) + require.NoError(t, err) + h2A := hsr.ExecutionID + hsr, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: h2.ID, ScriptContents: "F", UserID: &u.ID}) + require.NoError(t, err) + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{HostID: h2.ID, ExecutionID: hsr.ExecutionID, Output: "ok", ExitCode: 0}) + require.NoError(t, err) + h2F := hsr.ExecutionID + + // no script request for h3 + + execIDsWithUser := map[string]bool{ + h1A: true, + h1B: true, + h1C: true, + h1D: false, + h1E: false, + h2A: true, + h2F: true, + } + execIDsScriptName := map[string]string{ + h1A: scr1.Name, + h1B: scr2.Name, + h2A: scr1.Name, + } + + cases := []struct { + opts fleet.ListOptions + hostID uint + wantExecs []string + wantMeta *fleet.PaginationMetadata + }{ + { + opts: fleet.ListOptions{PerPage: 2}, + hostID: h1.ID, + wantExecs: []string{h1A, h1B}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{Page: 1, PerPage: 2}, + hostID: h1.ID, + wantExecs: []string{h1C, h1D}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{Page: 2, PerPage: 2}, + hostID: h1.ID, + wantExecs: []string{h1E}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{PerPage: 3}, + hostID: h1.ID, + wantExecs: []string{h1A, h1B, h1C}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{Page: 1, PerPage: 3}, + hostID: h1.ID, + wantExecs: []string{h1D, h1E}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{Page: 2, PerPage: 3}, + hostID: h1.ID, + wantExecs: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + opts: fleet.ListOptions{PerPage: 3}, + hostID: h2.ID, + wantExecs: []string{h2A}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + opts: fleet.ListOptions{}, + hostID: h3.ID, + wantExecs: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v: %#v", c.hostID, c.opts), func(t *testing.T) { + // always include metadata + c.opts.IncludeMetadata = true + c.opts.OrderKey = "created_at" + c.opts.OrderDirection = fleet.OrderAscending + + acts, meta, err := ds.ListHostUpcomingActivities(ctx, c.hostID, c.opts) + require.NoError(t, err) + + require.Equal(t, len(c.wantExecs), len(acts)) + require.Equal(t, c.wantMeta, meta) + + for i, a := range acts { + wantExec := c.wantExecs[i] + + var details map[string]any + require.NotNil(t, a.Details, "result %d", i) + require.NoError(t, json.Unmarshal([]byte(*a.Details), &details), "result %d", i) + + require.Equal(t, wantExec, details["script_execution_id"], "result %d", i) + require.Equal(t, c.hostID, uint(details["host_id"].(float64)), "result %d", i) + require.Equal(t, execIDsScriptName[wantExec], details["script_name"], "result %d", i) + if execIDsWithUser[wantExec] { + require.NotNil(t, a.ActorID, "result %d", i) + require.Equal(t, u.ID, *a.ActorID, "result %d", i) + require.NotNil(t, a.ActorFullName, "result %d", i) + require.Equal(t, u.Name, *a.ActorFullName, "result %d", i) + require.NotNil(t, a.ActorEmail, "result %d", i) + require.Equal(t, u.Email, *a.ActorEmail, "result %d", i) + } else { + require.Nil(t, a.ActorID, "result %d", i) + require.Nil(t, a.ActorFullName, "result %d", i) + require.Nil(t, a.ActorEmail, "result %d", i) + } + } + }) + } +} + +func testListHostPastActivities(t *testing.T, ds *Datastore) { + ctx := context.Background() + + getDetails := func(a *fleet.Activity) map[string]any { + details := make(map[string]any) + err := json.Unmarshal([]byte(*a.Details), &details) + require.NoError(t, err) + + return details + } + + u := test.NewUser(t, ds, "user1", "user1@example.com", false) + h1 := test.NewHost(t, ds, "h1.local", "10.10.10.1", "1", "1", time.Now()) + activities := []dummyActivity{ + { + name: "ran_script", + details: map[string]any{"host_id": float64(h1.ID), "host_display_name": h1.DisplayName(), "script_execution_id": "exec_1", "script_name": "script_1.sh", "async": true}, + hostIDs: []uint{h1.ID}, + }, + + { + name: "ran_script", + details: map[string]any{"host_id": float64(h1.ID), "host_display_name": h1.DisplayName(), "script_execution_id": "exec_2", "async": false}, + hostIDs: []uint{h1.ID}, + }, + } + + for _, a := range activities { + require.NoError(t, ds.NewActivity(context.Background(), u, a)) + } + + cases := []struct { + name string + expActs []dummyActivity + opts fleet.ListActivitiesOptions + expMeta *fleet.PaginationMetadata + }{ + { + name: "fetch page one", + expActs: []dummyActivity{activities[0]}, + expMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + opts: fleet.ListActivitiesOptions{ + ListOptions: fleet.ListOptions{ + Page: 0, + PerPage: 1, + }, + }, + }, + { + name: "fetch page two", + expActs: []dummyActivity{activities[1]}, + expMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + opts: fleet.ListActivitiesOptions{ + ListOptions: fleet.ListOptions{ + Page: 1, + PerPage: 1, + }, + }, + }, + { + name: "fetch all activities", + expActs: activities, + expMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + opts: fleet.ListActivitiesOptions{ + ListOptions: fleet.ListOptions{ + Page: 0, + PerPage: 2, + }, + }, + }, + } + + for _, c := range cases { + c.opts.ListOptions.IncludeMetadata = true + acts, meta, err := ds.ListHostPastActivities(ctx, h1.ID, c.opts.ListOptions) + require.NoError(t, err) + require.Len(t, acts, len(c.expActs)) + require.Equal(t, c.expMeta, meta) + + // check fields in activities + for i, ra := range acts { + require.Equal(t, u.Email, *ra.ActorEmail) + require.Equal(t, u.Name, *ra.ActorFullName) + require.Equal(t, "ran_script", ra.Type) + require.Equal(t, u.GravatarURL, *ra.ActorGravatar) + require.Equal(t, u.ID, *ra.ActorID) + details := getDetails(ra) + for k, v := range details { + require.Equal(t, c.expActs[i].details[k], v) + } + } + } +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 6a5e843c35..9c15f2e860 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -492,6 +492,7 @@ var hostRefs = []string{ "host_software_installed_paths", "host_script_results", "query_results", + "host_activities", } // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index b9c1cc2bb0..74060c3e97 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -6340,6 +6340,16 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { `, host.UUID) require.NoError(t, err) + err = ds.NewActivity( // automatically creates the host_activities entry + context.Background(), + user1, + fleet.ActivityTypeRanScript{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }, + ) + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool diff --git a/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go new file mode 100644 index 0000000000..13c05e4353 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities.go @@ -0,0 +1,57 @@ +package tables + +import ( + "database/sql" + "fmt" + + "github.com/pkg/errors" +) + +func init() { + MigrationClient.AddMigration(Up_20240126020643, Down_20240126020643) +} + +func Up_20240126020643(tx *sql.Tx) error { + // add user_id to host_script_results so that we can display the "actor" in + // the upcoming activities for a host (who requested the script execution). + // Unlike for activities, we don't copy over all the user's information, + // instead we just link to the existing user and set it to NULL if the user + // gets deleted. This is because the script executions are expected to run + // soon after the request is made, it should be a rare occurrence for the + // requesting user to be deleted before it runs. + // + // sync_request indicates if the script execution was requested via the + // synchronous API. We need this information to generate the proper activity + // details later on when the results are received. + const alterStmt = ` + ALTER TABLE host_script_results + ADD COLUMN user_id INT(10) UNSIGNED DEFAULT NULL, + ADD COLUMN sync_request TINYINT(1) NOT NULL DEFAULT '0', + ADD CONSTRAINT fk_host_script_results_user_id FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL; + ` + if _, err := tx.Exec(alterStmt); err != nil { + return fmt.Errorf("add user_id to host_script_results: %w", err) + } + + // Note that we don't create FKs to hosts for performance reasons (ingestion + // of data at scale). FK is created for activities, those entries should be + // deleted if for some reason the activity is deleted. + const hostActivitiesStmt = ` + CREATE TABLE IF NOT EXISTS host_activities ( + host_id INT(10) UNSIGNED NOT NULL, + activity_id INT(10) UNSIGNED NOT NULL, + + PRIMARY KEY (host_id, activity_id), + FOREIGN KEY fk_host_activities_activity_id (activity_id) REFERENCES activities (id) ON DELETE CASCADE + ); + ` + if _, err := tx.Exec(hostActivitiesStmt); err != nil { + return errors.Wrap(err, "create host_activities table") + } + + return nil +} + +func Down_20240126020643(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go new file mode 100644 index 0000000000..711a30a9b8 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240126020643_AddHostActivities_test.go @@ -0,0 +1,76 @@ +package tables + +import ( + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUp_20240126020643(t *testing.T) { + db := applyUpToPrev(t) + + // create a couple users + u1 := execNoErrLastID(t, db, `INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`, "u1", "u1@b.c", "1234", "salt") + u2 := execNoErrLastID(t, db, `INSERT INTO users (name, email, password, salt) VALUES (?, ?, ?, ?)`, "u2", "u2@b.c", "1234", "salt") + + // create an activity + act1 := execNoErrLastID(t, db, `INSERT INTO activities (user_id, user_name, user_email, activity_type) VALUES (?, ?, ?, ?)`, u1, "u1", "u1@b.c", "act1") + + // create a host execution request in the past + minutesAgo := time.Now().UTC().Add(-5 * time.Minute).Truncate(time.Second) + hsr1 := execNoErrLastID(t, db, `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, 1, uuid.NewString(), "echo 'hello'", "", minutesAgo, minutesAgo) + + // Apply current migration. + applyNext(t, db) + + // existing host execution request's timestamp hasn't changed (despite added column) + type timestamps struct { + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } + + var ts timestamps + err := db.Get(&ts, `SELECT created_at, updated_at FROM host_script_results WHERE id = ?`, hsr1) + require.NoError(t, err) + assert.Equal(t, minutesAgo, ts.CreatedAt) + assert.Equal(t, minutesAgo, ts.UpdatedAt) + + // create a new host execution request with user u1 and one with u2 + hsr2 := execNoErrLastID(t, db, `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, user_id) VALUES (?, ?, ?, ?, ?)`, 1, uuid.NewString(), "echo 'hello'", "", u1) + hsr3 := execNoErrLastID(t, db, `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, user_id) VALUES (?, ?, ?, ?, ?)`, 1, uuid.NewString(), "echo 'hello'", "", u2) + + // create a host activity entry for act1 + execNoErr(t, db, `INSERT INTO host_activities (host_id, activity_id) VALUES (?, ?)`, 1, act1) + + // delete user u1 + execNoErr(t, db, `DELETE FROM users WHERE id = ?`, u1) + + var userID sql.NullInt64 + // hsr2 now has a NULL user id, but hsr3 still has user id u2 + err = db.Get(&userID, `SELECT user_id FROM host_script_results WHERE id = ?`, hsr2) + require.NoError(t, err) + assert.False(t, userID.Valid) + err = db.Get(&userID, `SELECT user_id FROM host_script_results WHERE id = ?`, hsr3) + require.NoError(t, err) + assert.True(t, userID.Valid) + assert.Equal(t, u2, userID.Int64) + + // host activity entry exists for host 1 + var actID sql.NullInt64 + err = db.Get(&actID, `SELECT activity_id FROM host_activities WHERE host_id = ?`, 1) + require.NoError(t, err) + assert.True(t, actID.Valid) + assert.Equal(t, act1, actID.Int64) + + // delete activity act1 + execNoErr(t, db, `DELETE FROM activities WHERE id = ?`, act1) + + // host activity entry does not exist anymore + err = db.Get(&actID, `SELECT activity_id FROM host_activities WHERE host_id = ?`, 1) + require.Error(t, err) + assert.ErrorIs(t, err, sql.ErrNoRows) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 873f3296ea..825ac70acb 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -160,6 +160,16 @@ CREATE TABLE `eulas` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; +CREATE TABLE `host_activities` ( + `host_id` int(10) unsigned NOT NULL, + `activity_id` int(10) unsigned NOT NULL, + PRIMARY KEY (`host_id`,`activity_id`), + KEY `fk_host_activities_activity_id` (`activity_id`), + CONSTRAINT `host_activities_ibfk_1` FOREIGN KEY (`activity_id`) REFERENCES `activities` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `host_additional` ( `host_id` int(10) unsigned NOT NULL, `additional` json DEFAULT NULL, @@ -364,12 +374,16 @@ CREATE TABLE `host_script_results` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `script_id` int(10) unsigned DEFAULT NULL, + `user_id` int(10) unsigned DEFAULT NULL, + `sync_request` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_host_script_results_execution_id` (`execution_id`), KEY `idx_host_script_results_host_exit_created` (`host_id`,`exit_code`,`created_at`), KEY `fk_host_script_results_script_id` (`script_id`), KEY `idx_host_script_created_at` (`host_id`,`script_id`,`created_at`), - CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL + KEY `fk_host_script_results_user_id` (`user_id`), + CONSTRAINT `fk_host_script_results_script_id` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_host_script_results_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; @@ -745,9 +759,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=240 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=241 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index 2e2c1280ce..98f6cf46df 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -15,8 +15,8 @@ import ( func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { const ( - insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, script_id) VALUES (?, ?, ?, '', ?)` - getStmt = `SELECT id, host_id, execution_id, script_contents, created_at, script_id FROM host_script_results WHERE id = ?` + insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_contents, output, script_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?)` + getStmt = `SELECT id, host_id, execution_id, script_contents, created_at, script_id, user_id, sync_request FROM host_script_results WHERE id = ?` ) execID := uuid.New().String() @@ -25,6 +25,8 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request execID, request.ScriptContents, request.ScriptID, + request.UserID, + request.SyncRequest, ) if err != nil { return nil, ctxerr.Wrap(ctx, err, "new host script execution request") @@ -38,7 +40,7 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request return &script, nil } -func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { +func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) { const updStmt = ` UPDATE host_script_results SET output = ?, @@ -60,19 +62,29 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f output = string(outputRunes[len(outputRunes)-maxOutputRuneLen:]) } - if _, err := ds.writer(ctx).ExecContext(ctx, updStmt, + res, err := ds.writer(ctx).ExecContext(ctx, updStmt, output, result.Runtime, result.ExitCode, result.HostID, result.ExecutionID, - ); err != nil { - return ctxerr.Wrap(ctx, err, "update host script result") + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "update host script result") } - return nil + + var hsr *fleet.HostScriptResult + if n, _ := res.RowsAffected(); n > 0 { + // it did update, so return the updated result + hsr, err = ds.getHostScriptExecutionResultDB(ctx, ds.writer(ctx), result.ExecutionID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "load updated host script result") + } + } + return hsr, nil } -func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { +func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { const listStmt = ` SELECT id, @@ -84,18 +96,41 @@ func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID host_script_results WHERE host_id = ? AND - exit_code IS NULL AND - created_at >= DATE_SUB(NOW(), INTERVAL ? SECOND)` + exit_code IS NULL + ORDER BY + created_at ASC` var results []*fleet.HostScriptResult - seconds := int(ignoreOlder.Seconds()) - if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, hostID, seconds); err != nil { - return nil, ctxerr.Wrap(ctx, err, "list pending host script results") + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, listStmt, hostID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "list pending host script executions") + } + return results, nil +} + +func (ds *Datastore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) { + const getStmt = ` + SELECT + 1 + FROM + host_script_results + WHERE + host_id = ? AND + script_id = ? AND + exit_code IS NULL + ` + + var results []*uint + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, getStmt, hostID, scriptID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "is execution pending for host") } return results, nil } func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { + return ds.getHostScriptExecutionResultDB(ctx, ds.reader(ctx), execID) +} + +func (ds *Datastore) getHostScriptExecutionResultDB(ctx context.Context, q sqlx.QueryerContext, execID string) (*fleet.HostScriptResult, error) { const getStmt = ` SELECT id, @@ -106,14 +141,16 @@ func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID st output, runtime, exit_code, - created_at + created_at, + user_id, + sync_request FROM host_script_results WHERE execution_id = ?` var result fleet.HostScriptResult - if err := sqlx.GetContext(ctx, ds.reader(ctx), &result, getStmt, execID); err != nil { + if err := sqlx.GetContext(ctx, q, &result, getStmt, execID); err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("HostScriptResult").WithName(execID)) } @@ -280,7 +317,7 @@ FROM LEFT JOIN ( SELECT id, - host_id, + host_id, script_id, execution_id, created_at, diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 9e01be2750..da3316459c 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -9,6 +9,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -39,7 +40,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { ctx := context.Background() // no script saved yet - pending, err := ds.ListPendingHostScriptExecutions(ctx, 1, time.Second) + pending, err := ds.ListPendingHostScriptExecutions(ctx, 1) require.NoError(t, err) require.Empty(t, pending) @@ -48,10 +49,13 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { var nfe *notFoundError require.ErrorAs(t, err, &nfe) - // create a createdScript execution request + // create a createdScript execution request (with a user) + u := test.NewUser(t, ds, "Bob", "bob@example.com", true) createdScript, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ HostID: 1, ScriptContents: "echo", + UserID: &u.ID, + SyncRequest: true, }) require.NoError(t, err) require.NotZero(t, createdScript.ID) @@ -61,21 +65,18 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { require.Equal(t, "echo", createdScript.ScriptContents) require.Nil(t, createdScript.ExitCode) require.Empty(t, createdScript.Output) + require.NotNil(t, createdScript.UserID) + require.Equal(t, u.ID, *createdScript.UserID) + require.True(t, createdScript.SyncRequest) // the script execution is now listed as pending for this host - pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second) + pending, err = ds.ListPendingHostScriptExecutions(ctx, 1) require.NoError(t, err) require.Len(t, pending, 1) require.Equal(t, createdScript.ID, pending[0].ID) - // waiting for a second and an ignore of 0s ignores this script - time.Sleep(time.Second) - pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 0) - require.NoError(t, err) - require.Empty(t, pending) - // record a result for this execution - err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: 1, ExecutionID: createdScript.ExecutionID, Output: "foo", @@ -85,7 +86,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { require.NoError(t, err) // it is not pending anymore - pending, err = ds.ListPendingHostScriptExecutions(ctx, 1, 10*time.Second) + pending, err = ds.ListPendingHostScriptExecutions(ctx, 1) require.NoError(t, err) require.Empty(t, pending) @@ -98,7 +99,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { expectScript.ExitCode = ptr.Int64(0) require.Equal(t, &expectScript, script) - // create another script execution request + // create another script execution request (null user id this time) createdScript, err = ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ HostID: 1, ScriptContents: "echo2", @@ -106,6 +107,8 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { require.NoError(t, err) require.NotZero(t, createdScript.ID) require.NotEmpty(t, createdScript.ExecutionID) + require.Nil(t, createdScript.UserID) + require.False(t, createdScript.SyncRequest) // the script result can be retrieved even if it has no result yet script, err = ds.GetHostScriptExecutionResult(ctx, createdScript.ExecutionID) @@ -135,7 +138,7 @@ func testHostScriptResult(t *testing.T, ds *Datastore) { strings.Repeat("j", 1000) + strings.Repeat("k", 1000) - err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ HostID: 1, ExecutionID: createdScript.ExecutionID, Output: largeOutput, @@ -385,9 +388,9 @@ func testGetHostScriptDetails(t *testing.T, ds *Datastore) { insertResults := func(t *testing.T, hostID uint, script *fleet.Script, createdAt time.Time, execID string, exitCode *int64) { stmt := ` -INSERT INTO - host_script_results (%s host_id, created_at, execution_id, exit_code, script_contents, output) -VALUES +INSERT INTO + host_script_results (%s host_id, created_at, execution_id, exit_code, script_contents, output) +VALUES (%s ?,?,?,?,?,?)` args := []interface{}{} @@ -535,6 +538,13 @@ VALUES require.Len(t, res, 1) require.Equal(t, "script-6.ps1", res[0].Name) }) + + t.Run("can check if pending host script results exist", func(t *testing.T) { + insertResults(t, 42, scripts[2], now.Add(-2*time.Minute), "execution-3-4", nil) + r, err := ds.IsExecutionPendingForHost(ctx, 42, scripts[2].ID) + require.NoError(t, err) + require.Len(t, r, 1) + }) } func testBatchSetScripts(t *testing.T, ds *Datastore) { diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 2bdcfd91ed..7cda00fef0 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -89,6 +89,13 @@ type ActivityDetails interface { Documentation() (activity string, details string, detailsExample string) } +// ActivityHosts is the optional additional interface that can be implemented +// by activities that are related to hosts. +type ActivityHosts interface { + ActivityDetails + HostIDs() []uint +} + type ActivityTypeCreatedPack struct { ID uint `json:"pack_id"` Name string `json:"pack_name"` @@ -493,7 +500,17 @@ func (a ActivityTypeUserAddedBySSO) Documentation() (activity string, details st type Activity struct { CreateTimestamp - ID uint `json:"id" db:"id"` + + // ID is the activity id in the activities table, it is omitted for upcoming + // activities as those are "virtual activities" generated from entries in + // queues (e.g. pending host_script_results). + ID uint `json:"id,omitempty" db:"id"` + + // UUID is the activity UUID for the upcoming activities, as identified in + // the relevant queue (e.g. pending host_script_results). It is omitted for + // past activities as those are "real activities" with an activity id. + UUID string `json:"uuid,omitempty" db:"uuid"` + ActorFullName *string `json:"actor_full_name,omitempty" db:"name"` ActorID *uint `json:"actor_id,omitempty" db:"user_id"` ActorGravatar *string `json:"actor_gravatar,omitempty" db:"gravatar_url"` @@ -1074,6 +1091,7 @@ type ActivityTypeRanScript struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` ScriptExecutionID string `json:"script_execution_id"` + ScriptName string `json:"script_name"` Async bool `json:"async"` } @@ -1081,15 +1099,21 @@ func (a ActivityTypeRanScript) ActivityName() string { return "ran_script" } +func (a ActivityTypeRanScript) HostIDs() []uint { + return []uint{a.HostID} +} + func (a ActivityTypeRanScript) Documentation() (activity, details, detailsExample string) { return `Generated when a script is sent to be run for a host.`, `This activity contains the following fields: - "host_id": ID of the host. - "host_display_name": Display name of the host. - "script_execution_id": Execution ID of the script run. +- "script_name": Name of the script (empty if it was an anonymous script). - "async": Whether the script was executed asynchronously.`, `{ "host_id": 1, "host_display_name": "Anna's MacBook Pro", + "script_name": "set-timezones.sh", "script_execution_id": "d6cffa75-b5b5-41ef-9230-15073c8a88cf", "async": false }` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 2bda2cb3f9..32bb011677 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -537,6 +537,9 @@ type Datastore interface { NewActivity(ctx context.Context, user *User, activity ActivityDetails) error ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error) MarkActivitiesAsStreamed(ctx context.Context, activityIDs []uint) error + ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error) + ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error) + IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) /////////////////////////////////////////////////////////////////////////////// // StatisticsStore @@ -1213,18 +1216,19 @@ type Datastore interface { // NewHostScriptExecutionRequest creates a new host script result entry with // just the script to run information (result is not yet available). NewHostScriptExecutionRequest(ctx context.Context, request *HostScriptRequestPayload) (*HostScriptResult, error) - // SetHostScriptExecutionResult stores the result of a host script execution. - SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) error + // SetHostScriptExecutionResult stores the result of a host script execution + // and returns the updated host script result record. Note that it does not + // fail if the script execution request does not exist, in this case it will + // return nil, nil. + SetHostScriptExecutionResult(ctx context.Context, result *HostScriptResultPayload) (*HostScriptResult, error) // GetHostScriptExecutionResult returns the result of a host script // execution. It returns the host script results even if no results have been // received, it is the caller's responsibility to check if that was the case // (with ExitCode being null). GetHostScriptExecutionResult(ctx context.Context, execID string) (*HostScriptResult, error) // ListPendingHostScriptExecutions returns all the pending host script - // executions, which are those that have yet to record a result. Entries - // older than the ignoreOlder duration are ignored, considered too old to be - // pending. - ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*HostScriptResult, error) + // executions, which are those that have yet to record a result. + ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*HostScriptResult, error) // NewScript creates a new saved script. NewScript(ctx context.Context, script *Script) (*Script, error) diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 0b7913e88e..a2887d73b9 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -135,6 +135,12 @@ type HostScriptRequestPayload struct { HostID uint `json:"host_id"` ScriptID *uint `json:"script_id"` ScriptContents string `json:"script_contents"` + // UserID is filled automatically from the context's user (the authenticated + // user that made the API request). + UserID *uint `json:"-"` + // SyncRequest is filled automatically based on the endpoint used to create + // the execution request (synchronous or asynchronous). + SyncRequest bool `json:"-"` } type HostScriptResultPayload struct { @@ -173,6 +179,15 @@ type HostScriptResult struct { // ScriptID is the id of the saved script to execute, or nil if this was an // anonymous script execution. ScriptID *uint `json:"script_id" db:"script_id"` + // UserID is the id of the user that requested execution. It is not part of + // the rendered JSON as it is only returned by the + // /hosts/:id/activities/upcoming endpoint which doesn't use this struct as + // return type. + UserID *uint `json:"-" db:"user_id"` + // SyncRequest is used to determine when creating the script ran activity if + // the request was synchronous or asynchronous. It is otherwise not returned + // as part of any API endpoint. + SyncRequest bool `json:"-" db:"sync_request"` // TeamID is only used for authorization, it must be set to the team id of // the host when checking authorization and is otherwise not set. diff --git a/server/fleet/service.go b/server/fleet/service.go index dab1465d92..31b01bbfae 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -527,12 +527,21 @@ type Service interface { // What we call "Activities" are administrative operations, // logins, running a live query, etc. NewActivity(ctx context.Context, user *User, activity ActivityDetails) error + // ListActivities lists the activities stored in the datastore. // // What we call "Activities" are administrative operations, // logins, running a live query, etc. ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error) + // ListHostUpcomingActivities lists the upcoming activities for the specified + // host. Those are activities that are queued or scheduled to run on the host + // but haven't run yet. + ListHostUpcomingActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error) + + // ListHostPastActivities lists the activities that have already happened for the specified host. + ListHostPastActivities(ctx context.Context, hostID uint, opt ListOptions) ([]*Activity, *PaginationMetadata, error) + // ///////////////////////////////////////////////////////////////////////////// // UserRolesService diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 9bd0058726..d1ce400a67 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -394,6 +394,12 @@ type ListActivitiesFunc func(ctx context.Context, opt fleet.ListActivitiesOption type MarkActivitiesAsStreamedFunc func(ctx context.Context, activityIDs []uint) error +type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) + +type ListHostPastActivitiesFunc func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) + +type IsExecutionPendingForHostFunc func(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) + type ShouldSendStatisticsFunc func(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) type RecordStatisticsSentFunc func(ctx context.Context) error @@ -776,11 +782,11 @@ type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles [ type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) -type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) error +type SetHostScriptExecutionResultFunc func(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) type GetHostScriptExecutionResultFunc func(ctx context.Context, execID string) (*fleet.HostScriptResult, error) -type ListPendingHostScriptExecutionsFunc func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) +type ListPendingHostScriptExecutionsFunc func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) type NewScriptFunc func(ctx context.Context, script *fleet.Script) (*fleet.Script, error) @@ -1361,6 +1367,15 @@ type DataStore struct { MarkActivitiesAsStreamedFunc MarkActivitiesAsStreamedFunc MarkActivitiesAsStreamedFuncInvoked bool + ListHostUpcomingActivitiesFunc ListHostUpcomingActivitiesFunc + ListHostUpcomingActivitiesFuncInvoked bool + + ListHostPastActivitiesFunc ListHostPastActivitiesFunc + ListHostPastActivitiesFuncInvoked bool + + IsExecutionPendingForHostFunc IsExecutionPendingForHostFunc + IsExecutionPendingForHostFuncInvoked bool + ShouldSendStatisticsFunc ShouldSendStatisticsFunc ShouldSendStatisticsFuncInvoked bool @@ -3283,6 +3298,27 @@ func (s *DataStore) MarkActivitiesAsStreamed(ctx context.Context, activityIDs [] return s.MarkActivitiesAsStreamedFunc(ctx, activityIDs) } +func (s *DataStore) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.ListHostUpcomingActivitiesFuncInvoked = true + s.mu.Unlock() + return s.ListHostUpcomingActivitiesFunc(ctx, hostID, opt) +} + +func (s *DataStore) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + s.mu.Lock() + s.ListHostPastActivitiesFuncInvoked = true + s.mu.Unlock() + return s.ListHostPastActivitiesFunc(ctx, hostID, opt) +} + +func (s *DataStore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) { + s.mu.Lock() + s.IsExecutionPendingForHostFuncInvoked = true + s.mu.Unlock() + return s.IsExecutionPendingForHostFunc(ctx, hostID, scriptID) +} + func (s *DataStore) ShouldSendStatistics(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) { s.mu.Lock() s.ShouldSendStatisticsFuncInvoked = true @@ -4620,7 +4656,7 @@ func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request * return s.NewHostScriptExecutionRequestFunc(ctx, request) } -func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { +func (s *DataStore) SetHostScriptExecutionResult(ctx context.Context, result *fleet.HostScriptResultPayload) (*fleet.HostScriptResult, error) { s.mu.Lock() s.SetHostScriptExecutionResultFuncInvoked = true s.mu.Unlock() @@ -4634,11 +4670,11 @@ func (s *DataStore) GetHostScriptExecutionResult(ctx context.Context, execID str return s.GetHostScriptExecutionResultFunc(ctx, execID) } -func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { +func (s *DataStore) ListPendingHostScriptExecutions(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { s.mu.Lock() s.ListPendingHostScriptExecutionsFuncInvoked = true s.mu.Unlock() - return s.ListPendingHostScriptExecutionsFunc(ctx, hostID, ignoreOlder) + return s.ListPendingHostScriptExecutionsFunc(ctx, hostID) } func (s *DataStore) NewScript(ctx context.Context, script *fleet.Script) (*fleet.Script, error) { diff --git a/server/service/activities.go b/server/service/activities.go index 4c1eaf634f..c6da3774d8 100644 --- a/server/service/activities.go +++ b/server/service/activities.go @@ -3,6 +3,7 @@ package service import ( "context" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" ) @@ -45,3 +46,99 @@ func (svc *Service) ListActivities(ctx context.Context, opt fleet.ListActivities func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return svc.ds.NewActivity(ctx, user, activity) } + +//////////////////////////////////////////////////////////////////////////////// +// List host upcoming activities +//////////////////////////////////////////////////////////////////////////////// + +type listHostUpcomingActivitiesRequest struct { + HostID uint `url:"id"` + ListOptions fleet.ListOptions `url:"list_options"` +} + +func listHostUpcomingActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*listHostUpcomingActivitiesRequest) + acts, meta, err := svc.ListHostUpcomingActivities(ctx, req.HostID, req.ListOptions) + if err != nil { + return listActivitiesResponse{Err: err}, nil + } + + return listActivitiesResponse{Meta: meta, Activities: acts}, nil +} + +// ListHostUpcomingActivities returns a slice of upcoming activities for the +// specified host. +func (svc *Service) ListHostUpcomingActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + // First ensure the user has access to list hosts, then check the specific + // host once team_id is loaded. + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return nil, nil, err + } + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get host") + } + // Authorize again with team loaded now that we have team_id + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { + return nil, nil, err + } + + // cursor-based pagination is not supported for upcoming activities + opt.After = "" + // custom ordering is not supported, always by date (oldest first) + opt.OrderKey = "created_at" + opt.OrderDirection = fleet.OrderAscending + // no matching query support + opt.MatchQuery = "" + // always include metadata + opt.IncludeMetadata = true + + return svc.ds.ListHostUpcomingActivities(ctx, hostID, opt) +} + +//////////////////////////////////////////////////////////////////////////////// +// List host past activities +//////////////////////////////////////////////////////////////////////////////// + +type listHostPastActivitiesRequest struct { + HostID uint `url:"id"` + ListOptions fleet.ListOptions `url:"list_options"` +} + +func listHostPastActivitiesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*listHostPastActivitiesRequest) + acts, meta, err := svc.ListHostPastActivities(ctx, req.HostID, req.ListOptions) + if err != nil { + return listActivitiesResponse{Err: err}, nil + } + + return &listActivitiesResponse{Meta: meta, Activities: acts}, nil +} + +func (svc *Service) ListHostPastActivities(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + // First ensure the user has access to list hosts, then check the specific + // host once team_id is loaded. + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return nil, nil, err + } + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get host") + } + // Authorize again with team loaded now that we have team_id + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { + return nil, nil, err + } + + // cursor-based pagination is not supported for past activities + opt.After = "" + // custom ordering is not supported, always by date (oldest first) + opt.OrderKey = "created_at" + opt.OrderDirection = fleet.OrderDescending + // no matching query support + opt.MatchQuery = "" + // always include metadata + opt.IncludeMetadata = true + + return svc.ds.ListHostPastActivities(ctx, hostID, opt) +} diff --git a/server/service/handler.go b/server/service/handler.go index 08b9c23c9c..952412013c 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -467,6 +467,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/scripts/batch", batchSetScriptsEndpoint, batchSetScriptsRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/scripts", getHostScriptDetailsEndpoint, getHostScriptDetailsRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities/upcoming", listHostUpcomingActivitiesEndpoint, listHostUpcomingActivitiesRequest{}) + ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, listHostPastActivitiesRequest{}) // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 96ecc97e52..d60255288a 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -585,6 +585,9 @@ func TestHostAuth(t *testing.T) { ds.SetOrUpdateCustomHostDeviceMappingFunc = func(ctx context.Context, hostID uint, email, source string) ([]*fleet.HostDeviceMapping, error) { return nil, nil } + ds.ListHostUpcomingActivitiesFunc = func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { + return nil, nil, nil + } testCases := []struct { name string @@ -684,6 +687,9 @@ func TestHostAuth(t *testing.T) { _, err = svc.HostByIdentifier(ctx, "1", opts) checkAuthErr(t, tt.shouldFailTeamRead, err) + _, _, err = svc.ListHostUpcomingActivities(ctx, 1, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailTeamRead, err) + _, err = svc.GetHost(ctx, 2, opts) checkAuthErr(t, tt.shouldFailGlobalRead, err) @@ -693,6 +699,9 @@ func TestHostAuth(t *testing.T) { _, err = svc.HostByIdentifier(ctx, "2", opts) checkAuthErr(t, tt.shouldFailGlobalRead, err) + _, _, err = svc.ListHostUpcomingActivities(ctx, 2, fleet.ListOptions{}) + checkAuthErr(t, tt.shouldFailGlobalRead, err) + err = svc.DeleteHost(ctx, 1) checkAuthErr(t, tt.shouldFailTeamWrite, err) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index f766c04a38..72b6345c96 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5332,41 +5332,6 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { createHostAndDeviceToken(t, s.ds, "some-token") s.Do("POST", fmt.Sprintf("/api/v1/fleet/device/%s/migrate_mdm", "some-token"), nil, http.StatusPaymentRequired) - // run a script - var runResp runScriptResponse - s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusPaymentRequired, &runResp) - - // run a script sync - s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusPaymentRequired, &runResp) - - // get script result - var scriptResultResp getScriptResultResponse - s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusPaymentRequired, &scriptResultResp) - - // create a saved script - body, headers := generateNewScriptMultipartRequest(t, - "myscript.sh", []byte(`echo "hello"`), s.token, nil) - s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusPaymentRequired, headers) - - // delete a saved script - var delScriptResp deleteScriptResponse - s.DoJSON("DELETE", "/api/latest/fleet/scripts/123", nil, http.StatusPaymentRequired, &delScriptResp) - - // list saved scripts - var listScriptsResp listScriptsResponse - s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusPaymentRequired, &listScriptsResp, "per_page", "10") - - // get a saved script - var getScriptResp getScriptResponse - s.DoJSON("GET", "/api/latest/fleet/scripts/123", nil, http.StatusPaymentRequired, &getScriptResp) - - // get host script details - var getHostScriptDetailsResp getHostScriptDetailsResponse - s.DoJSON("GET", "/api/latest/fleet/hosts/123/scripts", nil, http.StatusPaymentRequired, &getHostScriptDetailsResp) - - // batch set scripts - s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, http.StatusPaymentRequired) - // software titles // a normal request works fine var resp listSoftwareTitlesResponse @@ -5386,6 +5351,48 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { ) } +func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() { + t := s.T() + + // this is just checking that the endpoints do not fail with "no license", the actual tests + // for scripts endpoints are in the enterprise integrations tests. + + // run a script + var runResp runScriptResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusNotFound, &runResp) + + // run a script sync + s.DoJSON("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: 1}, http.StatusNotFound, &runResp) + + // get script result + var scriptResultResp getScriptResultResponse + s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusNotFound, &scriptResultResp) + + // create a saved script + body, headers := generateNewScriptMultipartRequest(t, + "myscript.sh", []byte(`echo "hello"`), s.token, nil) + s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) + + // delete a saved script + var delScriptResp deleteScriptResponse + s.DoJSON("DELETE", "/api/latest/fleet/scripts/123", nil, http.StatusNotFound, &delScriptResp) + + // list saved scripts + var listScriptsResp listScriptsResponse + s.DoJSON("GET", "/api/latest/fleet/scripts", nil, http.StatusOK, &listScriptsResp, "per_page", "10") + + // get a saved script + var getScriptResp getScriptResponse + s.DoJSON("GET", "/api/latest/fleet/scripts/123", nil, http.StatusNotFound, &getScriptResp) + + // get host script details + var getHostScriptDetailsResp getHostScriptDetailsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts/123/scripts", nil, http.StatusNotFound, &getHostScriptDetailsResp) + + // batch set scripts + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, http.StatusNoContent) +} + // TestGlobalPoliciesBrowsing tests that team users can browse (read) global policies (see #3722). func (s *integrationTestSuite) TestGlobalPoliciesBrowsing() { t := s.T() @@ -7604,9 +7611,9 @@ func (s *integrationTestSuite) TestOrbitConfigNotifications() { s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusOK, &resp) require.False(t, resp.Notifications.RenewEnrollmentProfile) - // the scripts orbit endpoints are premium-only - s.Do("POST", "/api/fleet/orbit/scripts/request", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusPaymentRequired) - s.Do("POST", "/api/fleet/orbit/scripts/result", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusPaymentRequired) + // the scripts orbit endpoints are accessible without license + s.Do("POST", "/api/fleet/orbit/scripts/request", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusNotFound) + s.Do("POST", "/api/fleet/orbit/scripts/result", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *hFleetMDM.OrbitNodeKey)), http.StatusBadRequest) } func (s *integrationTestSuite) TestTryingToEnrollWithTheWrongSecret() { @@ -9642,3 +9649,200 @@ func (s *integrationTestSuite) TestHostDeviceToken() { } s.DoJSON("POST", "/api/fleet/orbit/device_token", body, http.StatusConflict, &response{}) } + +func (s *integrationTestSuite) TestHostPastActivities() { + t := s.T() + ctx := context.Background() + user := s.users["admin1@example.com"] + getDetails := func(a *fleet.Activity) fleet.ActivityTypeRanScript { + var details fleet.ActivityTypeRanScript + err := json.Unmarshal([]byte(*a.Details), &details) + require.NoError(t, err) + + return details + } + + host := createOrbitEnrolledHost(t, "linux", "", s.ds) + err := s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now()) + require.NoError(t, err) + + // create a valid script execution request + savedScript, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: nil, + Name: "saved.sh", + ScriptContents: "echo 'hello world'", + }) + require.NoError(t, err) + + var runResp runScriptResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedScript.ID}, http.StatusAccepted, &runResp) + require.Equal(t, host.ID, runResp.HostID) + require.NotEmpty(t, runResp.ExecutionID) + + execID1 := runResp.ExecutionID + + result, err := s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID) + require.NoError(t, err) + require.Equal(t, host.ID, result.HostID) + require.Equal(t, "echo 'hello world'", result.ScriptContents) + require.Nil(t, result.ExitCode) + + var orbitPostScriptResp orbitPostScriptResultResponse + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, result.ExecutionID)), + http.StatusOK, &orbitPostScriptResp) + + var listResp listActivitiesResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &listResp) + + require.Len(t, listResp.Activities, 1) + require.Equal(t, user.Email, *listResp.Activities[0].ActorEmail) + require.Equal(t, user.Name, *listResp.Activities[0].ActorFullName) + require.Equal(t, user.GravatarURL, *listResp.Activities[0].ActorGravatar) + require.Equal(t, "ran_script", *&listResp.Activities[0].Type) + d := getDetails(listResp.Activities[0]) + require.Equal(t, execID1, d.ScriptExecutionID) + require.Equal(t, savedScript.Name, d.ScriptName) + require.Equal(t, host.DisplayName(), d.HostDisplayName) + require.Equal(t, host.ID, d.HostID) + require.Equal(t, true, d.Async) + + // sleep to have the created_at timestamps differ + time.Sleep(time.Second) + + // Execute another script in order to test query params + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo 'foobar'"}, http.StatusAccepted, &runResp) + require.Equal(t, host.ID, runResp.HostID) + require.NotEmpty(t, runResp.ExecutionID) + + execID2 := runResp.ExecutionID + + result, err = s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID) + require.NoError(t, err) + require.Equal(t, host.ID, result.HostID) + require.Equal(t, "echo 'foobar'", result.ScriptContents) + require.Nil(t, result.ExitCode) + + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, result.ExecutionID)), + http.StatusOK, &orbitPostScriptResp) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &listResp, "page", "0", "per_page", "1") + + require.Len(t, listResp.Activities, 1) + d = getDetails(listResp.Activities[0]) + + require.Equal(t, execID2, d.ScriptExecutionID) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host.ID), nil, http.StatusOK, &listResp, "page", "1", "per_page", "1") + + require.Len(t, listResp.Activities, 1) + d = getDetails(listResp.Activities[0]) + require.Equal(t, execID1, d.ScriptExecutionID) +} + +func (s *integrationTestSuite) TestListHostUpcomingActivities() { + t := s.T() + ctx := context.Background() + + // there is already a datastore-layer test that verifies that correct values + // are returned for users, saved scripts, etc. so this is more focused on + // verifying that the service layer passes the proper options and the + // rendering of the response. + + host1, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name()), + NodeKey: ptr.String(t.Name()), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo.local", t.Name()), + Platform: "darwin", + }) + require.NoError(t, err) + + hsr, err := s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "A"}) + require.NoError(t, err) + h1A := hsr.ExecutionID + hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "B"}) + require.NoError(t, err) + h1B := hsr.ExecutionID + hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "C"}) + require.NoError(t, err) + h1C := hsr.ExecutionID + hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "D"}) + require.NoError(t, err) + h1D := hsr.ExecutionID + hsr, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host1.ID, ScriptContents: "E"}) + require.NoError(t, err) + h1E := hsr.ExecutionID + + cases := []struct { + queries []string // alternate query name and value + wantExecs []string + wantMeta *fleet.PaginationMetadata + }{ + { + wantExecs: []string{h1A, h1B, h1C, h1D, h1E}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2"}, + wantExecs: []string{h1A, h1B}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2", "page", "1"}, + wantExecs: []string{h1C, h1D}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "2", "page", "2"}, + wantExecs: []string{h1E}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3"}, + wantExecs: []string{h1A, h1B, h1C}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "3", "page", "1"}, + wantExecs: []string{h1D, h1E}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3", "page", "2"}, + wantExecs: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%#v", c.queries), func(t *testing.T) { + var listResp listActivitiesResponse + queryArgs := c.queries + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host1.ID), nil, http.StatusOK, &listResp, queryArgs...) + + require.Equal(t, len(c.wantExecs), len(listResp.Activities)) + require.Equal(t, c.wantMeta, listResp.Meta) + + var gotExecs []string + if len(listResp.Activities) > 0 { + gotExecs = make([]string, len(listResp.Activities)) + for i, a := range listResp.Activities { + require.Zero(t, a.ID) + require.NotEmpty(t, a.UUID) + require.Equal(t, fleet.ActivityTypeRanScript{}.ActivityName(), a.Type) + + var details map[string]any + require.NotNil(t, a.Details) + require.NoError(t, json.Unmarshal(*a.Details, &details)) + gotExecs[i] = details["script_execution_id"].(string) + } + } + require.Equal(t, c.wantExecs, gotExecs) + }) + } +} diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 96e436377a..c743917a0a 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -4449,16 +4449,6 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { require.Equal(t, host.ID, runResp.HostID) require.NotEmpty(t, runResp.ExecutionID) - // an activity was created for the async script execution - s.lastActivityMatches( - fleet.ActivityTypeRanScript{}.ActivityName(), - fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": true}`, - host.ID, host.DisplayName(), runResp.ExecutionID, - ), - 0, - ) - result, err := s.ds.GetHostScriptExecutionResult(ctx, runResp.ExecutionID) require.NoError(t, err) require.Equal(t, host.ID, result.HostID) @@ -4588,7 +4578,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { resultsCh := make(chan *fleet.HostScriptResultPayload, 1) go func() { for range time.Tick(300 * time.Millisecond) { - pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID, 10*time.Second) + pending, err := s.ds.ListPendingHostScriptExecutions(ctx, host.ID) if err != nil { t.Log(err) return @@ -4600,7 +4590,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { case r := <-resultsCh: r.ExecutionID = pending[0].ExecutionID // ignoring errors in this goroutine, the HTTP request below will fail if this fails - err = s.ds.SetHostScriptExecutionResult(ctx, r) + _, err = s.ds.SetHostScriptExecutionResult(ctx, r) if err != nil { t.Log(err) } @@ -4625,16 +4615,6 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { require.Equal(t, int64(0), *runSyncResp.ExitCode) require.False(t, runSyncResp.HostTimeout) - // an activity was created for the sync script execution - s.lastActivityMatches( - fleet.ActivityTypeRanScript{}.ActivityName(), - fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": false}`, - host.ID, host.DisplayName(), runSyncResp.ExecutionID, - ), - 0, - ) - // simulate a scripts disabled result resultsCh <- &fleet.HostScriptResultPayload{ HostID: host.ID, @@ -4662,11 +4642,8 @@ func (s *integrationEnterpriseTestSuite) TestRunHostScript() { errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, fleet.RunScriptHostOfflineErrMsg) - // attempt to create an async script execution request, fails because the host - // is offline. - res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusUnprocessableEntity) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, fleet.RunScriptHostOfflineErrMsg) + // attempt to create an async script execution request, succeeds because script is added to queue. + res = s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptContents: "echo"}, http.StatusAccepted) } func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { @@ -4725,16 +4702,6 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { require.Equal(t, host.ID, runResp.HostID) require.NotEmpty(t, runResp.ExecutionID) - // an activity was created for the async script execution - s.lastActivityMatches( - fleet.ActivityTypeRanScript{}.ActivityName(), - fmt.Sprintf( - `{"host_id": %d, "host_display_name": %q, "script_execution_id": %q, "async": true}`, - host.ID, host.DisplayName(), runResp.ExecutionID, - ), - 0, - ) - var scriptResultResp getScriptResultResponse s.DoJSON("GET", "/api/latest/fleet/scripts/results/"+runResp.ExecutionID, nil, http.StatusOK, &scriptResultResp) require.Equal(t, host.ID, scriptResultResp.HostID) @@ -4765,18 +4732,22 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now()) require.NoError(t, err) - // attempt to create a valid sync script execution request, fails because the - // host has a pending script execution - res = s.Do("POST", "/api/latest/fleet/scripts/run/sync", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &savedNoTmScript.ID}, http.StatusConflict) - errMsg = extractServerErrorText(res.Body) - require.Contains(t, errMsg, fleet.RunScriptAlreadyRunningErrMsg) - // save a result via the orbit endpoint var orbitPostScriptResp orbitPostScriptResultResponse s.DoJSON("POST", "/api/fleet/orbit/scripts/result", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, scriptResultResp.ExecutionID)), http.StatusOK, &orbitPostScriptResp) + // an activity was created for the script execution + s.lastActivityMatches( + fleet.ActivityTypeRanScript{}.ActivityName(), + fmt.Sprintf( + `{"host_id": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true}`, + host.ID, host.DisplayName(), savedNoTmScript.Name, scriptResultResp.ExecutionID, + ), + 0, + ) + // verify that orbit does not receive any pending script anymore orbitResp = orbitGetConfigResponse{} s.DoJSON("POST", "/api/fleet/orbit/config", @@ -4808,6 +4779,59 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() { require.False(t, scriptResultResp.HostTimeout) require.Contains(t, scriptResultResp.Message, fleet.RunScriptAlreadyRunningErrMsg) require.Nil(t, scriptResultResp.ScriptID) + + // Verify that we can't enqueue more than 1k scripts + + // Make the host offline so that scripts enqueue + err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour)) + require.NoError(t, err) + for i := 0; i < 1000; i++ { + script, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: nil, + Name: fmt.Sprintf("script_1k_%d.sh", i), + ScriptContents: fmt.Sprintf("echo %d", i), + }) + require.NoError(t, err) + + _, err = s.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}) + require.NoError(t, err) + } + + script, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: nil, + Name: "script_1k_1000.sh", + ScriptContents: "echo 1000", + }) + require.NoError(t, err) + + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusConflict, &runResp) +} + +func (s *integrationEnterpriseTestSuite) TestEnqueueSameScriptTwice() { + t := s.T() + ctx := context.Background() + + host := createOrbitEnrolledHost(t, "linux", "", s.ds) + script, err := s.ds.NewScript(ctx, &fleet.Script{ + TeamID: nil, + Name: "script.sh", + ScriptContents: "echo 'hi from script'", + }) + require.NoError(t, err) + + // Make the host offline so that scripts enqueue + err = s.ds.MarkHostsSeen(ctx, []uint{host.ID}, time.Now().Add(-time.Hour)) + require.NoError(t, err) + + var runResp runScriptResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusAccepted, &runResp) + require.Equal(t, host.ID, runResp.HostID) + require.NotEmpty(t, runResp.ExecutionID) + + // Should fail because the same script is already enqueued for this host. + resp := s.Do("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host.ID, ScriptID: &script.ID}, http.StatusConflict) + errorMsg := extractServerErrorText(resp.Body) + require.Contains(t, errorMsg, "The script is already queued on the given host") } func (s *integrationEnterpriseTestSuite) TestOrbitConfigExtensions() { diff --git a/server/service/orbit.go b/server/service/orbit.go index 758ed2fae4..91fb990907 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" - "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" @@ -231,7 +230,7 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro // it is important that the "ignoreOlder" parameter in this call is the // same everywhere (which is here and in RunScript to check if there is // already a pending script). - pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, host.ID, scripts.MaxServerWaitTime) + pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, host.ID) if err != nil { return fleet.OrbitConfig{}, err } @@ -502,11 +501,24 @@ func getOrbitScriptEndpoint(ctx context.Context, request interface{}, svc fleet. } func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. + // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) - return nil, fleet.ErrMissingLicense + host, ok := hostctx.FromContext(ctx) + if !ok { + return nil, fleet.OrbitError{Message: "internal error: missing host from request context"} + } + + // get the script's details + script, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) + if err != nil { + return nil, err + } + // ensure it cannot get access to a different host's script + if script.HostID != host.ID { + return nil, ctxerr.Wrap(ctx, newNotFoundError(), "no script found for this host") + } + return script, nil } ///////////////////////////////////////////////////////////////////////////////// @@ -543,11 +555,55 @@ func postOrbitScriptResultEndpoint(ctx context.Context, request interface{}, svc } func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { - // skipauth: No authorization check needed due to implementation returning - // only license error. + // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) - return fleet.ErrMissingLicense + host, ok := hostctx.FromContext(ctx) + if !ok { + return fleet.OrbitError{Message: "internal error: missing host from request context"} + } + if result == nil { + return ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "missing script result"}, "save host script result") + } + + // always use the authenticated host's ID as host_id + result.HostID = host.ID + hsr, err := svc.ds.SetHostScriptExecutionResult(ctx, result) + if err != nil { + return ctxerr.Wrap(ctx, err, "save host script result") + } + + if hsr != nil { + var user *fleet.User + if hsr.UserID != nil { + user, err = svc.ds.UserByID(ctx, *hsr.UserID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host script execution user") + } + } + var scriptName string + if hsr.ScriptID != nil { + scr, err := svc.ds.Script(ctx, *hsr.ScriptID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get saved script") + } + scriptName = scr.Name + } + if err := svc.ds.NewActivity( + ctx, + user, + fleet.ActivityTypeRanScript{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + ScriptExecutionID: hsr.ExecutionID, + ScriptName: scriptName, + Async: !hsr.SyncRequest, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for script execution request") + } + } + return nil } ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go index 5900ace6d5..b294983de1 100644 --- a/server/service/orbit_test.go +++ b/server/service/orbit_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "testing" - "time" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/fleet" @@ -23,7 +22,7 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return appCfg, nil } - ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { return nil, nil } ctx = test.HostContext(ctx, &fleet.Host{ @@ -76,7 +75,7 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) { return ptr.RawMessage(json.RawMessage(`{}`)), nil } - ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { return nil, nil } @@ -127,7 +126,7 @@ func TestGetOrbitConfigNudge(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return appCfg, nil } - ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { return nil, nil } diff --git a/server/service/scripts.go b/server/service/scripts.go index f38c2e242c..d5641d27b2 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -13,9 +13,12 @@ import ( "github.com/docker/go-units" "github.com/fleetdm/fleet/v4/pkg/scripts" + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/go-kit/kit/log/level" ) //////////////////////////////////////////////////////////////////////////////// @@ -108,12 +111,172 @@ func runScriptSyncEndpoint(ctx context.Context, request interface{}, svc fleet.S }, nil } -func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) +const maxPendingScripts = 1000 - return nil, fleet.ErrMissingLicense +func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScriptRequestPayload, waitForResult time.Duration) (*fleet.HostScriptResult, error) { + // First check if scripts are disabled globally. If so, no need for further processing. + cfg, err := svc.ds.AppConfig(ctx) + if err != nil { + svc.authz.SkipAuthorization(ctx) + return nil, err + } + + if cfg.ServerSettings.ScriptsDisabled { + svc.authz.SkipAuthorization(ctx) + return nil, fleet.NewUserMessageError(errors.New(fleet.RunScriptScriptsDisabledGloballyErrMsg), http.StatusForbidden) + } + + // must load the host to get the team (cannot use lite, the last seen time is + // required to check if it is online) to authorize with the proper team id. + // We cannot first authorize if the user can list hosts, in case we + // eventually allow a write-only role (e.g. gitops). + host, err := svc.ds.Host(ctx, request.HostID) + if err != nil { + // if error is because the host does not exist, check first if the user + // had access to run a script (to prevent leaking valid host ids). + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionWrite); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get host lite") + } + + maxPending := maxPendingScripts + + // must check that only one of script id or contents is provided before + // authorization, as the permissions are not the same if a script id is + // provided. There's no harm in returning the error if this validation fails, + // since both values are user-provided it doesn't leak any internal + // information. + if request.ScriptID != nil && request.ScriptContents != "" { + svc.authz.SkipAuthorization(ctx) + return nil, fleet.NewInvalidArgumentError("script_id", `Only one of "script_id" or "script_contents" can be provided.`) + } + + // authorize with the host's team and the script id provided, as both affect + // the permissions. + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID, ScriptID: request.ScriptID}, fleet.ActionWrite); err != nil { + return nil, err + } + + if request.ScriptID != nil { + script, err := svc.ds.Script(ctx, *request.ScriptID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`). + WithStatus(http.StatusNotFound) + } + return nil, err + } + var scriptTmID, hostTmID uint + if script.TeamID != nil { + scriptTmID = *script.TeamID + } + if host.TeamID != nil { + hostTmID = *host.TeamID + } + if scriptTmID != hostTmID { + return nil, fleet.NewInvalidArgumentError("script_id", `The script does not belong to the same team (or no team) as the host.`) + } + + r, err := svc.ds.IsExecutionPendingForHost(ctx, request.HostID, *request.ScriptID) + if err != nil { + return nil, err + } + + if len(r) > 0 { + return nil, fleet.NewInvalidArgumentError("script_id", `The script is already queued on the given host.`).WithStatus(http.StatusConflict) + } + + contents, err := svc.ds.GetScriptContents(ctx, *request.ScriptID) + if err != nil { + if fleet.IsNotFound(err) { + return nil, fleet.NewInvalidArgumentError("script_id", `No script exists for the provided "script_id".`). + WithStatus(http.StatusNotFound) + } + return nil, err + } + request.ScriptContents = string(contents) + } + + if err := fleet.ValidateHostScriptContents(request.ScriptContents); err != nil { + return nil, fleet.NewInvalidArgumentError("script_contents", err.Error()) + } + + asyncExecution := waitForResult <= 0 + + if !asyncExecution && host.Status(time.Now()) != fleet.StatusOnline { + return nil, fleet.NewInvalidArgumentError("host_id", fleet.RunScriptHostOfflineErrMsg) + } + + pending, err := svc.ds.ListPendingHostScriptExecutions(ctx, request.HostID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list host pending script executions") + } + if len(pending) > maxPending { + return nil, fleet.NewInvalidArgumentError( + "script_id", "cannot queue more than 1000 scripts per host", + ).WithStatus(http.StatusConflict) + } + + if !asyncExecution && len(pending) > 0 { + return nil, fleet.NewInvalidArgumentError("script_id", fleet.RunScriptAlreadyRunningErrMsg).WithStatus(http.StatusConflict) + } + + // create the script execution request, the host will be notified of the + // script execution request via the orbit config's Notifications mechanism. + if ctxUser := authz.UserFromContext(ctx); ctxUser != nil { + request.UserID = &ctxUser.ID + } + request.SyncRequest = !asyncExecution + script, err := svc.ds.NewHostScriptExecutionRequest(ctx, request) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "create script execution request") + } + script.Hostname = host.DisplayName() + + if asyncExecution { + // async execution, return + return script, nil + } + + ctx, cancel := context.WithTimeout(ctx, waitForResult) + defer cancel() + + // if waiting for a result times out, we still want to return the script's + // execution request information along with the error, so that the caller can + // use the execution id for later checks. + timeoutResult := script + checkInterval := time.Second + after := time.NewTimer(checkInterval) + for { + select { + case <-ctx.Done(): + return timeoutResult, ctx.Err() + case <-after.C: + result, err := svc.ds.GetHostScriptExecutionResult(ctx, script.ExecutionID) + if err != nil { + // is that due to the context being canceled during the DB access? + if ctxErr := ctx.Err(); ctxErr != nil { + return timeoutResult, ctxErr + } + return nil, ctxerr.Wrap(ctx, err, "get script execution result") + } + if result.ExitCode != nil { + // a result was received from the host, return + result.Hostname = host.DisplayName() + return result, nil + } + + // at a second to every attempt, until it reaches 5s (then check every 5s) + if checkInterval < 5*time.Second { + checkInterval += time.Second + } + after.Reset(checkInterval) + } + } } // ////////////////////////////////////////////////////////////////////////////// @@ -167,11 +330,36 @@ func getScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet } func (svc *Service) GetScriptResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + scriptResult, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) + if err != nil { + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get script result") + } - return nil, fleet.ErrMissingLicense + host, err := svc.ds.HostLite(ctx, scriptResult.HostID) + if err != nil { + // if error is because the host does not exist, check first if the user + // had access to run a script (to prevent leaking valid host ids). + if fleet.IsNotFound(err) { + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{}, fleet.ActionRead); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get host lite") + } + if err := svc.authz.Authorize(ctx, &fleet.HostScriptResult{TeamID: host.TeamID}, fleet.ActionRead); err != nil { + return nil, err + } + + scriptResult.Hostname = host.DisplayName() + + return scriptResult, nil } //////////////////////////////////////////////////////////////////////////////// @@ -236,11 +424,60 @@ func createScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Se } func (svc *Service) NewScript(ctx context.Context, teamID *uint, name string, r io.Reader) (*fleet.Script, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { + return nil, err + } - return nil, fleet.ErrMissingLicense + b, err := io.ReadAll(r) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "read script contents") + } + + script := &fleet.Script{ + TeamID: teamID, + Name: name, + ScriptContents: string(b), + } + if err := script.Validate(); err != nil { + return nil, fleet.NewInvalidArgumentError("script", err.Error()) + } + + savedScript, err := svc.ds.NewScript(ctx, script) + if err != nil { + var ( + existsErr fleet.AlreadyExistsError + fkErr fleet.ForeignKeyError + ) + if errors.As(err, &existsErr) { + err = fleet.NewInvalidArgumentError("script", "A script with this name already exists.").WithStatus(http.StatusConflict) + } else if errors.As(err, &fkErr) { + err = fleet.NewInvalidArgumentError("team_id", "The team does not exist.").WithStatus(http.StatusNotFound) + } + return nil, ctxerr.Wrap(ctx, err, "create script") + } + + var teamName *string + if teamID != nil && *teamID != 0 { + tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, teamID, nil) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get team name for create script activity") + } + teamName = &tm.Name + } + + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeAddedScript{ + TeamID: teamID, + TeamName: teamName, + ScriptName: script.Name, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "new activity for create script") + } + + return savedScript, nil } //////////////////////////////////////////////////////////////////////////////// @@ -268,11 +505,37 @@ func deleteScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Se } func (svc *Service) DeleteScript(ctx context.Context, scriptID uint) error { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionWrite) + if err != nil { + return err + } - return fleet.ErrMissingLicense + if err := svc.ds.DeleteScript(ctx, script.ID); err != nil { + return ctxerr.Wrap(ctx, err, "delete script") + } + + var teamName *string + if script.TeamID != nil && *script.TeamID != 0 { + tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, script.TeamID, nil) + if err != nil { + return ctxerr.Wrap(ctx, err, "get team name for delete script activity") + } + teamName = &tm.Name + } + + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeDeletedScript{ + TeamID: script.TeamID, + TeamName: teamName, + ScriptName: script.Name, + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "new activity for delete script") + } + + return nil } //////////////////////////////////////////////////////////////////////////////// @@ -305,11 +568,21 @@ func listScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Ser } func (svc *Service) ListScripts(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.Script, *fleet.PaginationMetadata, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionRead); err != nil { + return nil, nil, err + } - return nil, nil, fleet.ErrMissingLicense + // cursor-based pagination is not supported for scripts + opt.After = "" + // custom ordering is not supported, always by name + opt.OrderKey = "name" + opt.OrderDirection = fleet.OrderAscending + // no matching query support + opt.MatchQuery = "" + // always include metadata for scripts + opt.IncludeMetadata = true + + return svc.ds.ListScripts(ctx, teamID, opt) } //////////////////////////////////////////////////////////////////////////////// @@ -374,11 +647,19 @@ func getScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Servi } func (svc *Service) GetScript(ctx context.Context, scriptID uint, withContent bool) (*fleet.Script, []byte, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + script, err := svc.authorizeScriptByID(ctx, scriptID, fleet.ActionRead) + if err != nil { + return nil, nil, err + } - return nil, nil, fleet.ErrMissingLicense + var content []byte + if withContent { + content, err = svc.ds.GetScriptContents(ctx, scriptID) + if err != nil { + return nil, nil, err + } + } + return script, content, nil } //////////////////////////////////////////////////////////////////////////////// @@ -411,11 +692,39 @@ func getHostScriptDetailsEndpoint(ctx context.Context, request interface{}, svc } func (svc *Service) GetHostScriptDetails(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + h, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + if fleet.IsNotFound(err) { + // if error is because the host does not exist, check first if the user + // had global access (to prevent leaking valid host ids). + if err := svc.authz.Authorize(ctx, &fleet.Script{}, fleet.ActionRead); err != nil { + return nil, nil, err + } + } + return nil, nil, err + } - return nil, nil, fleet.ErrMissingLicense + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: h.TeamID}, fleet.ActionRead); err != nil { + return nil, nil, err + } + + if h.Platform != "darwin" && h.Platform != "windows" { + // darwin and windows are supported for now, all other platforms return empty results + level.Debug(svc.logger).Log("msg", "unsupported platform for host script details", "platform", h.Platform, "host_id", h.ID) + return []*fleet.HostScriptDetail{}, &fleet.PaginationMetadata{}, nil + } + + // cursor-based pagination is not supported for scripts + opt.After = "" + // custom ordering is not supported, always by name + opt.OrderKey = "name" + opt.OrderDirection = fleet.OrderAscending + // no matching query support + opt.MatchQuery = "" + // always include metadata for scripts + opt.IncludeMetadata = true + + return svc.ds.GetHostScriptDetails(ctx, h.ID, h.TeamID, opt, h.Platform) } //////////////////////////////////////////////////////////////////////////////// @@ -446,9 +755,88 @@ func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet } func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error { - // skipauth: No authorization check needed due to implementation returning - // only license error. - svc.authz.SkipAuthorization(ctx) + if maybeTmID != nil && maybeTmName != nil { + svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden" + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name")) + } - return fleet.ErrMissingLicense + var teamID *uint + var teamName *string + + if maybeTmID != nil || maybeTmName != nil { + team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, maybeTmID, maybeTmName) + if err != nil { + return err + } + teamID = &team.ID + teamName = &team.Name + } + + if err := svc.authz.Authorize(ctx, &fleet.Script{TeamID: teamID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err) + } + + // any duplicate name in the provided set results in an error + scripts := make([]*fleet.Script, 0, len(payloads)) + byName := make(map[string]bool, len(payloads)) + for i, p := range payloads { + script := &fleet.Script{ + ScriptContents: string(p.ScriptContents), + Name: p.Name, + TeamID: teamID, + } + + if err := script.Validate(); err != nil { + return ctxerr.Wrap(ctx, + fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error())) + } + + if byName[script.Name] { + return ctxerr.Wrap(ctx, + fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), fmt.Sprintf("Couldn’t edit scripts. More than one script has the same file name: %q", script.Name)), + "duplicate script by name") + } + byName[script.Name] = true + scripts = append(scripts, script) + } + + if dryRun { + return nil + } + + if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil { + return ctxerr.Wrap(ctx, err, "batch saving scripts") + } + + if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedScript{ + TeamID: teamID, + TeamName: teamName, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for edited scripts") + } + return nil +} + +func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) { + // first, get the script because we don't know which team id it is for. + script, err := svc.ds.Script(ctx, scriptID) + if err != nil { + if fleet.IsNotFound(err) { + // couldn't get the script to have its team, authorize with a no-team + // script as a fallback - the requested script does not exist so there's + // no way to know what team it would be for, and returning a 404 without + // authorization would leak the existing/non existing ids. + if err := svc.authz.Authorize(ctx, &fleet.Script{}, authzAction); err != nil { + return nil, err + } + } + svc.authz.SkipAuthorization(ctx) + return nil, ctxerr.Wrap(ctx, err, "get script") + } + + // do the actual authorization with the script's team id + if err := svc.authz.Authorize(ctx, script, authzAction); err != nil { + return nil, err + } + return script, nil } diff --git a/server/service/scripts_test.go b/server/service/scripts_test.go index 5ab37390e8..d6f4ed63b2 100644 --- a/server/service/scripts_test.go +++ b/server/service/scripts_test.go @@ -50,19 +50,16 @@ func TestHostRunScript(t *testing.T) { ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { return &fleet.HostScriptResult{HostID: request.HostID, ScriptContents: request.ScriptContents, ExecutionID: "abc"}, nil } - ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint, ignoreOlder time.Duration) ([]*fleet.HostScriptResult, error) { + ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) { return nil, nil } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { - require.IsType(t, fleet.ActivityTypeRanScript{}, activity) - return nil - } ds.ScriptFunc = func(ctx context.Context, id uint) (*fleet.Script, error) { return &fleet.Script{ID: id}, nil } ds.GetScriptContentsFunc = func(ctx context.Context, id uint) ([]byte, error) { return []byte("echo"), nil } + ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hostID, scriptID uint) ([]*uint, error) { return nil, nil } t.Run("authorization checks", func(t *testing.T) { testCases := []struct {