diff --git a/changes/gitops-windows-profiles-mdm-state b/changes/gitops-windows-profiles-mdm-state new file mode 100644 index 0000000000..5c40f39b08 --- /dev/null +++ b/changes/gitops-windows-profiles-mdm-state @@ -0,0 +1 @@ +- Improved GitOps consistency by validating batch-applied Windows configuration profiles against the server's current MDM configuration state, while continuing to support previewing (dry run) a config that enables Windows MDM and applies profiles in a single run. diff --git a/server/service/client.go b/server/service/client.go index cf576022c3..eb38e63fd6 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -1001,6 +1001,10 @@ func (c *Client) ApplyGroup( } if len(tmFileContents) > 0 { + // A prior step in this GitOps run may have updated AppConfig (e.g. enabled Windows MDM), so bypass the cached AppConfig on the + // server. This lets profile validation read the freshly persisted state. + teamProfilesOpts := teamOpts + teamProfilesOpts.NoCache = true for tmName, profs := range tmFileContents { // For non-dry run, currentTeamName and tmName are the same currentTeamName := getTeamName(tmName) @@ -1013,7 +1017,7 @@ func (c *Client) ApplyGroup( } else { logfn("[+] applying MDM profiles for fleet %s\n", tmName) } - if err := c.ApplyTeamProfiles(currentTeamName, profs, teamOpts); err != nil { + if err := c.ApplyTeamProfiles(currentTeamName, profs, teamProfilesOpts); err != nil { return nil, nil, nil, nil, fmt.Errorf("applying custom settings for fleet %q: %w", tmName, err) } } diff --git a/server/service/mdm.go b/server/service/mdm.go index 5d1c078a21..8ee3f78f4e 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2093,7 +2093,7 @@ type batchSetMDMProfilesRequest struct { TeamID *uint `json:"-" query:"team_id,optional" renameto:"fleet_id"` TeamName *string `json:"-" query:"team_name,optional" renameto:"fleet_name"` DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes - AssumeEnabled *bool `json:"-" query:"assume_enabled,optional"` // if true, assume MDM is enabled + AssumeEnabled *bool `json:"-" query:"assume_enabled,optional"` // if true, assume Windows MDM is enabled; honored on dry run only Profiles backwardsCompatProfilesParam `json:"profiles"` NoCache bool `json:"-" query:"no_cache,optional"` } @@ -2173,7 +2173,8 @@ func (svc *Service) BatchSetMDMProfiles( ctx = ctxdb.BypassCachedMysql(ctx, false) } - if assumeEnabled != nil { + // assume_enabled is only honored on dry runs + if dryRun && assumeEnabled != nil { appCfg.MDM.WindowsEnabledAndConfigured = *assumeEnabled } diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index d020fe4469..5f2989633e 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -2224,6 +2224,99 @@ func TestMDMBatchSetProfilesAppleConfigProfileScopeValidation(t *testing.T) { require.ErrorContains(t, err, "conflicting scopes") } +// TestMDMBatchSetProfilesWindowsAssumeEnabled is a regression test ensuring the assume_enabled flag is only honored on dry runs +func TestMDMBatchSetProfilesWindowsAssumeEnabled(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}) + + ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}}) + + var windowsEnabled bool + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{ + EnabledAndConfigured: true, + WindowsEnabledAndConfigured: windowsEnabled, + }, + }, nil + } + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return document, nil, nil + } + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.VerifyAppleConfigProfileScopesDoNotConflictFunc = func(ctx context.Context, cps []*fleet.MDMAppleConfigProfile) error { + return nil + } + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, + winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration, androidProfiles []*fleet.MDMAndroidConfigProfile, profVars []fleet.MDMProfileIdentifierFleetVariables, + ) (fleet.MDMProfilesUpdates, error) { + return fleet.MDMProfilesUpdates{}, nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, + hostUUIDs []string, + ) (fleet.MDMProfilesUpdates, error) { + return fleet.MDMProfilesUpdates{}, nil + } + + windowsProfiles := []fleet.MDMProfileBatchPayload{ + {Name: "win-profile", Contents: []byte(``)}, + } + + testCases := []struct { + name string + windowsEnabled bool + assumeEnabled *bool + dryRun bool + wantErr string + wantDSInvoked bool + }{ + { + name: "assume_enabled true on real run is a no-op when Windows MDM is disabled", + windowsEnabled: false, + assumeEnabled: new(true), + dryRun: false, + wantErr: fleet.ErrWindowsMDMNotConfigured.Error(), + wantDSInvoked: false, + }, + { + // The legitimate GitOps dry-run flow + name: "assume_enabled true on dry run validates when Windows MDM is disabled", + windowsEnabled: false, + assumeEnabled: new(true), + dryRun: true, + wantErr: "", + wantDSInvoked: false, // dry run never persists + }, + { + // The legitimate real run + name: "real run succeeds without assume_enabled when Windows MDM is enabled", + windowsEnabled: true, + assumeEnabled: nil, + dryRun: false, + wantErr: "", + wantDSInvoked: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + windowsEnabled = tc.windowsEnabled + ds.BatchSetMDMProfilesFuncInvoked = false + + err := svc.BatchSetMDMProfiles(ctx, nil, nil, windowsProfiles, tc.dryRun, false, tc.assumeEnabled, false) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.wantDSInvoked, ds.BatchSetMDMProfilesFuncInvoked) + }) + } +} + func TestValidateProfiles(t *testing.T) { tests := []struct { name string