diff --git a/Makefile b/Makefile index 9e63214ebd9a..ec4bac06ae40 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,10 @@ define HELP_TEXT make generate-dev - Generate and bundle required code in a watch loop make generate-doc - Generate updated API documentation for activities, osquery flags - make clean - Clean all build artifacts + make dump-test-schema - update schema.sql from current migrations + make generate-mock - update mock data store + + make clean - Clean all build artifacts make clean-assets - Clean assets only make build - Build the code diff --git a/cmd/fleetctl/apply.go b/cmd/fleetctl/apply.go index b201f5dc9474..b5ac4fc22f27 100644 --- a/cmd/fleetctl/apply.go +++ b/cmd/fleetctl/apply.go @@ -90,7 +90,7 @@ func applyCommand() *cli.Command { opts.TeamForPolicies = policiesTeamName } baseDir := filepath.Dir(flFilename) - _, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts) + _, _, _, err = fleetClient.ApplyGroup(c.Context, specs, baseDir, logf, nil, opts) if err != nil { return err } diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index f39ff1cd5551..6e3d4dcb06e0 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2306,8 +2306,8 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { ) (updates fleet.MDMProfilesUpdates, err error) { return fleet.MDMProfilesUpdates{}, nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { - return nil + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 295172612d74..498dd5a38eb7 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -59,7 +59,9 @@ func TestGitOpsBasicGlobalFree(t *testing.T) { ) (updates fleet.MDMProfilesUpdates, err error) { return fleet.MDMProfilesUpdates{}, nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { @@ -211,7 +213,9 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) { ) (updates fleet.MDMProfilesUpdates, err error) { return fleet.MDMProfilesUpdates{}, nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, ) error { @@ -327,7 +331,9 @@ func TestGitOpsBasicTeam(t *testing.T) { ds.BatchInsertVPPAppsFunc = func(ctx context.Context, apps []*fleet.VPPApp) error { return nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } ds.BatchSetMDMProfilesFunc = func( ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, ) (updates fleet.MDMProfilesUpdates, err error) { @@ -516,9 +522,18 @@ func TestGitOpsFullGlobal(t *testing.T) { ) var appliedScripts []*fleet.Script - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { appliedScripts = scripts - return nil + var scriptResponses []fleet.ScriptResponse + for _, script := range scripts { + scriptResponses = append(scriptResponses, fleet.ScriptResponse{ + ID: script.ID, + Name: script.Name, + TeamID: script.TeamID, + }) + } + + return scriptResponses, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, @@ -704,9 +719,18 @@ func TestGitOpsFullTeam(t *testing.T) { } var appliedScripts []*fleet.Script - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { appliedScripts = scripts - return nil + var scriptResponses []fleet.ScriptResponse + for _, script := range scripts { + scriptResponses = append(scriptResponses, fleet.ScriptResponse{ + ID: script.ID, + Name: script.Name, + TeamID: script.TeamID, + }) + } + + return scriptResponses, nil } ds.NewActivityFunc = func( ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, @@ -1040,9 +1064,9 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { assert.Empty(t, winProfiles) return fleet.MDMProfilesUpdates{}, nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { assert.Empty(t, scripts) - return nil + return []fleet.ScriptResponse{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, @@ -1318,9 +1342,9 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { assert.Empty(t, winProfiles) return fleet.MDMProfilesUpdates{}, nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { assert.Empty(t, scripts) - return nil + return []fleet.ScriptResponse{}, nil } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, @@ -2190,7 +2214,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ) (updates fleet.MDMProfilesUpdates, err error) { return fleet.MDMProfilesUpdates{}, nil } - ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { + return []fleet.ScriptResponse{}, nil + } ds.BulkSetPendingMDMHostProfilesFunc = func( ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, ) (updates fleet.MDMProfilesUpdates, err error) { diff --git a/cmd/fleetctl/preview.go b/cmd/fleetctl/preview.go index ef06f3c8cc88..820e9dcc1d65 100644 --- a/cmd/fleetctl/preview.go +++ b/cmd/fleetctl/preview.go @@ -387,7 +387,7 @@ Use the stop and reset subcommands to manage the server and dependencies once st } // this only applies standard queries, the base directory is not used, // so pass in the current working directory. - _, _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}) + _, _, _, err = client.ApplyGroup(c.Context, specs, ".", logf, nil, fleet.ApplyClientSpecOptions{}) if err != nil { return err } diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index e65a183a2e6a..ebc83e2f7c88 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -56,8 +56,8 @@ func TestRunScriptCommand(t *testing.T) { ds.GetScriptIDByNameFunc = func(ctx context.Context, name string, teamID *uint) (uint, error) { return 1, nil } - ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hid uint, scriptID uint) ([]*uint, error) { - return []*uint{}, nil + ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hid uint, scriptID uint) (bool, error) { + return false, nil } generateValidPath := func() string { diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 9e4adc22687e..bc52b9fa851e 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -56,10 +56,18 @@ type Policy struct { type GitOpsPolicySpec struct { fleet.PolicySpec + RunScript *PolicyRunScript `json:"run_script"` InstallSoftware *PolicyInstallSoftware `json:"install_software"` // InstallSoftwareURL is populated after parsing the software installer yaml // referenced by InstallSoftware.PackagePath. InstallSoftwareURL string `json:"-"` + // RunScriptName is populated after confirming the script exists on both the file system + // and in the controls scripts list for the same team + RunScriptName *string `json:"-"` +} + +type PolicyRunScript struct { + Path string `json:"path"` } type PolicyInstallSoftware struct { @@ -168,7 +176,7 @@ func GitOpsFromFile(filePath, baseDir string, appConfig *fleet.EnrichedAppConfig multiError = parseSoftware(top, result, baseDir, multiError) } - // Policies can reference software installers, thus we parse them after parseSoftware. + // Policies can reference software installers and scripts, thus we parse them after parseSoftware and parseControls. multiError = parsePolicies(top, result, baseDir, multiError) return result, multiError.ErrorOrNil() @@ -465,6 +473,10 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", item.Name, err)) continue } + if err := parsePolicyRunScript(baseDir, result.TeamName, &item, result.Controls.Scripts); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy run_script %q: %v", item.Name, err)) + continue + } result.Policies = append(result.Policies, &item.GitOpsPolicySpec) } else { fileBytes, err := os.ReadFile(resolveApplyRelativePath(baseDir, *item.Path)) @@ -496,6 +508,10 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy install_software %q: %v", pp.Name, err)) continue } + if err := parsePolicyRunScript(baseDir, result.TeamName, pp, result.Controls.Scripts); err != nil { + multiError = multierror.Append(multiError, fmt.Errorf("failed to parse policy run_script %q: %v", pp.Name, err)) + continue + } result.Policies = append(result.Policies, &pp.GitOpsPolicySpec) } } @@ -533,6 +549,41 @@ func parsePolicies(top map[string]json.RawMessage, result *GitOps, baseDir strin return multiError } +func parsePolicyRunScript(baseDir string, teamName *string, policy *Policy, scripts []BaseItem) error { + if policy.RunScript == nil { + policy.ScriptID = ptr.Uint(0) // unset the script + return nil + } + if policy.RunScript != nil && policy.RunScript.Path != "" && teamName == nil { + return errors.New("run_script can only be set on team policies") + } + + if policy.RunScript.Path == "" { + return errors.New("empty run_script path") + } + + _, err := os.Stat(resolveApplyRelativePath(baseDir, policy.RunScript.Path)) + if err != nil { + return fmt.Errorf("script file does not exist %q: %v", policy.RunScript.Path, err) + } + + scriptOnTeamFound := false + for _, script := range scripts { + if policy.RunScript.Path == *script.Path { + scriptOnTeamFound = true + break + } + } + if !scriptOnTeamFound { + return fmt.Errorf("policy script not found on team: %s", policy.RunScript.Path) + } + + scriptName := filepath.Base(policy.RunScript.Path) + policy.RunScriptName = &scriptName + + return nil +} + func parsePolicyInstallSoftware(baseDir string, teamName *string, policy *Policy, packages []*fleet.SoftwarePackageSpec) error { if policy.InstallSoftware == nil { policy.SoftwareTitleID = ptr.Uint(0) // unset the installer diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 65a73ab7c253..3e655ccf44b9 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -242,7 +242,7 @@ func TestValidGitOpsYaml(t *testing.T) { // Check policies expectedPoliciesCount := 5 if test.isTeam { - expectedPoliciesCount = 6 + expectedPoliciesCount = 8 } require.Len(t, gitops.Policies, expectedPoliciesCount) assert.Equal(t, "😊 Failing policy", gitops.Policies[0].Name) @@ -255,6 +255,14 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Equal(t, "Microsoft Teams on macOS installed and up to date", gitops.Policies[5].Name) assert.NotNil(t, gitops.Policies[5].InstallSoftware) assert.Equal(t, "./microsoft-teams.pkg.software.yml", gitops.Policies[5].InstallSoftware.PackagePath) + + assert.Equal(t, "Script run policy", gitops.Policies[6].Name) + assert.NotNil(t, gitops.Policies[6].RunScript) + assert.Equal(t, "./lib/collect-fleetd-logs.sh", gitops.Policies[6].RunScript.Path) + + assert.Equal(t, "🔥 Failing policy with script", gitops.Policies[7].Name) + assert.NotNil(t, gitops.Policies[7].RunScript) + assert.Equal(t, "./lib/collect-fleetd-logs.sh", gitops.Policies[7].RunScript.Path) } }, ) @@ -839,6 +847,20 @@ policies: assert.ErrorContains(t, err, "install_software can only be set on team policies") } +func TestGitOpsGlobalPolicyWithRunScript(t *testing.T) { + t.Parallel() + config := getGlobalConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + run_script: + path: ./some_path.sh +` + _, err := gitOpsFromString(t, config) + assert.ErrorContains(t, err, "run_script can only be set on team policies") +} + func TestGitOpsTeamPolicyWithInvalidInstallSoftware(t *testing.T) { t.Parallel() config := getTeamConfig([]string{"policies"}) @@ -939,6 +961,64 @@ software: assert.ErrorContains(t, err, "failed to unmarshal install_software.package_path file") } +func TestGitOpsTeamPolicyWithInvalidRunScript(t *testing.T) { + t.Parallel() + config := getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + run_script: + path: ./some_path.sh +` + _, err := gitOpsFromString(t, config) + assert.ErrorContains(t, err, "script file does not exist") + + config = getTeamConfig([]string{"policies"}) + config += ` +policies: +- name: Some policy + query: SELECT 1; + run_script: + path: +` + _, err = gitOpsFromString(t, config) + assert.ErrorContains(t, err, "empty run_script path") + + // Policy references a script not present in the team. + config = getTeamConfig([]string{"policies"}) + config += ` +policies: + - path: ./script-policy.yml +software: +controls: + scripts: + - path: ./top.policies2.yml + +` + path, basePath := createTempFile(t, "", config) + err = file.Copy( + filepath.Join("testdata", "script-policy.yml"), + filepath.Join(basePath, "script-policy.yml"), + 0o755, + ) + require.NoError(t, err) + err = file.Copy( + filepath.Join("testdata", "lib", "collect-fleetd-logs.sh"), + filepath.Join(basePath, "lib", "collect-fleetd-logs.sh"), + 0o755, + ) + require.NoError(t, err) + appConfig := fleet.EnrichedAppConfig{} + appConfig.License = &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + } + _, err = GitOpsFromFile(path, basePath, &appConfig, nopLogf) + assert.ErrorContains(t, err, + "policy script not found on team", + ) +} + func getGlobalConfig(optsToExclude []string) string { return getBaseConfig(topLevelOptions, optsToExclude) } diff --git a/pkg/spec/testdata/lib/collect-fleetd-logs.sh b/pkg/spec/testdata/lib/collect-fleetd-logs.sh new file mode 100644 index 000000000000..17089e08d8ca --- /dev/null +++ b/pkg/spec/testdata/lib/collect-fleetd-logs.sh @@ -0,0 +1 @@ +# collect fleetd logs \ No newline at end of file diff --git a/pkg/spec/testdata/script-policy.yml b/pkg/spec/testdata/script-policy.yml new file mode 100644 index 000000000000..7bc4dca52624 --- /dev/null +++ b/pkg/spec/testdata/script-policy.yml @@ -0,0 +1,7 @@ +- name: 🔥 Failing policy with script + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + run_script: + path: ./lib/collect-fleetd-logs.sh \ No newline at end of file diff --git a/pkg/spec/testdata/team_config.yml b/pkg/spec/testdata/team_config.yml index 8d593283a09b..2d088fd7a3d6 100644 --- a/pkg/spec/testdata/team_config.yml +++ b/pkg/spec/testdata/team_config.yml @@ -26,6 +26,13 @@ policies: resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; - path: ./team_install_software.policies.yml + - name: Script run policy + platform: linux + description: This should run a script on failure + query: SELECT * from osquery_info; + run_script: + path: ./lib/collect-fleetd-logs.sh + - path: ./script-policy.yml software: packages: - path: ./microsoft-teams.pkg.software.yml diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml index c0c0742d72fa..e660d5eccce7 100644 --- a/pkg/spec/testdata/team_config_no_paths.yml +++ b/pkg/spec/testdata/team_config_no_paths.yml @@ -120,6 +120,19 @@ policies: query: SELECT 1 FROM apps WHERE name = 'Microsoft Teams.app' AND version_compare(bundle_short_version, '24193.1707.3028.4282') >= 0; install_software: package_path: ./microsoft-teams.pkg.software.yml + - name: Script run policy + platform: linux + description: This should run a script on failure + query: SELECT * from osquery_info; + run_script: + path: ./lib/collect-fleetd-logs.sh + - name: 🔥 Failing policy with script + platform: linux + description: This policy should always fail. + resolution: There is no resolution for this policy. + query: SELECT 1 FROM osquery_info WHERE start_time < 0; + run_script: + path: ./lib/collect-fleetd-logs.sh software: packages: - path: ./microsoft-teams.pkg.software.yml diff --git a/pkg/spec/testdata/top.policies2.yml b/pkg/spec/testdata/top.policies2.yml index 501a557b773a..81f128dfaf47 100644 --- a/pkg/spec/testdata/top.policies2.yml +++ b/pkg/spec/testdata/top.policies2.yml @@ -2,4 +2,4 @@ platform: $LINUX_OS description: This policy should always fail. resolution: There is no resolution for this policy. - query: SELECT 1 FROM osquery_info WHERE start_time < 0; + query: SELECT 1 FROM osquery_info WHERE start_time < 0; \ No newline at end of file diff --git a/server/datastore/mysql/migrations/tables/20241002210000_PolicyAutomationRunScript.go b/server/datastore/mysql/migrations/tables/20241002210000_PolicyAutomationRunScript.go new file mode 100644 index 000000000000..60c61408d85f --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241002210000_PolicyAutomationRunScript.go @@ -0,0 +1,26 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20241002210000, Down_20241002210000) +} + +func Up_20241002210000(tx *sql.Tx) error { + if _, err := tx.Exec(` + ALTER TABLE policies + ADD COLUMN script_id INT UNSIGNED DEFAULT NULL, + ADD FOREIGN KEY fk_policies_script_id (script_id) REFERENCES scripts (id); + `); err != nil { + return fmt.Errorf("failed to add script_id to policies: %w", err) + } + + return nil +} + +func Down_20241002210000(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20241002210000_PolicyAutomationRunScript_test.go b/server/datastore/mysql/migrations/tables/20241002210000_PolicyAutomationRunScript_test.go new file mode 100644 index 000000000000..c700584e02b2 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20241002210000_PolicyAutomationRunScript_test.go @@ -0,0 +1,40 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20241002210000(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) + + // associate the policy to the script + execNoErr(t, db, `UPDATE policies SET script_id = ? WHERE id = ?`, scriptID, policyID) + + // attempt to delete the script; should error + _, err := db.Exec(`DELETE FROM scripts WHERE id = ?`, scriptID) + require.Error(t, err, "Foo") + + // dissociate the policy + execNoErr(t, db, `UPDATE policies SET script_id = NULL WHERE id = ?`, policyID) + + // attempt to delete the script; should succeed + execNoErr(t, db, `DELETE FROM scripts WHERE id = ?`, scriptID) +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 7ca49d35646a..7ff769541405 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -23,10 +23,11 @@ import ( const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, - p.calendar_events_enabled, p.software_installer_id + p.calendar_events_enabled, p.software_installer_id, p.script_id ` -var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be set on team policies only") +var errSoftwareTitleIDOnGlobalPolicy = errors.New("install software title id can be only be set on team policies") +var errScriptIDOnGlobalPolicy = errors.New("run script id can only be set on team or \"no team\" policies") var policySearchColumns = []string{"p.name"} @@ -34,6 +35,9 @@ func (ds *Datastore) NewGlobalPolicy(ctx context.Context, authorID *uint, args f if args.SoftwareInstallerID != nil { return nil, ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "create policy") } + if args.ScriptID != nil { + return nil, ctxerr.Wrap(ctx, errScriptIDOnGlobalPolicy, "create policy") + } if args.QueryID != nil { q, err := ds.Query(ctx, *args.QueryID) if err != nil { @@ -137,15 +141,25 @@ func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemo if p.TeamID == nil && p.SoftwareInstallerID != nil { return ctxerr.Wrap(ctx, errSoftwareTitleIDOnGlobalPolicy, "save policy") } + if p.TeamID == nil && p.ScriptID != nil { + return ctxerr.Wrap(ctx, errScriptIDOnGlobalPolicy, "save policy") + } + + if p.TeamID != nil { + if err := ds.assertTeamMatches(ctx, *p.TeamID, p.SoftwareInstallerID, p.ScriptID); err != nil { + return ctxerr.Wrap(ctx, err, "save policy") + } + } + // We must normalize the name for full Unicode support (Unicode equivalence). p.Name = norm.NFC.String(p.Name) sql := ` UPDATE policies - SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, checksum = ` + policiesChecksumComputedColumn() + ` + SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, software_installer_id = ?, script_id = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` result, err := ds.writer(ctx).ExecContext( - ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ID, + ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.SoftwareInstallerID, p.ScriptID, p.ID, ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") @@ -163,6 +177,41 @@ func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemo ) } +var errMismatchedInstallerTeam = &fleet.BadRequestError{Message: "software installer is associated with a different team"} +var errMismatchedScriptTeam = &fleet.BadRequestError{Message: "script is associated with a different team"} + +func (ds *Datastore) assertTeamMatches(ctx context.Context, teamID uint, softwareInstallerID *uint, scriptID *uint) error { + if softwareInstallerID != nil { + var softwareInstallerTeamID uint + err := sqlx.GetContext(ctx, ds.reader(ctx), &softwareInstallerTeamID, "SELECT global_or_team_id FROM software_installers WHERE id = ?", softwareInstallerID) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &fleet.BadRequestError{Message: "A software installer with the supplied ID does not exist"} + } + return err + } else if softwareInstallerTeamID != teamID { + return errMismatchedInstallerTeam + } + } + + if scriptID != nil { + var scriptTeamID uint + err := sqlx.GetContext(ctx, ds.reader(ctx), &scriptTeamID, "SELECT global_or_team_id FROM scripts WHERE id = ?", scriptID) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return &fleet.BadRequestError{Message: "A script with the supplied ID does not exist"} + } + return err + } else if scriptTeamID != teamID { + return errMismatchedScriptTeam + } + } + + return nil +} + func cleanupPolicy( ctx context.Context, queryerContext sqlx.QueryerContext, extContext sqlx.ExtContext, policyID uint, policyPlatform string, shouldRemoveAllPolicyMemberships bool, @@ -492,8 +541,7 @@ func (ds *Datastore) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*fl COALESCE(u.email, '') AS author_email, ps.updated_at as host_count_updated_at, COALESCE(ps.passing_host_count, 0) as passing_host_count, - COALESCE(ps.failing_host_count, 0) as failing_host_count, - p.software_installer_id + COALESCE(ps.failing_host_count, 0) as failing_host_count FROM policies p LEFT JOIN users u ON p.author_id = u.id LEFT JOIN policy_stats ps ON p.id = ps.policy_id @@ -606,13 +654,18 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u } // We must normalize the name for full Unicode support (Unicode equivalence). nameUnicode := norm.NFC.String(args.Name) + + if err := ds.assertTeamMatches(ctx, teamID, args.SoftwareInstallerID, args.ScriptID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create team policy") + } + res, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, software_installer_id, script_id, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, - args.CalendarEventsEnabled, args.SoftwareInstallerID, + args.CalendarEventsEnabled, args.SoftwareInstallerID, args.ScriptID, ) switch { case err == nil: @@ -757,6 +810,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs Query string `db:"query"` Platforms string `db:"platforms"` SoftwareInstallerID *uint `db:"software_installer_id"` + ScriptID *uint `db:"script_id"` } teamIDToPoliciesByName := make(map[*uint]map[string]policyLite, len(teamIDToPolicies)) for teamID, teamPolicySpecs := range teamIDToPolicies { @@ -770,10 +824,10 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs var args []interface{} var err error if teamID == nil { - query, args, err = sqlx.In("SELECT name, query, platforms, software_installer_id FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) + query, args, err = sqlx.In("SELECT name, query, platforms, software_installer_id, script_id FROM policies WHERE team_id IS NULL AND name IN (?)", policyNames) } else { query, args, err = sqlx.In( - "SELECT name, query, platforms, software_installer_id FROM policies WHERE team_id = ? AND name IN (?)", *teamID, policyNames, + "SELECT name, query, platforms, software_installer_id, script_id FROM policies WHERE team_id = ? AND name IN (?)", *teamID, policyNames, ) } if err != nil { @@ -803,8 +857,9 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs critical, calendar_events_enabled, software_installer_id, + script_id, checksum - ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) + ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, %s) ON DUPLICATE KEY UPDATE query = VALUES(query), description = VALUES(description), @@ -813,7 +868,8 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs platforms = VALUES(platforms), critical = VALUES(critical), calendar_events_enabled = VALUES(calendar_events_enabled), - software_installer_id = VALUES(software_installer_id) + software_installer_id = VALUES(software_installer_id), + script_id = VALUES(script_id) `, policiesChecksumComputedColumn(), ) for teamID, teamPolicySpecs := range teamIDToPolicies { @@ -822,10 +878,15 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs if spec.SoftwareTitleID != nil { softwareInstallerID = softwareInstallerIDs[teamNameToID[spec.Team]][*spec.SoftwareTitleID] } + scriptID := spec.ScriptID + if spec.ScriptID != nil && *spec.ScriptID == 0 { + scriptID = nil + } + res, err := tx.ExecContext( ctx, query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, teamID, spec.Platform, spec.Critical, - spec.CalendarEventsEnabled, softwareInstallerID, + spec.CalendarEventsEnabled, softwareInstallerID, scriptID, ) if err != nil { return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") @@ -854,6 +915,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs (prev.SoftwareInstallerID != nil && softwareInstallerID != nil && *prev.SoftwareInstallerID != *softwareInstallerID)): shouldRemoveAllPolicyMemberships = true removePolicyStats = true + case teamID != nil && + ((prev.ScriptID == nil && spec.ScriptID != nil) || + (prev.ScriptID != nil && spec.ScriptID != nil && *prev.ScriptID != *spec.ScriptID)): + shouldRemoveAllPolicyMemberships = true + removePolicyStats = true case prev.Platforms != spec.Platform: removePolicyStats = true } @@ -1525,6 +1591,22 @@ func (ds *Datastore) GetPoliciesWithAssociatedInstaller(ctx context.Context, tea return policies, nil } +func (ds *Datastore) GetPoliciesWithAssociatedScript(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicyScriptData, error) { + if len(policyIDs) == 0 { + return nil, nil + } + query := `SELECT id, script_id FROM policies WHERE team_id = ? AND script_id IS NOT NULL AND id IN (?);` + query, args, err := sqlx.In(query, teamID, policyIDs) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "build sqlx.In for get policies with associated script") + } + var policies []fleet.PolicyScriptData + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies with associated script") + } + return policies, nil +} + func (ds *Datastore) GetTeamHostsPolicyMemberships( ctx context.Context, domain string, diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 96392e494f04..e93175f5d2bb 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -67,6 +67,8 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller}, {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller}, {"ApplyPolicySpecWithInstallers", testApplyPolicySpecWithInstallers}, + {"TestPoliciesNewGlobalPolicyWithScript", testNewGlobalPolicyWithScript}, + {"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript}, {"TeamPoliciesNoTeam", testTeamPoliciesNoTeam}, } for _, c := range cases { @@ -1241,6 +1243,7 @@ func testPoliciesByID(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", UserID: user1.ID, + TeamID: &team1.ID, }) require.NoError(t, err) policy2.SoftwareInstallerID = ptr.Uint(installerID) @@ -3948,6 +3951,16 @@ func testNewGlobalPolicyWithInstaller(t *testing.T, ds *Datastore) { require.ErrorIs(t, err, errSoftwareTitleIDOnGlobalPolicy) } +func testNewGlobalPolicyWithScript(t *testing.T, ds *Datastore) { + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + _, err := ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{ + Query: "SELECT 1;", + ScriptID: ptr.Uint(1), + }) + require.Error(t, err) + require.ErrorIs(t, err, errScriptIDOnGlobalPolicy) +} + func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -3976,6 +3989,7 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { Version: "1.0", Source: "apps", UserID: user1.ID, + TeamID: &team1.ID, }) require.NoError(t, err) require.Nil(t, p1.SoftwareInstallerID) @@ -4000,12 +4014,33 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { require.Equal(t, installerID, *p2.SoftwareInstallerID) // Policy p4 in "No team" with associated installer. + noTeamInstallerID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: bytes.NewReader([]byte("hello")), + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: ptr.Uint(fleet.PolicyNoTeamID), + }) + require.NoError(t, err) p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ Name: "p4", Query: "SELECT 4;", - SoftwareInstallerID: ptr.Uint(installerID), + SoftwareInstallerID: ptr.Uint(noTeamInstallerID), }) require.NoError(t, err) + _, err = ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "p4", + Query: "SELECT 4;", + SoftwareInstallerID: ptr.Uint(installerID), + }) + require.Error(t, err, "software installer is associated with a different team") + policiesWithInstallers, err := ds.GetPoliciesWithAssociatedInstaller(ctx, fleet.PolicyNoTeamID, []uint{p4.ID}) require.NoError(t, err) require.Len(t, policiesWithInstallers, 1) @@ -4035,6 +4070,10 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { err = ds.SavePolicy(ctx, p1, false, false) require.NoError(t, err) + p1.SoftwareInstallerID = ptr.Uint(noTeamInstallerID) + err = ds.SavePolicy(ctx, p1, false, false) + require.Error(t, err, "software installer is associated with a different team") + p2, err = ds.Policy(ctx, p2.ID) require.NoError(t, err) require.NotNil(t, p2.SoftwareInstallerID) @@ -4053,6 +4092,120 @@ func testTeamPoliciesWithInstaller(t *testing.T, ds *Datastore) { require.Empty(t, policiesWithInstallers) } +func testTeamPoliciesWithScript(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Sierra", "sierra@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) // team2 has no policies + require.NoError(t, err) + + // Policy p1 has no associated script. + p1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + SoftwareInstallerID: nil, + }) + require.NoError(t, err) + // Create and associate a script to p2. + script, err := ds.NewScript(context.Background(), &fleet.Script{ + TeamID: &team1.ID, + Name: "hello-world.sh", + ScriptContents: "echo 'Hello World'", + }) + require.NoError(t, err) + require.Nil(t, p1.ScriptID) + p2, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "p2", + Query: "SELECT 1;", + ScriptID: &script.ID, + }) + require.NoError(t, err) + require.NotNil(t, p2.ScriptID) + require.Equal(t, script.ID, *p2.ScriptID) + // Create p3 as global policy. + _, err = ds.NewGlobalPolicy(ctx, &user1.ID, fleet.PolicyPayload{ + Name: "p3", + Query: "SELECT 1;", + }) + require.NoError(t, err) + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.ScriptID) + require.Equal(t, script.ID, *p2.ScriptID) + + // Policy p4 in "No team" with associated script. + noTeamScript, err := ds.NewScript(context.Background(), &fleet.Script{ + Name: "hello-world.sh", + ScriptContents: "echo 'Hello NoTeam'", + }) + require.NoError(t, err) + p4, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "p4", + Query: "SELECT 4;", + ScriptID: &noTeamScript.ID, + }) + require.NoError(t, err) + _, err = ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, &user1.ID, fleet.PolicyPayload{ + Name: "p4", + Query: "SELECT 4;", + ScriptID: &script.ID, + }) + require.Error(t, err, "script is associated with a different team") + + policiesWithScripts, err := ds.GetPoliciesWithAssociatedScript(ctx, fleet.PolicyNoTeamID, []uint{p4.ID}) + require.NoError(t, err) + require.Len(t, policiesWithScripts, 1) + require.Equal(t, p4.ID, policiesWithScripts[0].ID) + + policiesWithScripts, err = ds.GetPoliciesWithAssociatedScript(ctx, team1.ID, []uint{}) + require.NoError(t, err) + require.Empty(t, policiesWithScripts) + + // p1 has no associated scripts. + policiesWithScripts, err = ds.GetPoliciesWithAssociatedScript(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithScripts) + + policiesWithScripts, err = ds.GetPoliciesWithAssociatedScript(ctx, team1.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithScripts, 1) + require.Equal(t, p2.ID, policiesWithScripts[0].ID) + require.Equal(t, script.ID, policiesWithScripts[0].ScriptID) + + // p2 has associated script but belongs to team1. + policiesWithScripts, err = ds.GetPoliciesWithAssociatedScript(ctx, team2.ID, []uint{p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithScripts) + + p1.ScriptID = ptr.Uint(script.ID) + err = ds.SavePolicy(ctx, p1, false, false) + require.NoError(t, err) + + p1.ScriptID = ptr.Uint(noTeamScript.ID) + err = ds.SavePolicy(ctx, p1, false, false) + require.Error(t, err, "script is associated with a different team") + + p2, err = ds.Policy(ctx, p2.ID) + require.NoError(t, err) + require.NotNil(t, p2.ScriptID) + require.Equal(t, script.ID, *p2.ScriptID) + + policiesWithScripts, err = ds.GetPoliciesWithAssociatedScript(ctx, team1.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Len(t, policiesWithScripts, 2) + require.Equal(t, p1.ID, policiesWithScripts[0].ID) + require.Equal(t, script.ID, policiesWithScripts[0].ScriptID) + require.Equal(t, p2.ID, policiesWithScripts[1].ID) + require.Equal(t, script.ID, policiesWithScripts[1].ScriptID) + + policiesWithScripts, err = ds.GetPoliciesWithAssociatedScript(ctx, team2.ID, []uint{p1.ID, p2.ID}) + require.NoError(t, err) + require.Empty(t, policiesWithScripts) +} + func testApplyPolicySpecWithInstallers(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5755e67d0f0a..fe6155d04b31 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1038,9 +1038,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=316 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=317 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'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,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'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1389,12 +1389,15 @@ CREATE TABLE `policies` ( `checksum` binary(16) NOT NULL, `calendar_events_enabled` tinyint unsigned NOT NULL DEFAULT '0', `software_installer_id` int unsigned DEFAULT NULL, + `script_id` int unsigned DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), KEY `idx_policies_author_id` (`author_id`), KEY `idx_policies_team_id` (`team_id`), KEY `fk_policies_software_installer_id` (`software_installer_id`), + KEY `fk_policies_script_id` (`script_id`), CONSTRAINT `policies_ibfk_3` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`), + CONSTRAINT `policies_ibfk_4` FOREIGN KEY (`script_id`) REFERENCES `scripts` (`id`), CONSTRAINT `policies_queries_ibfk_1` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`) ON DELETE SET NULL ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index f0ad52acb014..ec51063d5160 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -228,7 +228,7 @@ func (ds *Datastore) ListPendingHostScriptExecutions(ctx context.Context, hostID return results, nil } -func (ds *Datastore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) { +func (ds *Datastore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) (bool, error) { const getStmt = ` SELECT 1 @@ -242,9 +242,9 @@ func (ds *Datastore) IsExecutionPendingForHost(ctx context.Context, hostID uint, 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 false, ctxerr.Wrap(ctx, err, "is execution pending for host") } - return results, nil + return len(results) > 0, nil } func (ds *Datastore) GetHostScriptExecutionResult(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { @@ -435,9 +435,21 @@ WHERE return contents, nil } +var errDeleteScriptWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this script. Please remove this script from associated policy automations and try again."} + func (ds *Datastore) DeleteScript(ctx context.Context, id uint) error { _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM scripts WHERE id = ?`, id) if err != nil { + if isMySQLForeignKey(err) { + // Check if the script is referenced by a policy automation. + var count int + if err := sqlx.GetContext(ctx, ds.reader(ctx), &count, `SELECT COUNT(*) FROM policies WHERE script_id = ?`, id); err != nil { + return ctxerr.Wrapf(ctx, err, "getting reference from policies") + } + if count > 0 { + return ctxerr.Wrap(ctx, errDeleteScriptWithAssociatedPolicy, "delete script") + } + } return ctxerr.Wrap(ctx, err, "delete script") } return nil @@ -604,7 +616,7 @@ WHERE return results, metaData, nil } -func (ds *Datastore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { +func (ds *Datastore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { const loadExistingScripts = ` SELECT name @@ -619,6 +631,12 @@ DELETE FROM scripts WHERE global_or_team_id = ? +` + const unsetAllScriptsFromPolicies = `UPDATE policies SET script_id = NULL WHERE team_id = ?` + + const unsetScriptsNotInListFromPolicies = ` +UPDATE policies SET script_id = NULL +WHERE script_id IN (SELECT id FROM scripts WHERE global_or_team_id = ? AND name NOT IN (?)) ` const deleteScriptsNotInList = ` @@ -640,6 +658,8 @@ ON DUPLICATE KEY UPDATE script_content_id = VALUES(script_content_id) ` + const loadInsertedScripts = `SELECT id, team_id, name FROM scripts WHERE global_or_team_id = ?` + // use a team id of 0 if no-team var globalOrTeamID uint if tmID != nil { @@ -657,7 +677,8 @@ ON DUPLICATE KEY UPDATE incomingScripts[p.Name] = p } - return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var insertedScripts []fleet.ScriptResponse + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var existingScripts []*fleet.Script if len(incomingNames) > 0 { @@ -680,21 +701,34 @@ ON DUPLICATE KEY UPDATE } var ( - stmt string - args []any - err error + scriptsStmt string + scriptsArgs []any + policiesStmt string + policiesArgs []any + err error ) if len(keepNames) > 0 { // delete the obsolete scripts - stmt, args, err = sqlx.In(deleteScriptsNotInList, globalOrTeamID, keepNames) + scriptsStmt, scriptsArgs, err = sqlx.In(deleteScriptsNotInList, globalOrTeamID, keepNames) if err != nil { return ctxerr.Wrap(ctx, err, "build statement to delete obsolete scripts") } + + policiesStmt, policiesArgs, err = sqlx.In(unsetScriptsNotInListFromPolicies, globalOrTeamID, keepNames) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to unset obsolete scripts from policies") + } } else { - stmt = deleteAllScriptsInTeam - args = []any{globalOrTeamID} + scriptsStmt = deleteAllScriptsInTeam + scriptsArgs = []any{globalOrTeamID} + + policiesStmt = unsetAllScriptsFromPolicies + policiesArgs = []any{globalOrTeamID} + } + if _, err := tx.ExecContext(ctx, policiesStmt, policiesArgs...); err != nil { + return ctxerr.Wrap(ctx, err, "unset obsolete scripts from policies") } - if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + if _, err := tx.ExecContext(ctx, scriptsStmt, scriptsArgs...); err != nil { return ctxerr.Wrap(ctx, err, "delete obsolete scripts") } @@ -709,8 +743,17 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrapf(ctx, err, "insert new/edited script with name %q", s.Name) } } + + if err := sqlx.SelectContext(ctx, tx, &insertedScripts, loadInsertedScripts, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "load inserted scripts") + } + return nil - }) + }); err != nil { + return nil, err + } + + return insertedScripts, nil } func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 7666f0ae4f31..251d2092eda3 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -38,6 +38,7 @@ func TestScripts(t *testing.T) { {"TestInsertScriptContents", testInsertScriptContents}, {"TestCleanupUnusedScriptContents", testCleanupUnusedScriptContents}, {"TestGetAnyScriptContents", testGetAnyScriptContents}, + {"TestDeleteScriptsAssignedToPolicy", testDeleteScriptsAssignedToPolicy}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -651,7 +652,7 @@ VALUES 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) + require.True(t, r) }) } @@ -659,7 +660,7 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { ctx := context.Background() applyAndExpect := func(newSet []*fleet.Script, tmID *uint, want []*fleet.Script) map[string]uint { - err := ds.BatchSetScripts(ctx, tmID, newSet) + responseFromSet, err := ds.BatchSetScripts(ctx, tmID, newSet) require.NoError(t, err) if tmID == nil { @@ -669,12 +670,18 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { require.NoError(t, err) // compare only the fields we care about - m := make(map[string]uint) + fromGetByScriptName := make(map[string]uint) + fromSetByScriptName := make(map[string]uint) + for _, gotScript := range responseFromSet { + fromSetByScriptName[gotScript.Name] = gotScript.ID + } for _, gotScript := range got { - m[gotScript.Name] = gotScript.ID + fromGetByScriptName[gotScript.Name] = gotScript.ID if gotScript.TeamID != nil && *gotScript.TeamID == 0 { gotScript.TeamID = nil } + + require.Equal(t, fromGetByScriptName[gotScript.Name], gotScript.ID) gotScript.ID = 0 gotScript.CreatedAt = time.Time{} gotScript.UpdatedAt = time.Time{} @@ -682,7 +689,7 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { // order is not guaranteed require.ElementsMatch(t, want, got) - return m + return fromGetByScriptName } // apply empty set for no-team @@ -698,6 +705,15 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { }, ptr.Uint(tm1.ID), []*fleet.Script{ {Name: "N1", TeamID: ptr.Uint(tm1.ID)}, }) + n1WithTeamID := sTm1["N1"] + + teamPolicy, err := ds.NewTeamPolicy(ctx, tm1.ID, nil, fleet.PolicyPayload{ + Name: "Team One Policy", + Query: "SELECT 1", + Platform: "darwin", + ScriptID: &n1WithTeamID, + }) + require.NoError(t, err) // apply single script set for no-team sNoTm := applyAndExpect([]*fleet.Script{ @@ -705,6 +721,15 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { }, nil, []*fleet.Script{ {Name: "N1", TeamID: nil}, }) + n1WithNoTeamId := sNoTm["N1"] + + noTeamPolicy, err := ds.NewTeamPolicy(ctx, fleet.PolicyNoTeamID, nil, fleet.PolicyPayload{ + Name: "No Team Policy", + Query: "SELECT 1", + Platform: "darwin", + ScriptID: &n1WithNoTeamId, + }) + require.NoError(t, err) // apply new script set for tm1 sTm1b := applyAndExpect([]*fleet.Script{ @@ -717,6 +742,11 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { // name for N1-I1 is unchanged require.Equal(t, sTm1["I1"], sTm1b["I1"]) + // policy still has script associated + teamPolicy, err = ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, n1WithTeamID, *teamPolicy.ScriptID) + // apply edited (by contents only) script set for no-team sNoTmb := applyAndExpect([]*fleet.Script{ {Name: "N1", ScriptContents: "C1-changed"}, @@ -725,6 +755,11 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { }) require.Equal(t, sNoTm["I1"], sNoTmb["I1"]) + // policy still has script associated + noTeamPolicy, err = ds.Policy(ctx, noTeamPolicy.ID) + require.NoError(t, err) + require.Equal(t, n1WithNoTeamId, *noTeamPolicy.ScriptID) + // apply edited script (by content only), unchanged script and new // script for tm1 sTm1c := applyAndExpect([]*fleet.Script{ @@ -741,6 +776,24 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { // identifier for N2-I2 is unchanged require.Equal(t, sTm1b["I2"], sTm1c["I2"]) + // policy still has script associated + teamPolicy, err = ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Equal(t, n1WithTeamID, *teamPolicy.ScriptID) + + // clear scripts for tm1 + applyAndExpect(nil, ptr.Uint(1), nil) + + // policy on team should not have script assigned + teamPolicy, err = ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Nil(t, teamPolicy.ScriptID) + + // no-team policy still has script associated + noTeamPolicy, err = ds.Policy(ctx, noTeamPolicy.ID) + require.NoError(t, err) + require.Equal(t, n1WithNoTeamId, *noTeamPolicy.ScriptID) + // apply only new scripts to no-team applyAndExpect([]*fleet.Script{ {Name: "N4", ScriptContents: "C4"}, @@ -750,8 +803,15 @@ func testBatchSetScripts(t *testing.T, ds *Datastore) { {Name: "N5", TeamID: nil}, }) - // clear scripts for tm1 - applyAndExpect(nil, ptr.Uint(1), nil) + // policy on team should not have script assigned + teamPolicy, err = ds.Policy(ctx, teamPolicy.ID) + require.NoError(t, err) + require.Nil(t, teamPolicy.ScriptID) + + // no-team policy should not have script associated + noTeamPolicy, err = ds.Policy(ctx, noTeamPolicy.ID) + require.NoError(t, err) + require.Nil(t, noTeamPolicy.ScriptID) } func testLockHostViaScript(t *testing.T, ds *Datastore) { @@ -1268,3 +1328,34 @@ func testGetAnyScriptContents(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, contents, string(result)) } + +func testDeleteScriptsAssignedToPolicy(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + script, err := ds.NewScript(ctx, &fleet.Script{ + Name: "script.sh", + TeamID: &team1.ID, + ScriptContents: "hello world", + }) + require.NoError(t, err) + + p1, err := ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "p1", + Query: "SELECT 1;", + ScriptID: &script.ID, + }) + require.NoError(t, err) + + err = ds.DeleteScript(ctx, script.ID) + require.Error(t, err) + require.ErrorIs(t, err, errDeleteScriptWithAssociatedPolicy) + + _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + + err = ds.DeleteScript(ctx, script.ID) + require.NoError(t, err) +} diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 487f2ddaadba..327bca9d9555 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -625,7 +625,7 @@ func testBatchSetSoftwareInstallers(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) - // TODO(roberto): perform better assertions, we should have evertything + // TODO(roberto): perform better assertions, we should have everything // to check that the actual values of everything match. assertSoftware := func(wantTitles []fleet.SoftwareTitle) { tmFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index c0d1b8b61b17..da012174e7da 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -106,8 +106,8 @@ func saveTeamSecretsDB(ctx context.Context, q sqlx.ExtContext, team *fleet.Team) func (ds *Datastore) DeleteTeam(ctx context.Context, tid uint) error { return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { - // Delete team policies first, because policies can have associated installers which may be deleted on cascade - // before deleting the policies (which are also deleted on cascade). + // Delete team policies first, because policies can have associated installers and scripts + // which may be deleted on cascade before deleting the policies (which are also deleted on cascade). _, err := tx.ExecContext(ctx, `DELETE FROM policies WHERE team_id = ?`, tid) if err != nil { return ctxerr.Wrapf(ctx, err, "deleting policies for team %d", tid) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 99b2cdb7d27c..581a10467b8f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -641,7 +641,7 @@ type Datastore interface { 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) + IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) (bool, error) /////////////////////////////////////////////////////////////////////////////// // StatisticsStore @@ -686,6 +686,7 @@ type Datastore interface { hostID *uint) ([]HostPolicyMembershipData, error) // GetPoliciesWithAssociatedInstaller returns team policies that have an associated installer. GetPoliciesWithAssociatedInstaller(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicySoftwareInstallerData, error) + GetPoliciesWithAssociatedScript(ctx context.Context, teamID uint, policyIDs []uint) ([]PolicyScriptData, error) GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) // Methods used for async processing of host policy query results. @@ -1587,7 +1588,7 @@ type Datastore interface { GetHostScriptDetails(ctx context.Context, hostID uint, teamID *uint, opts ListOptions, hostPlatform string) ([]*HostScriptDetail, *PaginationMetadata, error) // BatchSetScripts sets the scripts for the given team or no team. - BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) error + BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) ([]ScriptResponse, error) // GetHostLockWipeStatus gets the lock/unlock and wipe status for the host. GetHostLockWipeStatus(ctx context.Context, host *Host) (*HostLockWipeStatus, error) diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 53849a6227b6..9a27f39937f3 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -38,6 +38,10 @@ type PolicyPayload struct { // // Only applies to team policies. SoftwareInstallerID *uint + // ScriptID is the ID of the script that will be executed if the policy fails. + // + // Only applies to team policies. + ScriptID *uint } // NewTeamPolicyPayload holds data for team policy creation. @@ -68,6 +72,8 @@ type NewTeamPolicyPayload struct { CalendarEventsEnabled bool // SoftwareTitleID is the ID of the software title that will be installed if the policy fails. SoftwareTitleID *uint + // ScriptID is the ID of the script that will be executed if the policy fails. + ScriptID *uint } var ( @@ -157,6 +163,11 @@ type ModifyPolicyPayload struct { // // Only applies to team policies. SoftwareTitleID *uint `json:"software_title_id" premium:"true"` + // ScriptID is the ID of the script that will be executed if the policy fails. + // Value 0 will unset the current script from the policy. + // + // Only applies to team policies. + ScriptID *uint `json:"script_id" premium:"true"` } // Verify verifies the policy payload is valid. @@ -211,6 +222,7 @@ type PolicyData struct { CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` SoftwareInstallerID *uint `json:"-" db:"software_installer_id"` + ScriptID *uint `json:"-" db:"script_id"` UpdateCreateTimestamps } @@ -232,6 +244,13 @@ type Policy struct { // // This field is populated from PolicyData.SoftwareInstallerID. InstallSoftware *PolicySoftwareTitle `json:"install_software,omitempty"` + + // RunScript is used to trigger script execution when this policy fails. + // + // Only applies to team policies. + // + // This field is populated from PolicyData.ScriptID + RunScript *PolicyScript `json:"run_script,omitempty"` } type PolicyCalendarData struct { @@ -244,6 +263,11 @@ type PolicySoftwareInstallerData struct { InstallerID uint `db:"software_installer_id"` } +type PolicyScriptData struct { + ID uint `db:"id"` + ScriptID uint `db:"script_id"` +} + // PolicyLite is a stripped down version of the policy. type PolicyLite struct { ID uint `db:"id"` @@ -296,9 +320,12 @@ type PolicySpec struct { // // Only applies to team policies. CalendarEventsEnabled bool `json:"calendar_events_enabled"` - // SoftwareTitleID is the title ID of the installer associated with this policy. + // SoftwareTitleID is the title ID of the installer associated with this policy (team policies only). // When editing a policy, if this is nil or 0 then the title ID is unset from the policy. SoftwareTitleID *uint `json:"software_title_id"` + // ScriptID is the ID of the script associated with this policy (team policies only). + // When editing a policy, if this is nil or 0 then the script ID is unset from the policy. + ScriptID *uint `json:"script_id"` } // PolicySoftwareTitle contains software title data for policies. @@ -310,6 +337,14 @@ type PolicySoftwareTitle struct { Name string `json:"name"` } +// PolicyScript contains script data for policies. +type PolicyScript struct { + // ID is the ID of the script associated with the policy + ID uint `json:"id"` + // Name is the script name + Name string `json:"name"` +} + // Verify verifies the policy data is valid. func (p PolicySpec) Verify() error { if err := verifyPolicyName(p.Name); err != nil { diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index c6aeeaa93406..b5a4361ab19b 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -43,7 +43,7 @@ func (s *Script) ValidateNewScript() error { return errors.New("File type not supported. Only .sh and .ps1 file type is allowed.") } - // validate the script contents as if it were alreay a saved script + // validate the script contents as if it were already a saved script if err := ValidateHostScriptContents(s.ScriptContents, true); err != nil { return err } @@ -406,6 +406,17 @@ type HostLockWipeStatus struct { WipeScript *HostScriptResult } +// ScriptResponse is the response type used when applying scripts by batch. +type ScriptResponse struct { + // TeamID is the id of the team. + // A value of nil means it is scoped to hosts that are assigned to "No team". + TeamID *uint `json:"team_id" db:"team_id"` + // ID is the id of the script + ID uint `json:"id" db:"id"` + // Name is the name of the script + Name string `json:"name" db:"name"` +} + func (s *HostLockWipeStatus) IsPendingLock() bool { if s.HostFleetPlatform == "darwin" || s.HostFleetPlatform == "ios" || s.HostFleetPlatform == "ipados" { // pending lock if an MDM command is queued but no result received yet diff --git a/server/fleet/service.go b/server/fleet/service.go index 24756ebb6d80..1df2d4ca04cf 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1099,7 +1099,7 @@ type Service interface { // BatchSetScripts replaces the scripts for a specified team or for // hosts with no team. - BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []ScriptPayload, dryRun bool) error + BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []ScriptPayload, dryRun bool) ([]ScriptResponse, error) // Script-based methods (at least for some platforms, MDM-based for others) LockHost(ctx context.Context, hostID uint, viewPIN bool) (unlockPIN string, err error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index a592559bdf5a..9da88abfa788 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -464,7 +464,7 @@ type ListHostUpcomingActivitiesFunc func(ctx context.Context, hostID uint, opt f 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 IsExecutionPendingForHostFunc func(ctx context.Context, hostID uint, scriptID uint) (bool, error) type ShouldSendStatisticsFunc func(ctx context.Context, frequency time.Duration, config config.FleetConfig) (fleet.StatisticsPayload, bool, error) @@ -500,6 +500,8 @@ type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, type GetPoliciesWithAssociatedInstallerFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicySoftwareInstallerData, error) +type GetPoliciesWithAssociatedScriptFunc func(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicyScriptData, error) + type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error @@ -1008,7 +1010,7 @@ type GetScriptIDByNameFunc func(ctx context.Context, name string, teamID *uint) type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uint, opts fleet.ListOptions, hostPlatform string) ([]*fleet.HostScriptDetail, *fleet.PaginationMetadata, error) -type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error +type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) type GetHostLockWipeStatusFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) @@ -1811,6 +1813,9 @@ type DataStore struct { GetPoliciesWithAssociatedInstallerFunc GetPoliciesWithAssociatedInstallerFunc GetPoliciesWithAssociatedInstallerFuncInvoked bool + GetPoliciesWithAssociatedScriptFunc GetPoliciesWithAssociatedScriptFunc + GetPoliciesWithAssociatedScriptFuncInvoked bool + GetCalendarPoliciesFunc GetCalendarPoliciesFunc GetCalendarPoliciesFuncInvoked bool @@ -4253,7 +4258,7 @@ func (s *DataStore) ListHostPastActivities(ctx context.Context, hostID uint, opt return s.ListHostPastActivitiesFunc(ctx, hostID, opt) } -func (s *DataStore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) ([]*uint, error) { +func (s *DataStore) IsExecutionPendingForHost(ctx context.Context, hostID uint, scriptID uint) (bool, error) { s.mu.Lock() s.IsExecutionPendingForHostFuncInvoked = true s.mu.Unlock() @@ -4379,6 +4384,13 @@ func (s *DataStore) GetPoliciesWithAssociatedInstaller(ctx context.Context, team return s.GetPoliciesWithAssociatedInstallerFunc(ctx, teamID, policyIDs) } +func (s *DataStore) GetPoliciesWithAssociatedScript(ctx context.Context, teamID uint, policyIDs []uint) ([]fleet.PolicyScriptData, error) { + s.mu.Lock() + s.GetPoliciesWithAssociatedScriptFuncInvoked = true + s.mu.Unlock() + return s.GetPoliciesWithAssociatedScriptFunc(ctx, teamID, policyIDs) +} + func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { s.mu.Lock() s.GetCalendarPoliciesFuncInvoked = true @@ -6157,7 +6169,7 @@ func (s *DataStore) GetHostScriptDetails(ctx context.Context, hostID uint, teamI return s.GetHostScriptDetailsFunc(ctx, hostID, teamID, opts, hostPlatform) } -func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { +func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []*fleet.Script) ([]fleet.ScriptResponse, error) { s.mu.Lock() s.BatchSetScriptsFuncInvoked = true s.mu.Unlock() diff --git a/server/service/client.go b/server/service/client.go index 8924707784f1..d39bfe868b91 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -397,8 +397,9 @@ func (c *Client) ApplyGroup( logf func(format string, args ...interface{}), appconfig *fleet.EnrichedAppConfig, opts fleet.ApplyClientSpecOptions, -) (map[string]uint, map[string][]fleet.SoftwarePackageResponse, error) { +) (map[string]uint, map[string][]fleet.SoftwarePackageResponse, map[string][]fleet.ScriptResponse, error) { teamSoftwareInstallers := make(map[string][]fleet.SoftwarePackageResponse) + teamScripts := make(map[string][]fleet.ScriptResponse) logfn := func(format string, args ...interface{}) { if logf != nil { @@ -412,7 +413,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring queries, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyQueries(specs.Queries); err != nil { - return nil, nil, fmt.Errorf("applying queries: %w", err) + return nil, nil, nil, fmt.Errorf("applying queries: %w", err) } logfn("[+] applied %d queries\n", len(specs.Queries)) } @@ -423,7 +424,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring labels, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyLabels(specs.Labels); err != nil { - return nil, nil, fmt.Errorf("applying labels: %w", err) + return nil, nil, nil, fmt.Errorf("applying labels: %w", err) } logfn("[+] applied %d labels\n", len(specs.Labels)) } @@ -434,7 +435,7 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring packs, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyPacks(specs.Packs); err != nil { - return nil, nil, fmt.Errorf("applying packs: %w", err) + return nil, nil, nil, fmt.Errorf("applying packs: %w", err) } logfn("[+] applied %d packs\n", len(specs.Packs)) } @@ -454,7 +455,7 @@ func (c *Client) ApplyGroup( if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(windowsCustomSettings)+len(macosCustomSettings) > 0 { fileContents, err := getProfilesContents(baseDir, macosCustomSettings, windowsCustomSettings, opts.ExpandEnvConfigProfiles) if err != nil { - return nil, nil, err + return nil, nil, nil, err } // Figure out if MDM should be enabled. assumeEnabled := false @@ -468,30 +469,30 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyNoTeamProfiles(fileContents, opts.ApplySpecOptions, assumeEnabled); err != nil { - return nil, nil, fmt.Errorf("applying custom settings: %w", err) + return nil, nil, nil, fmt.Errorf("applying custom settings: %w", err) } } if macosSetup := extractAppCfgMacOSSetup(specs.AppConfig); macosSetup != nil { if macosSetup.BootstrapPackage.Value != "" { pkg, err := c.ValidateBootstrapPackageFromURL(macosSetup.BootstrapPackage.Value) if err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.EnsureBootstrapPackage(pkg, uint(0)); err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } } } if macosSetup.MacOSSetupAssistant.Value != "" { content, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, macosSetup.MacOSSetupAssistant.Value)) if err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } if !opts.DryRun { if err := c.uploadMacOSSetupAssistant(content, nil, macosSetup.MacOSSetupAssistant.Value); err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } } } @@ -502,19 +503,21 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, Name: filepath.Base(f), } } - if err := c.ApplyNoTeamScripts(scriptPayloads, opts.ApplySpecOptions); err != nil { - return nil, nil, fmt.Errorf("applying custom settings: %w", err) + noTeamScripts, err := c.ApplyNoTeamScripts(scriptPayloads, opts.ApplySpecOptions) + if err != nil { + return nil, nil, nil, fmt.Errorf("applying no-team scripts: %w", err) } + teamScripts["No team"] = noTeamScripts } if err := c.ApplyAppConfig(specs.AppConfig, opts.ApplySpecOptions); err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } if opts.DryRun { logfn("[+] would've applied fleet config\n") @@ -525,7 +528,7 @@ func (c *Client) ApplyGroup( if specs.EnrollSecret != nil { if err := c.ApplyEnrollSecretSpec(specs.EnrollSecret, opts.ApplySpecOptions); err != nil { - return nil, nil, fmt.Errorf("applying enroll secrets: %w", err) + return nil, nil, nil, fmt.Errorf("applying enroll secrets: %w", err) } if opts.DryRun { logfn("[+] would've applied enroll secrets\n") @@ -544,7 +547,7 @@ func (c *Client) ApplyGroup( for k, profileSpecs := range tmMDMSettings { fileContents, err := getProfilesContents(baseDir, profileSpecs.macos, profileSpecs.windows, opts.ExpandEnvConfigProfiles) if err != nil { - return nil, nil, fmt.Errorf("Team %s: %w", k, err) // TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once + return nil, nil, nil, fmt.Errorf("Team %s: %w", k, err) // TODO: consider adding team name to improve error messages generally for other parts of the config because multiple team configs can be processed at once } tmFileContents[k] = fileContents } @@ -556,14 +559,14 @@ func (c *Client) ApplyGroup( if setup.BootstrapPackage.Value != "" { bp, err := c.ValidateBootstrapPackageFromURL(setup.BootstrapPackage.Value) if err != nil { - return nil, nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, nil, fmt.Errorf("applying teams: %w", err) } tmBootstrapPackages[k] = bp } if setup.MacOSSetupAssistant.Value != "" { b, err := c.validateMacOSSetupAssistant(resolveApplyRelativePath(baseDir, setup.MacOSSetupAssistant.Value)) if err != nil { - return nil, nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, nil, fmt.Errorf("applying teams: %w", err) } tmMacSetupAssistants[k] = b } @@ -577,7 +580,7 @@ func (c *Client) ApplyGroup( for i, f := range files { b, err := os.ReadFile(f) if err != nil { - return nil, nil, fmt.Errorf("applying fleet config: %w", err) + return nil, nil, nil, fmt.Errorf("applying fleet config: %w", err) } scriptPayloads[i] = fleet.ScriptPayload{ ScriptContents: b, @@ -592,7 +595,7 @@ func (c *Client) ApplyGroup( for tmName, software := range tmSoftwarePackages { softwarePayloads, err := buildSoftwarePackagesPayload(baseDir, software) if err != nil { - return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } tmSoftwarePackagesPayloads[tmName] = softwarePayloads } @@ -617,7 +620,7 @@ func (c *Client) ApplyGroup( // In dry-run, the team names returned are the old team names (when team name is modified via gitops) teamIDsByName, err = c.ApplyTeams(specs.Teams, teamOpts) if err != nil { - return nil, nil, fmt.Errorf("applying teams: %w", err) + return nil, nil, nil, fmt.Errorf("applying teams: %w", err) } // When using GitOps, the team name could change, so we need to check for that @@ -644,7 +647,7 @@ func (c *Client) ApplyGroup( } else { logfn("[+] applying MDM profiles for team %s\n", tmName) if err := c.ApplyTeamProfiles(currentTeamName, profs, teamOpts); err != nil { - return nil, nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("applying custom settings for team %q: %w", tmName, err) } } } @@ -653,7 +656,7 @@ func (c *Client) ApplyGroup( for tmName, tmID := range teamIDsByName { if bp, ok := tmBootstrapPackages[tmName]; ok { if err := c.EnsureBootstrapPackage(bp, tmID); err != nil { - return nil, nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("uploading bootstrap package for team %q: %w", tmName, err) } } if b, ok := tmMacSetupAssistants[tmName]; ok { @@ -665,11 +668,11 @@ func (c *Client) ApplyGroup( // to render a more helpful error message. parts := strings.Split(err.Error(), ".") if len(parts) < 2 { - return nil, nil, fmt.Errorf("unexpected error while uploading macOS setup assistant for team %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("unexpected error while uploading macOS setup assistant for team %q: %w", tmName, err) } - return nil, nil, fmt.Errorf("Couldn't edit macos_setup_assistant. Response from Apple: %s. Learn more at %s", strings.Trim(parts[1], " "), "https://fleetdm.com/learn-more-about/dep-profile") + return nil, nil, nil, fmt.Errorf("Couldn't edit macos_setup_assistant. Response from Apple: %s. Learn more at %s", strings.Trim(parts[1], " "), "https://fleetdm.com/learn-more-about/dep-profile") } - return nil, nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("uploading macOS setup assistant for team %q: %w", tmName, err) } } } @@ -678,9 +681,11 @@ func (c *Client) ApplyGroup( for tmName, scripts := range tmScriptsPayloads { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) - if err := c.ApplyTeamScripts(currentTeamName, scripts, opts.ApplySpecOptions); err != nil { - return nil, nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) + scriptResponses, err := c.ApplyTeamScripts(currentTeamName, scripts, opts.ApplySpecOptions) + if err != nil { + return nil, nil, nil, fmt.Errorf("applying scripts for team %q: %w", tmName, err) } + teamScripts[tmName] = scriptResponses } } if len(tmSoftwarePackagesPayloads) > 0 { @@ -690,7 +695,7 @@ func (c *Client) ApplyGroup( logfn("[+] applying %d software packages for team %s\n", len(software), tmName) installers, err := c.ApplyTeamSoftwareInstallers(currentTeamName, software, opts.ApplySpecOptions) if err != nil { - return nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("applying software installers for team %q: %w", tmName, err) } teamSoftwareInstallers[tmName] = installers } @@ -700,7 +705,7 @@ func (c *Client) ApplyGroup( // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) if err := c.ApplyTeamAppStoreAppsAssociation(currentTeamName, apps, opts.ApplySpecOptions); err != nil { - return nil, nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) + return nil, nil, nil, fmt.Errorf("applying app store apps for team: %q: %w", tmName, err) } } } @@ -715,7 +720,7 @@ func (c *Client) ApplyGroup( if len(specs.Policies) > 0 { // Policy names must be unique, return error if duplicate policy names are found if policyName := fleet.FirstDuplicatePolicySpecName(specs.Policies); policyName != "" { - return nil, nil, fmt.Errorf( + return nil, nil, nil, fmt.Errorf( "applying policies: policy names must be unique. Please correct policy %q and try again.", policyName, ) } @@ -729,7 +734,7 @@ func (c *Client) ApplyGroup( } } if err := c.ApplyPolicies(specs.Policies); err != nil { - return nil, nil, fmt.Errorf("applying policies: %w", err) + return nil, nil, nil, fmt.Errorf("applying policies: %w", err) } logfn("[+] applied %d policies\n", len(specs.Policies)) } @@ -740,13 +745,13 @@ func (c *Client) ApplyGroup( logfn("[!] ignoring user roles, dry run mode only supported for 'config' and 'team' specs\n") } else { if err := c.ApplyUsersRoleSecretSpec(specs.UsersRoles); err != nil { - return nil, nil, fmt.Errorf("applying user roles: %w", err) + return nil, nil, nil, fmt.Errorf("applying user roles: %w", err) } logfn("[+] applied user roles\n") } } - return teamIDsByName, teamSoftwareInstallers, nil + return teamIDsByName, teamSoftwareInstallers, teamScripts, nil } func buildSoftwarePackagesPayload(baseDir string, specs []fleet.SoftwarePackageSpec) ([]fleet.SoftwareInstallerPayload, error) { @@ -1460,7 +1465,7 @@ func (c *Client) DoGitOps( } // Apply org settings, scripts, enroll secrets, team entities (software, scripts, etc.), and controls. - teamIDsByName, teamsSoftwareInstallers, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ + teamIDsByName, teamsSoftwareInstallers, teamsScripts, err := c.ApplyGroup(ctx, &group, baseDir, logf, appConfig, fleet.ApplyClientSpecOptions{ ApplySpecOptions: fleet.ApplySpecOptions{ DryRun: dryRun, }, @@ -1471,6 +1476,7 @@ func (c *Client) DoGitOps( } var teamSoftwareInstallers []fleet.SoftwarePackageResponse + var teamScripts []fleet.ScriptResponse if config.TeamName != nil { if !config.IsNoTeam() { if len(teamIDsByName) != 1 { @@ -1488,16 +1494,18 @@ func (c *Client) DoGitOps( config.TeamID = &teamID } teamSoftwareInstallers = teamsSoftwareInstallers[*config.TeamName] + teamScripts = teamsScripts[*config.TeamName] } else { noTeamSoftwareInstallers, err := c.doGitOpsNoTeamSoftware(config, baseDir, appConfig, logFn, dryRun) if err != nil { return nil, err } teamSoftwareInstallers = noTeamSoftwareInstallers + teamScripts = teamsScripts["No team"] } } - err = c.doGitOpsPolicies(config, teamSoftwareInstallers, logFn, dryRun) + err = c.doGitOpsPolicies(config, teamSoftwareInstallers, teamScripts, logFn, dryRun) if err != nil { return nil, err } @@ -1542,7 +1550,7 @@ func (c *Client) doGitOpsNoTeamSoftware(config *spec.GitOps, baseDir string, app return softwareInstallers, nil } -func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []fleet.SoftwarePackageResponse, logFn func(format string, args ...interface{}), dryRun bool) error { +func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []fleet.SoftwarePackageResponse, teamScripts []fleet.ScriptResponse, logFn func(format string, args ...interface{}), dryRun bool) error { var teamID *uint // Global policies (nil) switch { case config.TeamID != nil: // Team policies @@ -1550,8 +1558,8 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] case config.IsNoTeam(): // "No team" policies teamID = ptr.Uint(0) } - // Get software titles of packages for the team. if teamID != nil { + // Get software titles of packages for the team. softwareTitleURLs := make(map[string]uint) for _, softwareInstaller := range teamSoftwareInstallers { if softwareInstaller.TitleID == nil { @@ -1582,6 +1590,27 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers [] } config.Policies[i].SoftwareTitleID = &softwareTitleID } + + // Get scripts for the team. + scriptIDsByName := make(map[string]uint) + for _, script := range teamScripts { + scriptIDsByName[script.Name] = script.ID + } + for i := range config.Policies { + config.Policies[i].ScriptID = ptr.Uint(0) // 0 unsets the script + + if config.Policies[i].RunScript == nil { + continue + } + scriptID, ok := scriptIDsByName[*config.Policies[i].RunScriptName] + if !ok { + if !dryRun { // this shouldn't happen + logFn("[!] reference to an unknown script: %s\n", *config.Policies[i].RunScriptName) + } + continue + } + config.Policies[i].ScriptID = &scriptID + } } // Get the ids and names of current policies to figure out which ones to delete diff --git a/server/service/client_scripts.go b/server/service/client_scripts.go index faa488cea417..7a47c162e908 100644 --- a/server/service/client_scripts.go +++ b/server/service/client_scripts.go @@ -132,7 +132,10 @@ func (c *Client) pollForResult(id string) (*fleet.HostScriptResult, error) { // ApplyNoTeamScripts sends the list of scripts to be applied for the hosts in // no team. -func (c *Client) ApplyNoTeamScripts(scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error { +func (c *Client) ApplyNoTeamScripts(scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) ([]fleet.ScriptResponse, error) { verb, path := "POST", "/api/latest/fleet/scripts/batch" - return c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, nil, opts.RawQuery()) + var resp batchSetScriptsResponse + err := c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, &resp, opts.RawQuery()) + + return resp.Scripts, err } diff --git a/server/service/client_teams.go b/server/service/client_teams.go index 5d541e903c9b..6669e7c0c5ab 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -83,14 +83,17 @@ func (c *Client) ApplyTeamProfiles(tmName string, profiles []fleet.MDMProfileBat // ApplyTeamScripts sends the list of scripts to be applied for the specified // team. -func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) error { +func (c *Client) ApplyTeamScripts(tmName string, scripts []fleet.ScriptPayload, opts fleet.ApplySpecOptions) ([]fleet.ScriptResponse, error) { verb, path := "POST", "/api/latest/fleet/scripts/batch" query, err := url.ParseQuery(opts.RawQuery()) if err != nil { - return err + return nil, err } query.Add("team_name", tmName) - return c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, nil, query.Encode()) + + var resp batchSetScriptsResponse + err = c.authenticatedRequestWithQuery(map[string]interface{}{"scripts": scripts}, verb, path, &resp, query.Encode()) + return resp.Scripts, err } func (c *Client) ApplyTeamSoftwareInstallers(tmName string, softwareInstallers []fleet.SoftwareInstallerPayload, opts fleet.ApplySpecOptions) ([]fleet.SoftwarePackageResponse, error) { diff --git a/server/service/global_policies.go b/server/service/global_policies.go index 87c1d67152d4..f7ef7003673b 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -158,6 +158,9 @@ func (svc Service) GetPolicyByIDQueries(ctx context.Context, policyID uint) (*fl if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { return nil, ctxerr.Wrap(ctx, err, "populate install_software") } + if err := svc.populatePolicyRunScript(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate run_script") + } return policy, nil } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a1367e85c622..7fb0131c1bc7 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6286,7 +6286,7 @@ func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() { 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) + s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: nil}, http.StatusOK) } // TestGlobalPoliciesBrowsing tests that team users can browse (read) global policies (see #3722). diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 79f11cdbeb9e..942198e58eff 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -7595,13 +7595,15 @@ func (s *integrationEnterpriseTestSuite) TestBatchApplyScriptsEndpoints() { teamActivity = fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, team.Name) } - // create and check activities - s.Do("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: scripts}, http.StatusNoContent, "team_id", teamIDStr) + // create, check activities, and check scripts response + var scriptsBatchResponse batchSetScriptsResponse + s.DoJSON("POST", "/api/v1/fleet/scripts/batch", batchSetScriptsRequest{Scripts: scripts}, http.StatusOK, &scriptsBatchResponse, "team_id", teamIDStr) s.lastActivityMatches( fleet.ActivityTypeEditedScript{}.ActivityName(), teamActivity, 0, ) + require.Len(t, scriptsBatchResponse.Scripts, len(scripts)) // check that the right values got stored in the db var listResp listScriptsResponse @@ -14367,6 +14369,385 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.Nil(t, hostVanillaOsquery5Team1LastInstall) } +func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsScripts() { + t := s.T() + ctx := context.Background() + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + team2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team2"}) + require.NoError(t, err) + + newHost := func(name string, teamID *uint, platform string) *fleet.Host { + h, 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() + name), + NodeKey: ptr.String(t.Name() + name), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()), + Platform: platform, + TeamID: teamID, + }) + require.NoError(t, err) + return h + } + newFleetdHost := func(name string, teamID *uint, platform string) *fleet.Host { + h := newHost(name, teamID, platform) + orbitKey := setOrbitEnrollment(t, h, s.ds) + h.OrbitNodeKey = &orbitKey + return h + } + + host1Team1 := newFleetdHost("host1Team1", &team1.ID, "darwin") + host2Team1 := newFleetdHost("host2Team1", &team1.ID, "ubuntu") + host3Team2 := newFleetdHost("host3Team2", &team2.ID, "windows") + hostVanillaOsquery5Team1 := newHost("hostVanillaOsquery5Team2", &team1.ID, "darwin") + + // Upload script to team1. + script, err := s.ds.NewScript(ctx, &fleet.Script{ + Name: "unix-script.sh", + ScriptContents: "echo 'Hello World'", + TeamID: &team1.ID, + }) + require.NoError(t, err) + require.NotZero(t, script.ID) + + // Upload winScript to team1. + winScript, err := s.ds.NewScript(ctx, &fleet.Script{ + Name: "windows-script.ps1", + ScriptContents: "beep boop I am a windoge", + TeamID: &team1.ID, + }) + require.NoError(t, err) + require.NotZero(t, winScript.ID) + + // Upload script to team2. + psScript, err := s.ds.NewScript(ctx, &fleet.Script{ + Name: "windows-script.ps1", + ScriptContents: "beep boop I am a window", + TeamID: &team2.ID, + }) + require.NoError(t, err) + require.NotZero(t, psScript.ID) + + // craete a global policy that runs on all devices. + _, err = s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "policy0AllTeams", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy1Team1 runs on macOS devices. + policy1Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy1Team1", + Query: "SELECT 1;", + Platform: "darwin", + }) + require.NoError(t, err) + // policy2Team1 runs on macOS and Linux devices. + policy2Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy2Team1", + Query: "SELECT 2;", + Platform: "linux,darwin", + }) + require.NoError(t, err) + // policy3Team1 runs on all devices in team1 (will have no associated scripts). + policy3Team1, err := s.ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "policy3Team1", + Query: "SELECT 3;", + }) + require.NoError(t, err) + // policy4Team2 runs on Windows devices. + policy4Team2, err := s.ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "policy4Team2", + Query: "SELECT 4;", + Platform: "windows", + }) + require.NoError(t, err) + + // Attempt to associate to an unknown script. + mtplr := modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: ptr.Uint(999_999), + }, + }, http.StatusBadRequest, &mtplr) + // Associate first script to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: &script.ID, + }, + }, http.StatusOK, &mtplr) + // Change name only (to test not setting a script_id). + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), + json.RawMessage(`{"name": "policy1Team1_Renamed"}`), http.StatusOK, &mtplr, + ) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.ScriptID) + require.Equal(t, script.ID, *policy1Team1.ScriptID) + require.Equal(t, "policy1Team1_Renamed", *&policy1Team1.Name) + // Explicit set to 0 to disable. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Nil(t, policy1Team1.ScriptID) + + // Add some results and stats that should be cleared after updating the script + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(1), policy1Team1.FailingHostCount) + passes := true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &passes, + `SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`, + policy1Team1.ID, host1Team1.ID, + ) + }) + require.False(t, passes) + + // Back to associating the script with policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: &script.ID, + }, + }, http.StatusOK, &mtplr) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.ScriptID) + require.Equal(t, script.ID, *policy1Team1.ScriptID) + // Policy stats and membership should be cleared from policy1Team1. + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(0), policy1Team1.FailingHostCount) + countBiggerThanZero := true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + policy1Team1.ID, + ) + }) + require.False(t, countBiggerThanZero) + + // Add (again) some results and stats that should be cleared after changing an existing script. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(1), policy1Team1.FailingHostCount) + passes = true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &passes, + `SELECT passes FROM policy_membership WHERE policy_id = ? AND host_id = ?`, + policy1Team1.ID, host1Team1.ID, + ) + }) + require.False(t, passes) + + // Change the script (temporarily to test that changing a script will clear results) + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: &winScript.ID, + }, + }, http.StatusOK, &mtplr) + + // After changing the script, membership and stats should be cleared. + policy1Team1, err = s.ds.Policy(ctx, policy1Team1.ID) + require.NoError(t, err) + require.NotNil(t, policy1Team1.ScriptID) + require.Equal(t, winScript.ID, *policy1Team1.ScriptID) + // Policy stats and membership should be cleared from policy1Team1. + require.Equal(t, uint(0), policy1Team1.PassingHostCount) + require.Equal(t, uint(0), policy1Team1.FailingHostCount) + countBiggerThanZero = true + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, + &countBiggerThanZero, + `SELECT COUNT(*) > 0 FROM policy_membership WHERE policy_id = ?`, + policy1Team1.ID, + ) + }) + require.False(t, countBiggerThanZero) + + // Back to (again) associating first script to policy1Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy1Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: &script.ID, + }, + }, http.StatusOK, &mtplr) + + // Associate winScript to policy2Team1. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team1.ID, policy2Team1.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: &winScript.ID, + }, + }, http.StatusOK, &mtplr) + + // We use DoJSONWithoutAuth for distributed/write because we want the requests to not have the + // current user's "Authorization: Bearer " header. + + // host1Team1 fails all policies on the first report. + // Failing policy1Team1 means a script run must be generated. + // Failing policy2Team1 should not trigger a script run because it has a PowerShell script attached to it (doesn't apply to macOS). + // Failing policy3Team1 should do nothing because it doesn't have any scripts associated to it. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + hostPendingScript, err := s.ds.IsExecutionPendingForHost(ctx, host1Team1.ID, script.ID) + require.NoError(t, err) + require.True(t, hostPendingScript) + + // Request a manual script execution on the host for the same script, which should fail. + var scriptRunResp runScriptResponse + s.DoJSON("POST", "/api/latest/fleet/scripts/run", fleet.HostScriptRequestPayload{HostID: host1Team1.ID, ScriptID: &script.ID}, http.StatusConflict, &scriptRunResp) + + // Submit same results as before, which should not trigger a script run because the policy is already failing. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host1Team1.ID, script.ID) + require.NoError(t, err) + require.True(t, hostPendingScript) + + // Submit same results but policy1Team1 now passes, + // and then submit again but policy1Team1 fails. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(true), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host1Team1.ID, script.ID) + require.NoError(t, err) + require.True(t, hostPendingScript) + + // host2Team1 is failing policy2Team1 (incompatible) and policy3Team1 (no script) policies; no scripts should be queued + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host2Team1, + map[uint]*bool{ + policy2Team1.ID: ptr.Bool(false), + policy3Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host2Team1.ID, script.ID) + require.NoError(t, err) + require.False(t, hostPendingScript) + + // Associate psScript to policy4Team2. + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", team2.ID, policy4Team2.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + ScriptID: &psScript.ID, + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2, which should trigger a script run. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + hostPendingScript, err = s.ds.IsExecutionPendingForHost(ctx, host3Team2.ID, psScript.ID) + require.NoError(t, err) + require.True(t, hostPendingScript) + + // Unassociate 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{ + ScriptID: ptr.Uint(0), + }, + }, http.StatusOK, &mtplr) + + // host3Team2 reports a failing result for policy4Team2. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host3Team2, + map[uint]*bool{ + policy4Team2.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + + // hostVanillaOsquery5Team1 sends policy results with failed policies with associated scripts. + // Fleet should not queue scripts for vanilla osquery hosts. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + hostVanillaOsquery5Team1, + map[uint]*bool{ + policy1Team1.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + hostPendingScripts, err := s.ds.ListPendingHostScriptExecutions(ctx, hostVanillaOsquery5Team1.ID) + require.NoError(t, err) + require.Len(t, hostPendingScripts, 0) +} + func (s *integrationEnterpriseTestSuite) TestSoftwareInstallersWithoutBundleIdentifier() { t := s.T() ctx := context.Background() diff --git a/server/service/osquery.go b/server/service/osquery.go index de5c1e2e2688..8c2a630941e4 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1013,6 +1013,10 @@ func (svc *Service) SubmitDistributedQueryResults( logging.WithErr(ctx, err) } + if err := svc.processScriptsForNewlyFailingPolicies(ctx, host.ID, host.TeamID, host.Platform, host.OrbitNodeKey, host.ScriptsEnabled, policyResults); err != nil { + logging.WithErr(ctx, err) + } + // filter policy results for webhooks var policyIDs []uint if globalPolicyAutomationsEnabled(ac.WebhookSettings, ac.Integrations) { @@ -1830,6 +1834,181 @@ func (svc *Service) processSoftwareForNewlyFailingPolicies( return nil } +func (svc *Service) processScriptsForNewlyFailingPolicies( + ctx context.Context, + hostID uint, + hostTeamID *uint, + hostPlatform string, + hostOrbitNodeKey *string, + hostScriptsEnabled *bool, + incomingPolicyResults map[uint]*bool, +) error { + if hostOrbitNodeKey == nil || *hostOrbitNodeKey == "" { + return nil // vanilla osquery hosts can't run scripts + } + // not logging here to avoid spamming logs on every policy failure for every no-scripts host even if the policy + // doesn't have a script attached + if hostScriptsEnabled != nil && !*hostScriptsEnabled { + return nil + } + + // Bail if scripts are disabled globally + cfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return err + } + if cfg.ServerSettings.ScriptsDisabled { + return nil + } + + var policyTeamID uint + if hostTeamID == nil { + policyTeamID = fleet.PolicyNoTeamID + } else { + policyTeamID = *hostTeamID + } + + // Filter out results that are not failures (we are only interested on failing policies, + // we don't care about passing policies or policies that failed to execute). + incomingFailingPolicies := make(map[uint]*bool) + var incomingFailingPoliciesIDs []uint + for policyID, policyResult := range incomingPolicyResults { + if policyResult != nil && !*policyResult { + incomingFailingPolicies[policyID] = policyResult + incomingFailingPoliciesIDs = append(incomingFailingPoliciesIDs, policyID) + } + } + if len(incomingFailingPolicies) == 0 { + return nil + } + + // Get policies with associated scripts for the team. + policiesWithScript, err := svc.ds.GetPoliciesWithAssociatedScript(ctx, policyTeamID, incomingFailingPoliciesIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get policies with script") + } + if len(policiesWithScript) == 0 { + return nil + } + + // Filter out results of policies that are not associated to scripts. + policiesWithScriptsMap := make(map[uint]fleet.PolicyScriptData) + for _, policyWithScript := range policiesWithScript { + policiesWithScriptsMap[policyWithScript.ID] = policyWithScript + } + policyResultsOfPoliciesWithScripts := make(map[uint]*bool) + for policyID, passes := range incomingFailingPolicies { + if _, ok := policiesWithScriptsMap[policyID]; !ok { + continue + } + policyResultsOfPoliciesWithScripts[policyID] = passes + } + if len(policyResultsOfPoliciesWithScripts) == 0 { + return nil + } + + // Get the policies associated with scripts that are flipping from passing to failing on this host. + policyIDsOfNewlyFailingPoliciesWithScripts, _, err := svc.ds.FlippingPoliciesForHost( + ctx, hostID, policyResultsOfPoliciesWithScripts, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get flipping policies for host") + } + if len(policyIDsOfNewlyFailingPoliciesWithScripts) == 0 { + return nil + } + policyIDsOfNewlyFailingPoliciesWithScriptsSet := make(map[uint]struct{}) + for _, policyID := range policyIDsOfNewlyFailingPoliciesWithScripts { + policyIDsOfNewlyFailingPoliciesWithScriptsSet[policyID] = struct{}{} + } + + // Finally filter out policies with scripts that are not newly failing. + var failingPoliciesWithScript []fleet.PolicyScriptData + for _, policyWithScript := range policiesWithScript { + if _, ok := policyIDsOfNewlyFailingPoliciesWithScriptsSet[policyWithScript.ID]; ok { + failingPoliciesWithScript = append(failingPoliciesWithScript, policyWithScript) + } + } + + for _, failingPolicyWithScript := range failingPoliciesWithScript { + scriptMetadata, err := svc.ds.Script(ctx, failingPolicyWithScript.ScriptID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get script metadata by id") + } + logger := log.With(svc.logger, + "host_id", hostID, + "host_platform", hostPlatform, + "policy_id", failingPolicyWithScript.ID, + "script_id", failingPolicyWithScript.ScriptID, + "script_name", scriptMetadata.Name, + ) + + allScriptsExecutionPending, err := svc.ds.ListPendingHostScriptExecutions(ctx, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "list host pending script executions") + } + if len(allScriptsExecutionPending) > maxPendingScripts { + level.Warn(logger).Log("msg", "too many scripts pending for host") + return nil + } + + // skip incompatible scripts + hostPlatform := fleet.PlatformFromHost(hostPlatform) + if (hostPlatform == "windows" && strings.HasSuffix(scriptMetadata.Name, ".sh")) || + (hostPlatform != "windows" && strings.HasSuffix(scriptMetadata.Name, ".ps1")) { + level.Info(logger).Log("msg", "script type does not match host platform") + continue + } + + // skip different-team scripts + var scriptTeamID uint + if scriptMetadata.TeamID != nil { + scriptTeamID = *scriptMetadata.TeamID + } + if policyTeamID != scriptTeamID { // this should not happen + level.Error(logger).Log("msg", "script team does not match host team") + continue + } + + scriptIsAlreadyPending, err := svc.ds.IsExecutionPendingForHost(ctx, hostID, scriptMetadata.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "check whether script is pending execution") + } + if scriptIsAlreadyPending { + level.Debug(logger).Log("msg", "script is already pending on host") + continue + } + + contents, err := svc.ds.GetScriptContents(ctx, scriptMetadata.ID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get script contents") + } + runScriptRequest := fleet.HostScriptRequestPayload{ + HostID: hostID, + ScriptContents: string(contents), + ScriptContentID: scriptMetadata.ScriptContentID, + ScriptID: &scriptMetadata.ID, + TeamID: policyTeamID, + // no user ID as scripts are executed by Fleet + } + + scriptResult, err := svc.ds.NewHostScriptExecutionRequest(ctx, &runScriptRequest) + if err != nil { + return ctxerr.Wrapf(ctx, err, + "insert script run request; host_id=%d, script_id=%d", + hostID, scriptMetadata.ID, + ) + } + + level.Debug(logger).Log( + "msg", "script run request sent", + "execution_id", scriptResult.ExecutionID, + ) + } + + return nil +} + func (svc *Service) maybeDebugHost( ctx context.Context, host *fleet.Host, diff --git a/server/service/scripts.go b/server/service/scripts.go index 14d39e496476..9428d3215681 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -241,12 +241,12 @@ func (svc *Service) RunHostScript(ctx context.Context, request *fleet.HostScript 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) + isQueued, err := svc.ds.IsExecutionPendingForHost(ctx, request.HostID, *request.ScriptID) if err != nil { return nil, err } - if len(r) > 0 { + if isQueued { return nil, fleet.NewInvalidArgumentError("script_id", `The script is already queued on the given host.`).WithStatus(http.StatusConflict) } @@ -804,25 +804,25 @@ type batchSetScriptsRequest struct { } type batchSetScriptsResponse struct { - Err error `json:"error,omitempty"` + Scripts []fleet.ScriptResponse `json:"scripts"` + Err error `json:"error,omitempty"` } func (r batchSetScriptsResponse) error() error { return r.Err } -func (r batchSetScriptsResponse) Status() int { return http.StatusNoContent } - func batchSetScriptsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*batchSetScriptsRequest) - if err := svc.BatchSetScripts(ctx, req.TeamID, req.TeamName, req.Scripts, req.DryRun); err != nil { + scriptList, err := svc.BatchSetScripts(ctx, req.TeamID, req.TeamName, req.Scripts, req.DryRun) + if err != nil { return batchSetScriptsResponse{Err: err}, nil } - return batchSetScriptsResponse{}, nil + return batchSetScriptsResponse{Scripts: scriptList}, nil } -func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) error { +func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeTmName *string, payloads []fleet.ScriptPayload, dryRun bool) ([]fleet.ScriptResponse, 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")) + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("team_name", "cannot specify both team_id and team_name")) } var teamID *uint @@ -833,16 +833,16 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT if err != nil { // If this is a dry run, the team may not have been created yet if dryRun && fleet.IsNotFound(err) { - return nil + return nil, nil } - return err + return nil, 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) + return nil, ctxerr.Wrap(ctx, err) } // any duplicate name in the provided set results in an error @@ -856,12 +856,12 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT } if err := script.ValidateNewScript(); err != nil { - return ctxerr.Wrap(ctx, + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("scripts[%d]", i), err.Error())) } if byName[script.Name] { - return ctxerr.Wrap(ctx, + return nil, 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") } @@ -870,11 +870,12 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT } if dryRun { - return nil + return nil, nil } - if err := svc.ds.BatchSetScripts(ctx, teamID, scripts); err != nil { - return ctxerr.Wrap(ctx, err, "batch saving scripts") + scriptResponses, err := svc.ds.BatchSetScripts(ctx, teamID, scripts) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "batch saving scripts") } if err := svc.NewActivity( @@ -882,9 +883,9 @@ func (svc *Service) BatchSetScripts(ctx context.Context, maybeTmID *uint, maybeT TeamID: teamID, TeamName: teamName, }); err != nil { - return ctxerr.Wrap(ctx, err, "logging activity for edited scripts") + return nil, ctxerr.Wrap(ctx, err, "logging activity for edited scripts") } - return nil + return scriptResponses, nil } func (svc *Service) authorizeScriptByID(ctx context.Context, scriptID uint, authzAction string) (*fleet.Script, error) { diff --git a/server/service/scripts_test.go b/server/service/scripts_test.go index aecf67dea446..404f34570489 100644 --- a/server/service/scripts_test.go +++ b/server/service/scripts_test.go @@ -59,7 +59,7 @@ func TestHostRunScript(t *testing.T) { 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 } + ds.IsExecutionPendingForHostFunc = func(ctx context.Context, hostID, scriptID uint) (bool, error) { return false, nil } t.Run("authorization checks", func(t *testing.T) { testCases := []struct { diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 14cea750e1a0..12427dc52871 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -30,6 +30,7 @@ type teamPolicyRequest struct { Critical bool `json:"critical" premium:"true"` CalendarEventsEnabled bool `json:"calendar_events_enabled"` SoftwareTitleID *uint `json:"software_title_id"` + ScriptID *uint `json:"script_id"` } type teamPolicyResponse struct { @@ -51,6 +52,7 @@ func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Serv Critical: req.Critical, CalendarEventsEnabled: req.CalendarEventsEnabled, SoftwareTitleID: req.SoftwareTitleID, + ScriptID: req.ScriptID, }) if err != nil { return teamPolicyResponse{Err: err}, nil @@ -90,6 +92,9 @@ func (svc Service) NewTeamPolicy(ctx context.Context, teamID uint, tp fleet.NewT if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { return nil, ctxerr.Wrap(ctx, err, "populate install_software") } + if err := svc.populatePolicyRunScript(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate run_script") + } // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity @@ -121,6 +126,18 @@ func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet. return nil } +func (svc *Service) populatePolicyRunScript(ctx context.Context, p *fleet.Policy) error { + if p.ScriptID == nil { + return nil + } + scriptMetadata, err := svc.ds.Script(ctx, *p.ScriptID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get script metadata by id") + } + p.RunScript = &fleet.PolicyScript{ID: *p.ScriptID, Name: scriptMetadata.Name} + return nil +} + func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, teamID uint, p fleet.NewTeamPolicyPayload) (fleet.PolicyPayload, error) { softwareInstallerID, err := svc.deduceSoftwareInstallerIDFromTitleID(ctx, &teamID, p.SoftwareTitleID) if err != nil { @@ -136,6 +153,7 @@ func (svc *Service) newTeamPolicyPayloadToPolicyPayload(ctx context.Context, tea Platform: p.Platform, CalendarEventsEnabled: p.CalendarEventsEnabled, SoftwareInstallerID: softwareInstallerID, + ScriptID: p.ScriptID, }, nil } @@ -199,6 +217,9 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee if err := svc.populatePolicyInstallSoftware(ctx, policies[i]); err != nil { return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", policies[i].ID) } + if err := svc.populatePolicyRunScript(ctx, policies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate run_script for policy_id: %d", policies[i].ID) + } } return policies, nil, err } @@ -212,6 +233,9 @@ func (svc *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts flee if err := svc.populatePolicyInstallSoftware(ctx, teamPolicies[i]); err != nil { return nil, nil, ctxerr.Wrapf(ctx, err, "populate install_software for policy_id: %d", teamPolicies[i].ID) } + if err := svc.populatePolicyRunScript(ctx, teamPolicies[i]); err != nil { + return nil, nil, ctxerr.Wrapf(ctx, err, "populate run_script for policy_id: %d", teamPolicies[i].ID) + } } return teamPolicies, inheritedPolicies, nil @@ -307,6 +331,9 @@ func (svc Service) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, po if err := svc.populatePolicyInstallSoftware(ctx, teamPolicy); err != nil { return nil, ctxerr.Wrap(ctx, err, "populate install_software") } + if err := svc.populatePolicyRunScript(ctx, teamPolicy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate run_script") + } return teamPolicy, nil } @@ -502,6 +529,21 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f } policy.SoftwareInstallerID = softwareInstallerID } + if p.ScriptID != nil { // indicates that script ID is changing, but might be to 0 to remove + // If the associated script is changed (or it's set and the policy didn't have an associated script) + // then we clear the results of the policy so that automation can be triggered upon failure + // (automation is currently triggered on the first failure or when it goes from passing to failure). + if *p.ScriptID != 0 && (policy.ScriptID == nil || *policy.ScriptID != *p.ScriptID) { + removeAllMemberships = true + removeStats = true + } + + if *p.ScriptID == 0 { + policy.ScriptID = nil + } else { + policy.ScriptID = p.ScriptID + } + } logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query) @@ -513,6 +555,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if err := svc.populatePolicyInstallSoftware(ctx, policy); err != nil { return nil, ctxerr.Wrap(ctx, err, "populate install_software") } + if err := svc.populatePolicyRunScript(ctx, policy); err != nil { + return nil, ctxerr.Wrap(ctx, err, "populate run_script") + } // Note: Issue #4191 proposes that we move to SQL transactions for actions so that we can // rollback an action in the event of an error writing the associated activity diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 48476f71625e..2250f4cf3331 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -161,6 +161,12 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { return err }) + // Clean scripts in "No team" (the others are deleted in ts.ds.DeleteTeam above). + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM scripts WHERE global_or_team_id = 0;`) + return err + }) + globalPolicies, err := ts.ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) require.NoError(t, err) if len(globalPolicies) > 0 {