Skip to content

Commit ebd2cb0

Browse files
authored
Fix patch policy bugs (#43420)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #43389 1. Added verifyPatchPolicy check 2. Fixed nil pointer dereference when calling spec/policies with no fleet_maintained_app_slug key provided 3. Fixed bug where renaming a patch policy in a gitops file caused it to be deleted on the first run, and only added when gitops is run again. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Renaming a patch policy via GitOps now updates the existing policy instead of deleting it. * Fixed nil-pointer errors in policy API operations. * Reject applying patch policies with missing, invalid, or disallowed Fleet Maintained App references (including global/enterprise slugs). * Improved matching for patch policies to avoid unintended deletions when names differ. * Patch policies now preserve intended platform/target behavior during apply/update. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c9e66b2 commit ebd2cb0

6 files changed

Lines changed: 163 additions & 1 deletion

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- Fixed bug where renaming a patch policy in a GitOps file caused it to be deleted initially.
2+
- Fixed a nil pointer dereference in the contributor api spec/policies.

server/datastore/mysql/policies.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
14781478

14791479
// generate new up-to-date patch policy
14801480
if spec.Type == fleet.PolicyTypePatch {
1481+
if fmaTitleID == nil {
1482+
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
1483+
Message: fmt.Sprintf("fleet_maintained_app_slug must be set for patch policy: %s", spec.Name),
1484+
})
1485+
}
14811486
installer, err := ds.getPatchPolicyInstaller(ctx, ptr.ValOrZero(teamID), *fmaTitleID)
14821487
if err != nil {
14831488
return ctxerr.Wrap(ctx, err, "getting patch policy installer")
@@ -1517,6 +1522,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
15171522
var (
15181523
shouldRemoveAllPolicyMemberships bool
15191524
removePolicyStats bool
1525+
shouldUpdatePatchPolicyName bool
15201526
)
15211527
if insertOnDuplicateDidInsertOrUpdate(res) {
15221528
// Figure out if the query, platform, software installer, VPP app, or script changed.
@@ -1548,7 +1554,16 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
15481554
if teamID == nil {
15491555
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE name = ? AND team_id is NULL", spec.Name)
15501556
} else {
1551-
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE name = ? AND team_id = ?", spec.Name, teamID)
1557+
// Patch policies are unique by patch_software_title_id so we need to get them by that, and update their name
1558+
// so that it doesn't get deleted later.
1559+
if spec.Type == fleet.PolicyTypePatch {
1560+
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE patch_software_title_id = ? AND team_id = ?", fmaTitleID, teamID)
1561+
if _, ok := teamIDToPoliciesByName[teamID][spec.Name]; !ok {
1562+
shouldUpdatePatchPolicyName = true
1563+
}
1564+
} else {
1565+
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE name = ? AND team_id = ?", spec.Name, teamID)
1566+
}
15521567
}
15531568
if err != nil {
15541569
return ctxerr.Wrap(ctx, err, "select policies id")
@@ -1595,6 +1610,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
15951610
return ctxerr.Wrap(ctx, err, "setting needs_full_membership_cleanup flag")
15961611
}
15971612
}
1613+
if shouldUpdatePatchPolicyName {
1614+
if _, err := tx.ExecContext(ctx, `UPDATE policies SET name = ?, checksum = `+policiesChecksumComputedColumn()+` WHERE id = ?`, spec.Name, policyID); err != nil {
1615+
return ctxerr.Wrap(ctx, err, "setting name for patch policy")
1616+
}
1617+
}
15981618
// Defer cleanup outside the transaction to avoid long-held row locks on
15991619
// policy_membership.
16001620
pendingCleanups = append(pendingCleanups, policyCleanupArgs{

server/datastore/mysql/policies_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7720,6 +7720,108 @@ func testTeamPatchPolicy(t *testing.T, ds *Datastore) {
77207720
require.Equal(t, "Windows - Maintained2 up to date", p5.Name)
77217721
require.Equal(t, "windows", p5.Platform)
77227722
require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Maintained2' AND version_compare(version, '1.0') < 0);", p5.Query)
7723+
7724+
//////////////////////////////////////////////
7725+
// ApplyPolicySpecs
7726+
7727+
// Test ApplyPolicySpecs with patch policies using a separate team.
7728+
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
7729+
require.NoError(t, err)
7730+
7731+
// Set up an FMA installer on team2 for the valid slug test.
7732+
team2Payload := &fleet.UploadSoftwareInstallerPayload{
7733+
InstallScript: "hello",
7734+
PreInstallQuery: "SELECT 1",
7735+
PostInstallScript: "world",
7736+
StorageID: "storage-team2",
7737+
Filename: "maintained1-team2",
7738+
Title: "Maintained1",
7739+
Version: "1.0",
7740+
Source: "apps",
7741+
Platform: "darwin",
7742+
BundleIdentifier: "fleet.maintained1",
7743+
UserID: user1.ID,
7744+
TeamID: &team2.ID,
7745+
ValidatedLabels: &fleet.LabelIdentsWithScope{},
7746+
FleetMaintainedAppID: &maintainedApp.ID,
7747+
}
7748+
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, team2Payload)
7749+
require.NoError(t, err)
7750+
7751+
// ApplyPolicySpecs with type=patch and empty fleet_maintained_app_slug should return an error.
7752+
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
7753+
{
7754+
Name: "patch-no-slug",
7755+
Query: "SELECT 1;",
7756+
Team: "team2",
7757+
Type: fleet.PolicyTypePatch,
7758+
},
7759+
})
7760+
require.Error(t, err)
7761+
7762+
// ApplyPolicySpecs with type=patch and a non-existent fleet_maintained_app_slug should return an error.
7763+
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
7764+
{
7765+
Name: "patch-bad-slug",
7766+
Query: "SELECT 1;",
7767+
Team: "team2",
7768+
Type: fleet.PolicyTypePatch,
7769+
FleetMaintainedAppSlug: "nonexistent-slug",
7770+
},
7771+
})
7772+
require.Error(t, err)
7773+
7774+
// ApplyPolicySpecs with type=patch and a valid fleet_maintained_app_slug should succeed.
7775+
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
7776+
{
7777+
Name: "patch-valid-slug",
7778+
Query: "SELECT 1;",
7779+
Team: "team2",
7780+
Type: fleet.PolicyTypePatch,
7781+
FleetMaintainedAppSlug: "maintained1",
7782+
},
7783+
})
7784+
require.NoError(t, err)
7785+
7786+
// Verify the policy was created with the expected auto-generated query and platform.
7787+
policies, _, err := ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}, "")
7788+
require.NoError(t, err)
7789+
require.Len(t, policies, 1)
7790+
require.Equal(t, "patch-valid-slug", policies[0].Name)
7791+
require.Equal(t, fleet.PolicyTypePatch, policies[0].Type)
7792+
require.Equal(t, "darwin", policies[0].Platform)
7793+
require.Contains(t, policies[0].Query, "fleet.maintained1")
7794+
7795+
// Renaming a patch policy via ApplyPolicySpecs should update it, not delete it.
7796+
previousID := policies[0].ID
7797+
var previousChecksum []byte
7798+
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
7799+
return sqlx.GetContext(ctx, q, &previousChecksum, `SELECT checksum FROM policies WHERE id = ?`, previousID)
7800+
})
7801+
7802+
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
7803+
{
7804+
Name: "patch-renamed",
7805+
Query: "SELECT 1;",
7806+
Team: "team2",
7807+
Type: fleet.PolicyTypePatch,
7808+
FleetMaintainedAppSlug: "maintained1",
7809+
},
7810+
})
7811+
require.NoError(t, err)
7812+
7813+
policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}, "")
7814+
require.NoError(t, err)
7815+
require.Len(t, policies, 1)
7816+
require.Equal(t, previousID, policies[0].ID)
7817+
require.Equal(t, "patch-renamed", policies[0].Name)
7818+
require.Equal(t, fleet.PolicyTypePatch, policies[0].Type)
7819+
7820+
var newChecksum []byte
7821+
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
7822+
return sqlx.GetContext(ctx, q, &newChecksum, `SELECT checksum FROM policies WHERE id = ?`, previousID)
7823+
})
7824+
require.NotEqual(t, previousChecksum, newChecksum)
77237825
}
77247826

