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 -->
This commit is contained in:
Jonathan Katz 2026-04-10 21:42:14 -04:00 committed by GitHub
parent c9e66b221e
commit ebd2cb0012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 163 additions and 1 deletions

View file

@ -0,0 +1,2 @@
- Fixed bug where renaming a patch policy in a GitOps file caused it to be deleted initially.
- Fixed a nil pointer dereference in the contributor api spec/policies.

View file

@ -1478,6 +1478,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
// generate new up-to-date patch policy
if spec.Type == fleet.PolicyTypePatch {
if fmaTitleID == nil {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("fleet_maintained_app_slug must be set for patch policy: %s", spec.Name),
})
}
installer, err := ds.getPatchPolicyInstaller(ctx, ptr.ValOrZero(teamID), *fmaTitleID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting patch policy installer")
@ -1517,6 +1522,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
var (
shouldRemoveAllPolicyMemberships bool
removePolicyStats bool
shouldUpdatePatchPolicyName bool
)
if insertOnDuplicateDidInsertOrUpdate(res) {
// 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
if teamID == nil {
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE name = ? AND team_id is NULL", spec.Name)
} else {
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE name = ? AND team_id = ?", spec.Name, teamID)
// Patch policies are unique by patch_software_title_id so we need to get them by that, and update their name
// so that it doesn't get deleted later.
if spec.Type == fleet.PolicyTypePatch {
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE patch_software_title_id = ? AND team_id = ?", fmaTitleID, teamID)
if _, ok := teamIDToPoliciesByName[teamID][spec.Name]; !ok {
shouldUpdatePatchPolicyName = true
}
} else {
err = sqlx.GetContext(ctx, tx, &lastID, "SELECT id FROM policies WHERE name = ? AND team_id = ?", spec.Name, teamID)
}
}
if err != nil {
return ctxerr.Wrap(ctx, err, "select policies id")
@ -1595,6 +1610,11 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
return ctxerr.Wrap(ctx, err, "setting needs_full_membership_cleanup flag")
}
}
if shouldUpdatePatchPolicyName {
if _, err := tx.ExecContext(ctx, `UPDATE policies SET name = ?, checksum = `+policiesChecksumComputedColumn()+` WHERE id = ?`, spec.Name, policyID); err != nil {
return ctxerr.Wrap(ctx, err, "setting name for patch policy")
}
}
// Defer cleanup outside the transaction to avoid long-held row locks on
// policy_membership.
pendingCleanups = append(pendingCleanups, policyCleanupArgs{

View file

@ -7720,6 +7720,108 @@ func testTeamPatchPolicy(t *testing.T, ds *Datastore) {
require.Equal(t, "Windows - Maintained2 up to date", p5.Name)
require.Equal(t, "windows", p5.Platform)
require.Equal(t, "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Maintained2' AND version_compare(version, '1.0') < 0);", p5.Query)
//////////////////////////////////////////////
// ApplyPolicySpecs
// Test ApplyPolicySpecs with patch policies using a separate team.
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
// Set up an FMA installer on team2 for the valid slug test.
team2Payload := &fleet.UploadSoftwareInstallerPayload{
InstallScript: "hello",
PreInstallQuery: "SELECT 1",
PostInstallScript: "world",
StorageID: "storage-team2",
Filename: "maintained1-team2",
Title: "Maintained1",
Version: "1.0",
Source: "apps",
Platform: "darwin",
BundleIdentifier: "fleet.maintained1",
UserID: user1.ID,
TeamID: &team2.ID,
ValidatedLabels: &fleet.LabelIdentsWithScope{},
FleetMaintainedAppID: &maintainedApp.ID,
}
_, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, team2Payload)
require.NoError(t, err)
// ApplyPolicySpecs with type=patch and empty fleet_maintained_app_slug should return an error.
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
{
Name: "patch-no-slug",
Query: "SELECT 1;",
Team: "team2",
Type: fleet.PolicyTypePatch,
},
})
require.Error(t, err)
// ApplyPolicySpecs with type=patch and a non-existent fleet_maintained_app_slug should return an error.
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
{
Name: "patch-bad-slug",
Query: "SELECT 1;",
Team: "team2",
Type: fleet.PolicyTypePatch,
FleetMaintainedAppSlug: "nonexistent-slug",
},
})
require.Error(t, err)
// ApplyPolicySpecs with type=patch and a valid fleet_maintained_app_slug should succeed.
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
{
Name: "patch-valid-slug",
Query: "SELECT 1;",
Team: "team2",
Type: fleet.PolicyTypePatch,
FleetMaintainedAppSlug: "maintained1",
},
})
require.NoError(t, err)
// Verify the policy was created with the expected auto-generated query and platform.
policies, _, err := ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, policies, 1)
require.Equal(t, "patch-valid-slug", policies[0].Name)
require.Equal(t, fleet.PolicyTypePatch, policies[0].Type)
require.Equal(t, "darwin", policies[0].Platform)
require.Contains(t, policies[0].Query, "fleet.maintained1")
// Renaming a patch policy via ApplyPolicySpecs should update it, not delete it.
previousID := policies[0].ID
var previousChecksum []byte
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &previousChecksum, `SELECT checksum FROM policies WHERE id = ?`, previousID)
})
err = ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
{
Name: "patch-renamed",
Query: "SELECT 1;",
Team: "team2",
Type: fleet.PolicyTypePatch,
FleetMaintainedAppSlug: "maintained1",
},
})
require.NoError(t, err)
policies, _, err = ds.ListTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, fleet.ListOptions{}, "")
require.NoError(t, err)
require.Len(t, policies, 1)
require.Equal(t, previousID, policies[0].ID)
require.Equal(t, "patch-renamed", policies[0].Name)
require.Equal(t, fleet.PolicyTypePatch, policies[0].Type)
var newChecksum []byte
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &newChecksum, `SELECT checksum FROM policies WHERE id = ?`, previousID)
})
require.NotEqual(t, previousChecksum, newChecksum)
}
func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) {

View file

@ -117,6 +117,7 @@ var (
errPolicyPatchAndQuerySet = errors.New("If the \"type\" is \"patch\", the \"query\" field is not supported.")
errPolicyPatchAndPlatformSet = errors.New("If the \"type\" is \"patch\", the \"platform\" field is not supported.")
errPolicyPatchNoTitleID = errors.New("If the \"type\" is \"patch\", the \"patch_software_title_id\" field is required.")
errPatchPolicyRequiresTeam = errors.New("If the \"type\" is \"patch\", the \"team\" field is required.")
errPolicyQueryUpdated = errors.New("\"query\" can't be updated")
errPolicyPlatformUpdated = errors.New("\"platform\" can't be updated")
errPolicyConditionalAccessEnabledInvalidPlatform = errors.New("\"conditional_access_enabled\" is only valid on \"darwin\" and \"windows\" policies")
@ -207,6 +208,13 @@ func verifyPolicyPlatforms(platforms string) error {
return nil
}
func verifyPatchPolicy(team string, typ string) error {
if typ == PolicyTypePatch && emptyString(team) {
return errPatchPolicyRequiresTeam
}
return nil
}
func PolicyVerifyConditionalAccess(conditionalAccessEnabled bool, platform string) error {
if conditionalAccessEnabled && !strings.Contains(platform, "darwin") && !strings.Contains(platform, "windows") {
return errPolicyConditionalAccessEnabledInvalidPlatform
@ -473,6 +481,7 @@ type PolicySpec struct {
Type string `json:"type"`
FleetMaintainedAppSlug string `json:"fleet_maintained_app_slug"`
PatchSoftwareTitleID uint `json:"-"`
}
// PolicySoftwareTitle contains software title data for policies.
@ -507,6 +516,9 @@ func (p PolicySpec) Verify() error {
if err := PolicyVerifyConditionalAccess(p.ConditionalAccessEnabled, p.Platform); err != nil {
return err
}
if err := verifyPatchPolicy(p.Team, p.Type); err != nil {
return err
}
return nil
}

View file

@ -3000,6 +3000,11 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
}
config.Policies[i].ScriptID = &scriptID
}
// Get patch policy title IDs for the team
for i := range config.Policies {
config.Policies[i].PatchSoftwareTitleID = softwareTitleIDsBySlug[config.Policies[i].FleetMaintainedAppSlug]
}
}
// 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 []
}
}
}
var policiesToDelete []uint
for _, oldItem := range policies {
found := false
@ -3045,6 +3051,13 @@ func (c *Client) doGitOpsPolicies(config *spec.GitOps, teamSoftwareInstallers []
found = true
break
}
// patch policies are unique by patch_software_title_id so matching by name doesn't always work
if newItem.Type == fleet.PolicyTypePatch && oldItem.PatchSoftware != nil {
if oldItem.PatchSoftware.SoftwareTitleID == newItem.PatchSoftwareTitleID {
found = true
break
}
}
}
if !found {
policiesToDelete = append(policiesToDelete, oldItem.ID)

View file

@ -27438,6 +27438,19 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() {
require.Equal(t, "dynamic", getPolicyResp.Policy.Type)
require.Empty(t, getPolicyResp.Policy.PatchSoftware)
require.Empty(t, getPolicyResp.Policy.PatchSoftwareTitleID)
// Should not be able to apply global patch policy
spec := &fleet.PolicySpec{
Name: "team patch policy",
Query: "SELECT 1",
Type: fleet.PolicyTypePatch,
FleetMaintainedAppSlug: "zoom/windows",
}
applyResp := fleet.ApplyPolicySpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/policies",
fleet.ApplyPolicySpecsRequest{Specs: []*fleet.PolicySpec{spec}},
http.StatusBadRequest, &applyResp,
)
})
t.Run("patch_policy_lifecycle", func(t *testing.T) {