Include the policy ID and name in the "script ran" activity of a script run queued by a policy failure (#22690)

#22692 

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] If database migrations are included, checked table schema to
confirm autoupdate
- For database migrations:
- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Confirmed that updating the timestamps is acceptable, and will not
cause unwanted side effects.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Ian Littman
2024-10-08 15:45:31 -05:00
committed by GitHub
parent 580dc13f92
commit 5339794f97
15 changed files with 176 additions and 24 deletions
+1
View File
@@ -0,0 +1 @@
* Record which policy automation triggered a script run in the activity feed
+5 -1
View File
@@ -887,6 +887,8 @@ This activity contains the following fields:
- "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.
- "policy_id": ID of the policy whose failure triggered the script run. Null if no associated policy.
- "policy_name": Name of the policy whose failure triggered the script run. Null if no associated policy.
#### Example
@@ -896,7 +898,9 @@ This activity contains the following fields:
"host_display_name": "Anna's MacBook Pro",
"script_name": "set-timezones.sh",
"script_execution_id": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
"async": false
"async": false,
"policy_id": 123,
"policy_name": "Ensure photon torpedoes are primed"
}
```
+12 -2
View File
@@ -15,6 +15,8 @@ import (
"github.com/jmoiron/sqlx"
)
var automationActivityAuthor = "Fleet"
// NewActivity stores an activity item that the user performed
func (ds *Datastore) NewActivity(
ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
@@ -39,6 +41,10 @@ func (ds *Datastore) NewActivity(
}
userName = &user.Name
userEmail = &user.Email
} else if ranScriptActivity, ok := activity.(fleet.ActivityTypeRanScript); ok {
if ranScriptActivity.PolicyID != nil {
userName = &automationActivityAuthor
}
}
cols := []string{"user_id", "user_name", "activity_type", "details", "created_at"}
@@ -293,7 +299,7 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
// list pending scripts
`SELECT
hsr.execution_id as uuid,
u.name as name,
IF(hsr.policy_id IS NOT NULL, 'Fleet', u.name) as name,
u.id as user_id,
u.gravatar_url as gravatar_url,
u.email as user_email,
@@ -304,12 +310,16 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
'host_display_name', COALESCE(hdn.display_name, ''),
'script_name', COALESCE(scr.name, ''),
'script_execution_id', hsr.execution_id,
'async', NOT hsr.sync_request
'async', NOT hsr.sync_request,
'policy_id', hsr.policy_id,
'policy_name', p.name
) as details
FROM
host_script_results hsr
LEFT OUTER JOIN
users u ON u.id = hsr.user_id
LEFT OUTER JOIN
policies p ON p.id = hsr.policy_id
LEFT OUTER JOIN
host_display_names hdn ON hdn.host_id = hsr.host_id
LEFT OUTER JOIN
@@ -0,0 +1,26 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20241004005000, Down_20241004005000)
}
func Up_20241004005000(tx *sql.Tx) error {
if _, err := tx.Exec(`
ALTER TABLE host_script_results
ADD COLUMN policy_id INT UNSIGNED DEFAULT NULL,
ADD FOREIGN KEY fk_script_result_policy_id (policy_id) REFERENCES policies (id) ON DELETE SET NULL
`); err != nil {
return fmt.Errorf("failed to add policy_id to host script results: %w", err)
}
return nil
}
func Down_20241004005000(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,41 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20241004005000(t *testing.T) {
db := applyUpToPrev(t)
// insert a team
teamID := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ("Foo")`)
// insert a policy
policyID := execNoErrLastID(t, db, `INSERT INTO policies (name, query, description, team_id, checksum)
VALUES ('test_policy', "SELECT 1", "", ?, "a123b123")`, teamID)
// insert a script
scriptContentID := execNoErrLastID(t, db, `INSERT INTO script_contents (md5_checksum, contents) VALUES ("md5", "echo 'Hello World'")`)
scriptID := execNoErrLastID(t, db, `INSERT INTO scripts (
team_id, global_or_team_id, name, script_content_id
) VALUES (?, ?, "hello-world.sh", ?)`, teamID, teamID, scriptContentID)
// Apply current migration.
applyNext(t, db)
// insert a script result
hostScriptResultID := execNoErrLastID(t, db, `INSERT INTO host_script_results
(host_id, execution_id, script_content_id, output, script_id, policy_id, user_id, sync_request)
VALUES (1, 'a123b123', ?, '', ?, ?, NULL, FALSE)`, scriptContentID, scriptID, policyID)
// delete the associated policy
execNoErr(t, db, `DELETE FROM policies WHERE id = ?`, policyID)
// policy ID should be null but script result should still exist
var count int
err := db.Get(&count, "SELECT COUNT(*) FROM host_script_results WHERE policy_id IS NULL AND id = ?", hostScriptResultID)
require.NoError(t, err)
require.Equal(t, 1, count)
}
+1 -1
View File
@@ -123,7 +123,7 @@ func (ds *Datastore) PolicyLite(ctx context.Context, id uint) (*fleet.PolicyLite
var policy fleet.PolicyLite
err := sqlx.GetContext(
ctx, ds.reader(ctx), &policy,
`SELECT id, description, resolution FROM policies WHERE id=?`, id,
`SELECT id, name, description, resolution FROM policies WHERE id=?`, id,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -39,8 +39,8 @@ func (ds *Datastore) NewHostScriptExecutionRequest(ctx context.Context, request
func newHostScriptExecutionRequest(ctx context.Context, tx sqlx.ExtContext, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {
const (
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_content_id, output, script_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?)`
getStmt = `SELECT hsr.id, hsr.host_id, hsr.execution_id, hsr.created_at, hsr.script_id, hsr.user_id, hsr.sync_request, sc.contents as script_contents FROM host_script_results hsr JOIN script_contents sc WHERE sc.id = hsr.script_content_id AND hsr.id = ?`
insStmt = `INSERT INTO host_script_results (host_id, execution_id, script_content_id, output, script_id, policy_id, user_id, sync_request) VALUES (?, ?, ?, '', ?, ?, ?, ?)`
getStmt = `SELECT hsr.id, hsr.host_id, hsr.execution_id, hsr.created_at, hsr.script_id, hsr.policy_id, hsr.user_id, hsr.sync_request, sc.contents as script_contents FROM host_script_results hsr JOIN script_contents sc WHERE sc.id = hsr.script_content_id AND hsr.id = ?`
)
execID := uuid.New().String()
@@ -49,6 +49,7 @@ func newHostScriptExecutionRequest(ctx context.Context, tx sqlx.ExtContext, requ
execID,
request.ScriptContentID,
request.ScriptID,
request.PolicyID,
request.UserID,
request.SyncRequest,
)
@@ -260,6 +261,7 @@ func (ds *Datastore) getHostScriptExecutionResultDB(ctx context.Context, q sqlx.
hsr.execution_id,
sc.contents as script_contents,
hsr.script_id,
hsr.policy_id,
hsr.output,
hsr.runtime,
hsr.exit_code,
+13 -7
View File
@@ -1166,11 +1166,13 @@ func (a ActivityTypeDisabledWindowsMDM) Documentation() (activity, details, deta
}
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"`
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"`
PolicyID *uint `json:"policy_id"`
PolicyName *string `json:"policy_name"`
}
func (a ActivityTypeRanScript) ActivityName() string {
@@ -1188,12 +1190,16 @@ func (a ActivityTypeRanScript) Documentation() (activity, details, detailsExampl
- "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.`, `{
- "async": Whether the script was executed asynchronously.
- "policy_id": ID of the policy whose failure triggered the script run. Null if no associated policy.
- "policy_name": Name of the policy whose failure triggered the script run. Null if no associated policy.`, `{
"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
"async": false,
"policy_id": 123,
"policy_name": "Ensure photon torpedoes are primed"
}`
}
+2
View File
@@ -271,6 +271,8 @@ type PolicyScriptData struct {
// PolicyLite is a stripped down version of the policy.
type PolicyLite struct {
ID uint `db:"id"`
// Name is the name of the policy.
Name string `db:"name"`
// Description describes the policy.
Description string `db:"description"`
// Resolution describes how to solve a failing policy.
+4
View File
@@ -138,6 +138,7 @@ func (hs *HostScriptDetail) setLastExecution(executionID *string, executedAt *ti
type HostScriptRequestPayload struct {
HostID uint `json:"host_id"`
ScriptID *uint `json:"script_id"`
PolicyID *uint `json:"policy_id"`
ScriptContents string `json:"script_contents"`
ScriptContentID uint `json:"-"`
ScriptName string `json:"script_name"`
@@ -217,6 +218,9 @@ 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"`
// PolicyID is the id of the policy that triggered the script execution, or
// nil if the execution was not triggered by a policy failure
PolicyID *uint `json:"policy_id" db:"policy_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
+9 -1
View File
@@ -67,6 +67,8 @@ func (svc *Service) NewActivity(ctx context.Context, user *fleet.User, activity
return newActivity(ctx, user, activity, svc.ds, svc.logger)
}
var automationActivityAuthor = "Fleet"
func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, ds fleet.Datastore, logger kitlog.Logger) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
@@ -84,6 +86,8 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD
var userID *uint
var userName *string
var userEmail *string
activityType := activity.ActivityName()
if user != nil {
// To support creating activities with users that were deleted. This can happen
// for automatically installed software which uses the author of the upload as the author of
@@ -93,8 +97,12 @@ func newActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityD
}
userName = &user.Name
userEmail = &user.Email
} else if ranScriptActivity, ok := activity.(fleet.ActivityTypeRanScript); ok {
if ranScriptActivity.PolicyID != nil {
userName = &automationActivityAuthor
}
}
activityType := activity.ActivityName()
go func() {
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 30 * time.Minute
+39 -6
View File
@@ -6284,7 +6284,7 @@ func (s *integrationEnterpriseTestSuite) TestRunHostSavedScript() {
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": %d, "host_display_name": %q, "script_name": %q, "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null}`,
host.ID, host.DisplayName(), savedNoTmScript.Name, scriptResultResp.ExecutionID,
),
0,
@@ -12196,7 +12196,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(
`{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true}`,
`{"host_id": %d, "host_display_name": %q, "script_name": "", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null}`,
host.ID, host.DisplayName(), scriptExecID), 0)
// create a saved script execution request
@@ -12218,7 +12218,7 @@ func (s *integrationEnterpriseTestSuite) TestHostScriptSoftDelete() {
http.StatusOK)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeRanScript{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true}`,
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "script_name": "script1.sh", "script_execution_id": %q, "async": true, "policy_id": null, "policy_name": null}`,
host.ID, host.DisplayName(), savedScriptExecID), 0)
// get the anoymous script result details
@@ -14717,11 +14717,12 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
},
), http.StatusOK, &distributedResp)
hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host3Team2.ID, psScript.ID)
host3PendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, host3Team2.ID)
require.NoError(t, err)
require.True(t, hostPendingScript)
require.Len(t, host3PendingScripts, 1)
host3executionID := host3PendingScripts[0].ExecutionID
// Unassociate policy4Team2 from script.
// Dissociate policy4Team2 from script.
mtplr = modifyTeamPolicyResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{
ModifyPolicyPayload: fleet.ModifyPolicyPayload{
@@ -14750,6 +14751,38 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() {
hostPendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, hostVanillaOsquery5Team1.ID)
require.NoError(t, err)
require.Len(t, hostPendingScripts, 0)
// activity feed should show script run as pending, with "Fleet" as author, policy ID and name set in body
var listResp listActivitiesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", host3Team2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Nil(t, listResp.Activities[0].ActorEmail)
require.Equal(t, "Fleet", *listResp.Activities[0].ActorFullName)
require.Nil(t, listResp.Activities[0].ActorGravatar)
require.Equal(t, "ran_script", listResp.Activities[0].Type)
var activityJson map[string]interface{}
err = json.Unmarshal(*listResp.Activities[0].Details, &activityJson)
require.NoError(t, err)
require.Equal(t, float64(policy4Team2.ID), activityJson["policy_id"])
require.Equal(t, "policy4Team2", activityJson["policy_name"])
// post script result response
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"}`, *host3Team2.OrbitNodeKey, host3executionID)),
http.StatusOK, &orbitPostScriptResp)
// activity feed should show script run as completed
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", host3Team2.ID), nil, http.StatusOK, &listResp)
require.Len(t, listResp.Activities, 1)
require.Equal(t, "", *listResp.Activities[0].ActorEmail) // actor email is blank rather than nil here 👀
require.Equal(t, "Fleet", *listResp.Activities[0].ActorFullName)
require.Nil(t, listResp.Activities[0].ActorGravatar)
require.Equal(t, "ran_script", listResp.Activities[0].Type)
err = json.Unmarshal(*listResp.Activities[0].Details, &activityJson)
require.NoError(t, err)
require.Equal(t, float64(policy4Team2.ID), activityJson["policy_id"])
require.Equal(t, "policy4Team2", activityJson["policy_name"])
}
func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIdentifier() {
+9
View File
@@ -733,6 +733,13 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
}
default:
// TODO(sarah): We may need to special case lock/unlock script results here?
var policyName *string
if hsr.PolicyID != nil {
if policy, err := svc.ds.PolicyLite(ctx, *hsr.PolicyID); err == nil {
policyName = &policy.Name // fall back to blank policy name if we can't retrieve the policy
}
}
if err := svc.NewActivity(
ctx,
user,
@@ -742,6 +749,8 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
ScriptExecutionID: hsr.ExecutionID,
ScriptName: scriptName,
Async: !hsr.SyncRequest,
PolicyID: hsr.PolicyID,
PolicyName: policyName,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for script execution request")
+4 -1
View File
@@ -1931,6 +1931,8 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
}
for _, failingPolicyWithScript := range failingPoliciesWithScript {
policyID := failingPolicyWithScript.ID
scriptMetadata, err := svc.ds.Script(ctx, failingPolicyWithScript.ScriptID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get script metadata by id")
@@ -1938,7 +1940,7 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
logger := log.With(svc.logger,
"host_id", hostID,
"host_platform", hostPlatform,
"policy_id", failingPolicyWithScript.ID,
"policy_id", policyID,
"script_id", failingPolicyWithScript.ScriptID,
"script_name", scriptMetadata.Name,
)
@@ -1989,6 +1991,7 @@ func (svc *Service) processScriptsForNewlyFailingPolicies(
ScriptContentID: scriptMetadata.ScriptContentID,
ScriptID: &scriptMetadata.ID,
TeamID: policyTeamID,
PolicyID: &policyID,
// no user ID as scripts are executed by Fleet
}