77257827
func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) {

server/fleet/policies.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ var (
117117
errPolicyPatchAndQuerySet = errors.New("If the \"type\" is \"patch\", the \"query\" field is not supported.")
118118
errPolicyPatchAndPlatformSet = errors.New("If the \"type\" is \"patch\", the \"platform\" field is not supported.")
119119
errPolicyPatchNoTitleID = errors.New("If the \"type\" is \"patch\", the \"patch_software_title_id\" field is required.")
120+
errPatchPolicyRequiresTeam = errors.New("If the \"type\" is \"patch\", the \"team\" field is required.")
120121
errPolicyQueryUpdated = errors.New("\"query\" can't be updated")
121122
errPolicyPlatformUpdated = errors.New("\"platform\" can't be updated")
122123
errPolicyConditionalAccessEnabledInvalidPlatform = errors.New("\"conditional_access_enabled\" is only valid on \"darwin\" and \"windows\" policies")
@@ -207,6 +208,13 @@ func verifyPolicyPlatforms(platforms string) error {
207208
return nil
208209
}
209210

211+
func verifyPatchPolicy(team string, typ string) error {
212+
if typ == PolicyTypePatch && emptyString(team) {
213+
return errPatchPolicyRequiresTeam
214+
}
215+
return nil
216+
}
217+
210218
func PolicyVerifyConditionalAccess(conditionalAccessEnabled bool, platform string) error {
211219
if conditionalAccessEnabled && !strings.Contains(platform, "darwin") && !strings.Contains(platform, "windows") {
212220
return errPolicyConditionalAccessEnabledInvalidPlatform
@@ -473,6 +481,7 @@ type PolicySpec struct {
473481

474482
Type string `json:"type"`
475483
FleetMaintainedAppSlug string `json:"fleet_maintained_app_slug"`
484+
PatchSoftwareTitleID uint `json:"-"`
476485
}
477486

478487
// PolicySoftwareTitle contains software title data for policies.
@@ -507,6 +516,9 @@ func (p PolicySpec) Verify() error {
507516
if err := PolicyVerifyConditionalAccess(p.ConditionalAccessEnabled, p.Platform); err != nil {
508517
return err
509518
}
519+
if err := verifyPatchPolicy(p.Team, p.Type); err != nil {
520+
return err
521+
}
510522
return nil
511523
}
512524

server/service/client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3000,6 +3000,11 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
30003000
}
30013001
config.Policies[i].ScriptID = &scriptID
30023002
}
3003+
3004+
// Get patch policy title IDs for the team
3005+
for i := range config.Policies {
3006+
config.Policies[i].PatchSoftwareTitleID = softwareTitleIDsBySlug[config.Policies[i].FleetMaintainedAppSlug]
3007+
}
30033008
}
30043009

30053010
// Get the ids and names of current policies to figure out which ones to delete
@@ -3037,6 +3042,7 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
30373042
}
30383043
}
30393044
}
3045+
30403046
var policiesToDelete []uint
30413047
for _, oldItem := range policies {
30423048
found := false
@@ -3045,6 +3051,13 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
30453051
found = true
30463052
break
30473053
}
3054+
// patch policies are unique by patch_software_title_id so matching by name doesn't always work
3055+
if newItem.Type == fleet.PolicyTypePatch && oldItem.PatchSoftware != nil {
3056+
if oldItem.PatchSoftware.SoftwareTitleID == newItem.PatchSoftwareTitleID {
3057+
found = true
3058+
break
3059+
}
3060+
}
30483061
}
30493062
if !found {
30503063
policiesToDelete = append(policiesToDelete, oldItem.ID)

server/service/integration_enterprise_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27438,6 +27438,19 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() {
2743827438
require.Equal(t, "dynamic", getPolicyResp.Policy.Type)
2743927439
require.Empty(t, getPolicyResp.Policy.PatchSoftware)
2744027440
require.Empty(t, getPolicyResp.Policy.PatchSoftwareTitleID)
27441+
27442+
// Should not be able to apply global patch policy
27443+
spec := &fleet.PolicySpec{
27444+
Name: "team patch policy",
27445+
Query: "SELECT 1",
27446+
Type: fleet.PolicyTypePatch,
27447+
FleetMaintainedAppSlug: "zoom/windows",
27448+
}
27449+
applyResp := fleet.ApplyPolicySpecsResponse{}
27450+
s.DoJSON("POST", "/api/latest/fleet/spec/policies",
27451+
fleet.ApplyPolicySpecsRequest{Specs: []*fleet.PolicySpec{spec}},
27452+
http.StatusBadRequest, &applyResp,
27453+
)
2744127454
})
2744227455

2744327456
t.Run("patch_policy_lifecycle", func(t *testing.T) {

0 commit comments

Comments
 (0)