From 2f927df4f04b76575ce71cd0c4075d19b227b765 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 29 Nov 2023 11:07:24 -0500 Subject: [PATCH] Implement Windows OS Updates (feature branch). (#15359) --- assets/images/windows-nudge-screenshot.png | Bin 0 -> 124317 bytes .../issue-14027-implement-windows-os-updates | 1 + .../issue-14028-support-windows-os-updates | 1 + changes/issue-14029-apply-windows-os-updates | 1 + .../issue-14045-add-windows-update-activites | 1 + cmd/fleetctl/apply_test.go | 340 ++++++++++++ cmd/fleetctl/get_test.go | 14 + .../expectedGetConfigAppConfigJson.json | 240 +++++---- .../expectedGetConfigAppConfigYaml.yml | 3 + ...ectedGetConfigIncludeServerConfigJson.json | 364 ++++++------- ...pectedGetConfigIncludeServerConfigYaml.yml | 3 + .../testdata/expectedGetTeamsJson.json | 8 + .../testdata/expectedGetTeamsYaml.yml | 6 + .../macosSetupExpectedAppConfigEmpty.yml | 3 + .../macosSetupExpectedAppConfigSet.yml | 3 + .../macosSetupExpectedTeam1And2Empty.yml | 6 + .../macosSetupExpectedTeam1And2Set.yml | 6 + .../testdata/macosSetupExpectedTeam1Empty.yml | 3 + ee/server/service/mdm.go | 28 + ee/server/service/mdm_profiles.go | 84 ++- ee/server/service/service.go | 2 + ee/server/service/teams.go | 52 +- frontend/__mocks__/configMock.ts | 4 + frontend/components/DataError/DataError.tsx | 21 +- .../SectionHeader/SectionHeader.stories.tsx | 14 + .../SectionHeader/SectionHeader.tsx | 10 +- .../components/SectionHeader/_styles.scss | 18 +- frontend/interfaces/activity.ts | 3 + frontend/interfaces/config.ts | 8 +- frontend/interfaces/team.ts | 8 +- .../ActivityItem/ActivityItem.tsx | 24 + .../cards/CustomSettings/CustomSettings.tsx | 3 +- .../cards/CustomSettings/_styles.scss | 10 - .../cards/DiskEncryption/DiskEncryption.tsx | 3 +- .../cards/DiskEncryption/_styles.scss | 10 - .../OSUpdates/OSUpdates.tsx | 94 ++-- .../ManageControlsPage/OSUpdates/_styles.scss | 15 +- .../CurrentVersionSection.tsx | 99 ++++ .../CurrentVersionSection/_styles.scss | 17 + .../components/CurrentVersionSection/index.ts | 1 + .../MacOSTargetForm.tsx} | 70 +-- .../components/MacOSTargetForm/_styles.scss | 6 + .../components/MacOSTargetForm/index.ts | 1 + .../components/NudgePreview/NudgePreview.tsx | 61 ++- .../components/NudgePreview/_styles.scss | 7 + .../components/OSTypeCell/OSTypeCell.tsx | 23 + .../components/OSTypeCell/_styles.scss | 5 + .../OSUpdates/components/OSTypeCell/index.ts | 1 + .../OSVersionTable/OSVersionTable.tsx | 48 ++ .../OSVersionTable/OSVersionTableConfig.tsx | 85 +++ .../components/OSVersionTable/_styles.scss | 18 + .../components/OSVersionTable/index.ts | 1 + .../OSVersionsEmptyState.tsx | 22 + .../OSVersionsEmptyState/_styles.scss | 3 + .../components/OSVersionsEmptyState/index.ts | 1 + .../components/OsMinVersionForm/index.ts | 1 - .../PlatformsAccordion/PlatformsAccordion.tsx | 103 ++++ .../PlatformsAccordion/_styles.scss | 32 ++ .../components/PlatformsAccordion/index.ts | 1 + .../TargetSection/TargetSection.tsx | 155 ++++++ .../components/TargetSection/_styles.scss | 15 + .../components/TargetSection/index.ts | 1 + .../WindowsTargetForm/WindowsTargetForm.tsx | 168 ++++++ .../components/WindowsTargetForm/_styles.scss | 7 + .../components/WindowsTargetForm/index.ts | 1 + .../BootstrapPackage/BootstrapPackage.tsx | 4 +- .../cards/BootstrapPackage/_styles.scss | 9 - .../EndUserAuthentication.tsx | 7 +- .../services/entities/operating_systems.ts | 3 +- frontend/services/entities/teams.ts | 6 +- pkg/optjson/optjson.go | 39 ++ pkg/optjson/optjson_test.go | 86 ++- server/datastore/mysql/apple_mdm_test.go | 3 +- server/datastore/mysql/mdm.go | 14 +- server/datastore/mysql/mdm_test.go | 26 +- server/datastore/mysql/microsoft_mdm.go | 57 ++ server/datastore/mysql/microsoft_mdm_test.go | 69 ++- server/datastore/mysql/schema.sql | 2 +- server/datastore/mysql/teams_test.go | 8 + server/fleet/activities.go | 26 + server/fleet/app.go | 66 ++- server/fleet/app_test.go | 82 +++ server/fleet/datastore.go | 9 + server/fleet/service.go | 2 + server/fleet/teams.go | 14 +- server/fleet/windows_mdm.go | 4 + server/fleet/windows_mdm_test.go | 9 + server/mdm/microsoft/microsoft_mdm.go | 8 + server/mock/datastore_mock.go | 24 + server/service/appconfig.go | 45 ++ server/service/appconfig_test.go | 5 + server/service/integration_core_test.go | 7 + server/service/integration_enterprise_test.go | 503 +++++++++++++++++- server/service/integration_mdm_test.go | 30 +- server/service/mdm.go | 7 + 95 files changed, 3058 insertions(+), 483 deletions(-) create mode 100644 assets/images/windows-nudge-screenshot.png create mode 100644 changes/issue-14027-implement-windows-os-updates create mode 100644 changes/issue-14028-support-windows-os-updates create mode 100644 changes/issue-14029-apply-windows-os-updates create mode 100644 changes/issue-14045-add-windows-update-activites create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/index.ts rename frontend/pages/ManageControlsPage/OSUpdates/components/{OsMinVersionForm/OsMinVersionForm.tsx => MacOSTargetForm/MacOSTargetForm.tsx} (69%) create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/MacOSTargetForm/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/MacOSTargetForm/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/OSTypeCell.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTable.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTableConfig.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/OSVersionsEmptyState.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/index.ts delete mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/PlatformsAccordion.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/index.ts create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/index.ts diff --git a/assets/images/windows-nudge-screenshot.png b/assets/images/windows-nudge-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..30b5f8791407287b4502b7572478612fda454066 GIT binary patch literal 124317 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%0D$MJXX-&?6J{E*Bd(y$>oKK~@RS-J~Dj=U-e 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 KMNUMnLSTYf+d;1Y literal 0 HcmV?d00001 diff --git a/changes/issue-14027-implement-windows-os-updates b/changes/issue-14027-implement-windows-os-updates new file mode 100644 index 000000000..12f123ce6 --- /dev/null +++ b/changes/issue-14027-implement-windows-os-updates @@ -0,0 +1 @@ +- add ability to change and view windows os updates in Fleet UI diff --git a/changes/issue-14028-support-windows-os-updates b/changes/issue-14028-support-windows-os-updates new file mode 100644 index 000000000..e100922d4 --- /dev/null +++ b/changes/issue-14028-support-windows-os-updates @@ -0,0 +1 @@ +* Added support to configure Windows OS updates requirements for hosts enrolled in Fleet MDM. diff --git a/changes/issue-14029-apply-windows-os-updates b/changes/issue-14029-apply-windows-os-updates new file mode 100644 index 000000000..ba5ae878b --- /dev/null +++ b/changes/issue-14029-apply-windows-os-updates @@ -0,0 +1 @@ +* Added deployment of Windows OS updates settings to the targeted hosts so that they take effect. diff --git a/changes/issue-14045-add-windows-update-activites b/changes/issue-14045-add-windows-update-activites new file mode 100644 index 000000000..88f0584be --- /dev/null +++ b/changes/issue-14045-add-windows-update-activites @@ -0,0 +1 @@ +- add window os updates activites to Fleet UI. diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 02abf327b..b3e9c0486 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -213,6 +213,32 @@ spec: assert.True(t, ds.ApplyEnrollSecretsFuncInvoked) ds.ApplyEnrollSecretsFuncInvoked = false + // add windows updates settings to team1 + filename = writeTmpYml(t, ` +--- +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: 5 + grace_period_days: 1 +`) + require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) + newMDMSettings = fleet.TeamMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("12.3.1"), + Deadline: optjson.SetString("2011-03-01"), + }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(1), + }, + } + assert.Equal(t, newMDMSettings, teamsByName["team1"].Config.MDM) + mobileCfgPath := writeTmpMobileconfig(t, "N1") filename = writeTmpYml(t, fmt.Sprintf(` apiVersion: v1 @@ -231,6 +257,10 @@ spec: MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2011-03-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(1), + }, MacOSSettings: fleet.MacOSSettings{ CustomSettings: []string{mobileCfgPath}, }, @@ -268,6 +298,10 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(1), + }, MacOSSettings: fleet.MacOSSettings{ // macos settings not provided, so not cleared CustomSettings: []string{mobileCfgPath}, }, @@ -325,6 +359,9 @@ spec: macos_updates: minimum_version: deadline: + windows_updates: + deadline_days: + grace_period_days: macos_settings: custom_settings: `) @@ -334,6 +371,10 @@ spec: MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.Int{Set: true}, + GracePeriodDays: optjson.Int{Set: true}, + }, MacOSSettings: fleet.MacOSSettings{ CustomSettings: []string{}, }, @@ -385,6 +426,12 @@ func TestApplyAppConfig(t *testing.T) { ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { return &fleet.Team{ID: 123}, nil } + ds.SetOrUpdateMDMWindowsConfigProfileFunc = func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error { + return nil + } + ds.DeleteMDMWindowsConfigProfileByTeamAndNameFunc = func(ctx context.Context, teamID *uint, profileName string) error { + return nil + } defaultAgentOpts := json.RawMessage(`{"config":{"foo":"bar"}}`) savedAppConfig := &fleet.AppConfig{ @@ -413,6 +460,9 @@ spec: macos_updates: minimum_version: 12.1.1 deadline: 2011-02-01 + windows_updates: + deadline_days: 5 + grace_period_days: 1 `) newMDMSettings := fleet.MDM{ @@ -422,6 +472,10 @@ spec: MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(1), + }, } assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name})) require.NotNil(t, savedAppConfig) @@ -441,6 +495,7 @@ spec: agent_options: mdm: macos_updates: + windows_updates: `) assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name})) @@ -449,6 +504,33 @@ spec: assert.True(t, savedAppConfig.Features.EnableSoftwareInventory) // agent options were cleared, provided but empty assert.Nil(t, savedAppConfig.AgentOptions) + // MDM settings unchanged, not provided + assert.Equal(t, newMDMSettings, savedAppConfig.MDM) + + name = writeTmpYml(t, `--- +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: + grace_period_days: +`) + + newMDMSettings = fleet.MDM{ + AppleBMDefaultTeam: "team1", + AppleBMTermsExpired: false, + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("12.1.1"), + Deadline: optjson.SetString("2011-02-01"), + }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.Int{Set: true}, + GracePeriodDays: optjson.Int{Set: true}, + }, + } + assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name})) + require.NotNil(t, savedAppConfig) assert.Equal(t, newMDMSettings, savedAppConfig.MDM) } @@ -927,6 +1009,12 @@ func TestApplyAsGitOps(t *testing.T) { ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error { return nil } + ds.SetOrUpdateMDMWindowsConfigProfileFunc = func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error { + return nil + } + ds.DeleteMDMWindowsConfigProfileByTeamAndNameFunc = func(ctx context.Context, teamID *uint, profileName string) error { + return nil + } // Apply global config. name := writeTmpYml(t, `--- @@ -977,6 +1065,9 @@ spec: macos_updates: minimum_version: 10.10.10 deadline: 2020-02-02 + windows_updates: + deadline_days: 1 + grace_period_days: 0 macos_settings: custom_settings: - %s @@ -1001,6 +1092,10 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("2020-02-02"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(1), + GracePeriodDays: optjson.SetInt(0), + }, MacOSSettings: fleet.MacOSSettings{ CustomSettings: []string{mobileConfigPath}, }, @@ -1039,6 +1134,10 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("2020-02-02"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(1), + GracePeriodDays: optjson.SetInt(0), + }, MacOSSettings: fleet.MacOSSettings{ CustomSettings: []string{mobileConfigPath}, }, @@ -1061,6 +1160,9 @@ spec: macos_updates: minimum_version: 10.10.10 deadline: 1992-03-01 + windows_updates: + deadline_days: 0 + grace_period_days: 1 macos_settings: custom_settings: - %s @@ -1083,6 +1185,10 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(0), + GracePeriodDays: optjson.SetInt(1), + }, }, savedTeam.Config.MDM) assert.Equal(t, []*fleet.EnrollSecret{{Secret: "BBB"}}, teamEnrollSecrets) assert.True(t, ds.ApplyEnrollSecretsFuncInvoked) @@ -1118,6 +1224,10 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(0), + GracePeriodDays: optjson.SetInt(1), + }, MacOSSetup: fleet.MacOSSetup{ MacOSSetupAssistant: optjson.SetString(emptySetupAsst), }, @@ -1150,6 +1260,10 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(0), + GracePeriodDays: optjson.SetInt(1), + }, MacOSSetup: fleet.MacOSSetup{ MacOSSetupAssistant: optjson.SetString(emptySetupAsst), BootstrapPackage: optjson.SetString(bootstrapURL), @@ -2196,6 +2310,12 @@ func TestApplySpecs(t *testing.T) { ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { return nil } + ds.SetOrUpdateMDMWindowsConfigProfileFunc = func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error { + return nil + } + ds.DeleteMDMWindowsConfigProfileByTeamAndNameFunc = func(ctx context.Context, teamID *uint, profileName string) error { + return nil + } } cases := []struct { @@ -2665,6 +2785,124 @@ spec: `, wantErr: `422 Validation Failed: deadline accepts YYYY-MM-DD format only (E.g., "2023-06-01.")`, }, + { + desc: "windows_updates.deadline_days but grace period empty", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: 5 +`, + wantErr: `422 Validation Failed: grace_period_days is required when deadline_days is provided`, + }, + { + desc: "windows_updates.grace_period_days but deadline empty", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + grace_period_days: 5 +`, + wantErr: `422 Validation Failed: deadline_days is required when grace_period_days is provided`, + }, + { + desc: "windows_updates.deadline_days out of range", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: 9999 + grace_period_days: 1 +`, + wantErr: `422 Validation Failed: deadline_days must be an integer between 0 and 30`, + }, + { + desc: "windows_updates.grace_period_days out of range", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: 1 + grace_period_days: 9999 +`, + wantErr: `422 Validation Failed: grace_period_days must be an integer between 0 and 7`, + }, + { + desc: "windows_updates.deadline_days not a number", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: abc + grace_period_days: 1 +`, + wantErr: `400 Bad Request: invalid value type at 'specs.mdm.windows_updates.deadline_days': expected int but got string`, + }, + { + desc: "windows_updates.grace_period_days not a number", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: 1 + grace_period_days: true +`, + wantErr: `400 Bad Request: invalid value type at 'specs.mdm.windows_updates.grace_period_days': expected int but got bool`, + }, + { + desc: "windows_updates valid", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: 5 + grace_period_days: 1 +`, + wantOutput: `[+] applied 1 teams`, + }, + { + desc: "windows_updates unset valid", + spec: ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + mdm: + windows_updates: + deadline_days: + grace_period_days: +`, + wantOutput: `[+] applied 1 teams`, + }, { desc: "missing required sso entity_id", spec: ` @@ -2868,6 +3106,108 @@ spec: `, wantErr: `422 Validation Failed: deadline accepts YYYY-MM-DD format only (E.g., "2023-06-01.")`, }, + { + desc: "app config windows_updates.deadline_days but grace period empty", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: 5 +`, + wantErr: `422 Validation Failed: grace_period_days is required when deadline_days is provided`, + }, + { + desc: "app config windows_updates.grace_period_days but deadline empty", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + grace_period_days: 5 +`, + wantErr: `422 Validation Failed: deadline_days is required when grace_period_days is provided`, + }, + { + desc: "app config windows_updates.deadline_days out of range", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: 9999 + grace_period_days: 1 +`, + wantErr: `422 Validation Failed: deadline_days must be an integer between 0 and 30`, + }, + { + desc: "app config windows_updates.grace_period_days out of range", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: 1 + grace_period_days: 9999 +`, + wantErr: `422 Validation Failed: grace_period_days must be an integer between 0 and 7`, + }, + { + desc: "app config windows_updates.deadline_days not a number", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: abc + grace_period_days: 1 +`, + wantErr: `400 Bad request: failed to decode app config`, + }, + { + desc: "app config windows_updates.grace_period_days not a number", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: 1 + grace_period_days: true +`, + wantErr: `400 Bad request: failed to decode app config`, + }, + { + desc: "app config windows_updates valid", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: 5 + grace_period_days: 1 +`, + wantOutput: `[+] applied fleet config`, + }, + { + desc: "app config windows_updates unset valid", + spec: ` +apiVersion: v1 +kind: config +spec: + mdm: + windows_updates: + deadline_days: + grace_period_days: +`, + wantOutput: `[+] applied fleet config`, + }, { desc: "app config macos_settings.enable_disk_encryption without a value", spec: ` diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index d32af906c..6843f8344 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -161,6 +161,10 @@ func TestGetTeams(t *testing.T) { MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2021-12-14"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(7), + GracePeriodDays: optjson.SetInt(3), + }, }, }, }, @@ -560,6 +564,12 @@ func TestGetConfig(t *testing.T) { VulnerabilitySettings: fleet.VulnerabilitySettings{DatabasesPath: "/some/path"}, SMTPSettings: &fleet.SMTPSettings{}, SSOSettings: &fleet.SSOSettings{}, + MDM: fleet.MDM{ + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(7), + GracePeriodDays: optjson.SetInt(3), + }, + }, }, nil } @@ -1989,6 +1999,10 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2021-12-14"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(7), + GracePeriodDays: optjson.SetInt(3), + }, }, }, } diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 36e04eb41..05d1de8ef 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -1,120 +1,124 @@ { - "kind": "config", - "apiVersion": "v1", - "spec": { - "org_info": { - "org_name": "", - "org_logo_url": "", - "org_logo_url_light_background": "", - "contact_url": "https://fleetdm.com/company/contact" - }, - "server_settings": { - "server_url": "", - "live_query_disabled": false, - "query_reports_disabled": false, - "enable_analytics": false, - "deferred_save_host": false - }, - "smtp_settings": { - "enable_smtp": false, - "configured": false, - "sender_address": "", - "server": "", - "port": 0, - "authentication_type": "", - "user_name": "", - "password": "", - "enable_ssl_tls": false, - "authentication_method": "", - "domain": "", - "verify_ssl_certs": false, - "enable_start_tls": false - }, - "host_expiry_settings": { - "host_expiry_enabled": false, - "host_expiry_window": 0 - }, - "features": { - "enable_host_users": true, - "enable_software_inventory": false - }, - "sso_settings": { - "entity_id": "", - "issuer_uri": "", - "idp_image_url": "", - "metadata": "", - "metadata_url": "", - "idp_name": "", - "enable_jit_provisioning": false, - "enable_jit_role_sync": false, - "enable_sso": false, - "enable_sso_idp_login": false - }, - "fleet_desktop": { - "transparency_url": "https://fleetdm.com/transparency" - }, - "vulnerability_settings": { - "databases_path": "/some/path" - }, - "webhook_settings": { - "host_status_webhook": { - "enable_host_status_webhook": false, - "destination_url": "", - "host_percentage": 0, - "days_count": 0 - }, - "failing_policies_webhook": { - "enable_failing_policies_webhook": false, - "destination_url": "", - "policy_ids": null, - "host_batch_size": 0 - }, - "vulnerabilities_webhook": { - "enable_vulnerabilities_webhook": false, - "destination_url": "", - "host_batch_size": 0 - }, - "interval": "0s" - }, - "integrations": { - "jira": null, - "zendesk": null - }, - "mdm": { - "apple_bm_terms_expired": false, - "apple_bm_enabled_and_configured": false, - "enabled_and_configured": false, - "apple_bm_default_team": "", - "windows_enabled_and_configured": false, - "enable_disk_encryption": false, - "macos_updates": { - "minimum_version": null, - "deadline": null - }, - "macos_migration": { - "enable": false, - "mode": "", - "webhook_url": "" - }, - "macos_settings": { - "custom_settings": null - }, - "macos_setup": { - "bootstrap_package": null, - "enable_end_user_authentication": false, - "macos_setup_assistant": null - }, - "windows_settings": { - "custom_settings": null - }, - "end_user_authentication": { - "entity_id": "", - "issuer_uri": "", - "metadata": "", - "metadata_url": "", - "idp_name": "" - } - }, - "scripts": null - } + "kind": "config", + "apiVersion": "v1", + "spec": { + "org_info": { + "org_name": "", + "org_logo_url": "", + "org_logo_url_light_background": "", + "contact_url": "https://fleetdm.com/company/contact" + }, + "server_settings": { + "server_url": "", + "live_query_disabled": false, + "query_reports_disabled": false, + "enable_analytics": false, + "deferred_save_host": false + }, + "smtp_settings": { + "enable_smtp": false, + "configured": false, + "sender_address": "", + "server": "", + "port": 0, + "authentication_type": "", + "user_name": "", + "password": "", + "enable_ssl_tls": false, + "authentication_method": "", + "domain": "", + "verify_ssl_certs": false, + "enable_start_tls": false + }, + "host_expiry_settings": { + "host_expiry_enabled": false, + "host_expiry_window": 0 + }, + "features": { + "enable_host_users": true, + "enable_software_inventory": false + }, + "sso_settings": { + "entity_id": "", + "issuer_uri": "", + "idp_image_url": "", + "metadata": "", + "metadata_url": "", + "idp_name": "", + "enable_jit_provisioning": false, + "enable_jit_role_sync": false, + "enable_sso": false, + "enable_sso_idp_login": false + }, + "fleet_desktop": { + "transparency_url": "https://fleetdm.com/transparency" + }, + "vulnerability_settings": { + "databases_path": "/some/path" + }, + "webhook_settings": { + "host_status_webhook": { + "enable_host_status_webhook": false, + "destination_url": "", + "host_percentage": 0, + "days_count": 0 + }, + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + }, + "vulnerabilities_webhook": { + "enable_vulnerabilities_webhook": false, + "destination_url": "", + "host_batch_size": 0 + }, + "interval": "0s" + }, + "integrations": { + "jira": null, + "zendesk": null + }, + "mdm": { + "apple_bm_terms_expired": false, + "apple_bm_enabled_and_configured": false, + "enabled_and_configured": false, + "apple_bm_default_team": "", + "windows_enabled_and_configured": false, + "enable_disk_encryption": false, + "macos_updates": { + "minimum_version": null, + "deadline": null + }, + "windows_updates": { + "deadline_days": 7, + "grace_period_days": 3 + }, + "macos_migration": { + "enable": false, + "mode": "", + "webhook_url": "" + }, + "macos_settings": { + "custom_settings": null + }, + "macos_setup": { + "bootstrap_package": null, + "enable_end_user_authentication": false, + "macos_setup_assistant": null + }, + "windows_settings": { + "custom_settings": null + }, + "end_user_authentication": { + "entity_id": "", + "issuer_uri": "", + "metadata": "", + "metadata_url": "", + "idp_name": "" + } + }, + "scripts": null + } } diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 974745148..9d4d4a6e3 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -27,6 +27,9 @@ spec: macos_updates: minimum_version: null deadline: null + windows_updates: + deadline_days: 7 + grace_period_days: 3 macos_settings: custom_settings: macos_setup: diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 1fec3ce9e..2964165e0 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -1,182 +1,186 @@ { - "kind": "config", - "apiVersion": "v1", - "spec": { - "org_info": { - "org_name": "", - "org_logo_url": "", - "org_logo_url_light_background": "", - "contact_url": "https://fleetdm.com/company/contact" - }, - "server_settings": { - "server_url": "", - "live_query_disabled": false, - "query_reports_disabled": false, - "enable_analytics": false, - "deferred_save_host": false - }, - "smtp_settings": { - "enable_smtp": false, - "configured": false, - "sender_address": "", - "server": "", - "port": 0, - "authentication_type": "", - "user_name": "", - "password": "", - "enable_ssl_tls": false, - "authentication_method": "", - "domain": "", - "verify_ssl_certs": false, - "enable_start_tls": false - }, - "host_expiry_settings": { - "host_expiry_enabled": false, - "host_expiry_window": 0 - }, - "features": { - "enable_host_users": true, - "enable_software_inventory": false - }, - "mdm": { - "apple_bm_default_team": "", - "apple_bm_terms_expired": false, - "apple_bm_enabled_and_configured": false, - "enabled_and_configured": false, - "windows_enabled_and_configured": false, - "enable_disk_encryption": false, - "macos_updates": { - "minimum_version": null, - "deadline": null - }, - "macos_migration": { - "enable": false, - "mode": "", - "webhook_url": "" - }, - "macos_settings": { - "custom_settings": null - }, - "macos_setup": { - "bootstrap_package": null, - "enable_end_user_authentication": false, - "macos_setup_assistant": null - }, - "windows_settings": { - "custom_settings": null - }, - "end_user_authentication": { - "entity_id": "", - "issuer_uri": "", - "metadata": "", - "metadata_url": "", - "idp_name": "" - } - }, - "scripts": null, - "sso_settings": { - "enable_jit_provisioning": false, - "enable_jit_role_sync": false, - "entity_id": "", - "issuer_uri": "", - "idp_image_url": "", - "metadata": "", - "metadata_url": "", - "idp_name": "", - "enable_sso": false, - "enable_sso_idp_login": false - }, - "fleet_desktop": { - "transparency_url": "https://fleetdm.com/transparency" - }, - "vulnerability_settings": { - "databases_path": "/some/path" - }, - "webhook_settings": { - "host_status_webhook": { - "enable_host_status_webhook": false, - "destination_url": "", - "host_percentage": 0, - "days_count": 0 - }, - "failing_policies_webhook": { - "enable_failing_policies_webhook": false, - "destination_url": "", - "policy_ids": null, - "host_batch_size": 0 - }, - "vulnerabilities_webhook": { - "enable_vulnerabilities_webhook": false, - "destination_url": "", - "host_batch_size": 0 - }, - "interval": "0s" - }, - "integrations": { - "jira": null, - "zendesk": null - }, - "update_interval": { - "osquery_detail": "1h0m0s", - "osquery_policy": "1h0m0s" - }, - "vulnerabilities": { - "databases_path": "", - "periodicity": "0s", - "cpe_database_url": "", - "cpe_translations_url": "", - "cve_feed_prefix_url": "", - "current_instance_checks": "", - "disable_data_sync": false, - "recent_vulnerability_max_age": "0s", - "disable_win_os_vulnerabilities": false - }, - "license": { - "tier": "free", - "expiration": "0001-01-01T00:00:00Z" - }, - "logging": { - "debug": true, - "json": false, - "result": { - "plugin": "filesystem", - "config": { - "enable_log_compression": false, - "enable_log_rotation": false, - "result_log_file": "/dev/null", - "status_log_file": "/dev/null", - "audit_log_file": "/dev/null", - "max_size": 500, - "max_age": 0, - "max_backups": 0 - } - }, - "status": { - "plugin": "filesystem", - "config": { - "enable_log_compression": false, - "enable_log_rotation": false, - "result_log_file": "/dev/null", - "status_log_file": "/dev/null", - "audit_log_file": "/dev/null", - "max_size": 500, - "max_age": 0, - "max_backups": 0 - } - }, - "audit": { - "plugin": "filesystem", - "config": { - "enable_log_compression": false, - "enable_log_rotation": false, - "result_log_file": "/dev/null", - "status_log_file": "/dev/null", - "audit_log_file": "/dev/null", - "max_size": 500, - "max_age": 0, - "max_backups": 0 - } - } - } - } + "kind": "config", + "apiVersion": "v1", + "spec": { + "org_info": { + "org_name": "", + "org_logo_url": "", + "org_logo_url_light_background": "", + "contact_url": "https://fleetdm.com/company/contact" + }, + "server_settings": { + "server_url": "", + "live_query_disabled": false, + "query_reports_disabled": false, + "enable_analytics": false, + "deferred_save_host": false + }, + "smtp_settings": { + "enable_smtp": false, + "configured": false, + "sender_address": "", + "server": "", + "port": 0, + "authentication_type": "", + "user_name": "", + "password": "", + "enable_ssl_tls": false, + "authentication_method": "", + "domain": "", + "verify_ssl_certs": false, + "enable_start_tls": false + }, + "host_expiry_settings": { + "host_expiry_enabled": false, + "host_expiry_window": 0 + }, + "features": { + "enable_host_users": true, + "enable_software_inventory": false + }, + "mdm": { + "apple_bm_default_team": "", + "apple_bm_terms_expired": false, + "apple_bm_enabled_and_configured": false, + "enabled_and_configured": false, + "windows_enabled_and_configured": false, + "enable_disk_encryption": false, + "macos_updates": { + "minimum_version": null, + "deadline": null + }, + "windows_updates": { + "deadline_days": 7, + "grace_period_days": 3 + }, + "macos_migration": { + "enable": false, + "mode": "", + "webhook_url": "" + }, + "macos_settings": { + "custom_settings": null + }, + "macos_setup": { + "bootstrap_package": null, + "enable_end_user_authentication": false, + "macos_setup_assistant": null + }, + "windows_settings": { + "custom_settings": null + }, + "end_user_authentication": { + "entity_id": "", + "issuer_uri": "", + "metadata": "", + "metadata_url": "", + "idp_name": "" + } + }, + "scripts": null, + "sso_settings": { + "enable_jit_provisioning": false, + "enable_jit_role_sync": false, + "entity_id": "", + "issuer_uri": "", + "idp_image_url": "", + "metadata": "", + "metadata_url": "", + "idp_name": "", + "enable_sso": false, + "enable_sso_idp_login": false + }, + "fleet_desktop": { + "transparency_url": "https://fleetdm.com/transparency" + }, + "vulnerability_settings": { + "databases_path": "/some/path" + }, + "webhook_settings": { + "host_status_webhook": { + "enable_host_status_webhook": false, + "destination_url": "", + "host_percentage": 0, + "days_count": 0 + }, + "failing_policies_webhook": { + "enable_failing_policies_webhook": false, + "destination_url": "", + "policy_ids": null, + "host_batch_size": 0 + }, + "vulnerabilities_webhook": { + "enable_vulnerabilities_webhook": false, + "destination_url": "", + "host_batch_size": 0 + }, + "interval": "0s" + }, + "integrations": { + "jira": null, + "zendesk": null + }, + "update_interval": { + "osquery_detail": "1h0m0s", + "osquery_policy": "1h0m0s" + }, + "vulnerabilities": { + "databases_path": "", + "periodicity": "0s", + "cpe_database_url": "", + "cpe_translations_url": "", + "cve_feed_prefix_url": "", + "current_instance_checks": "", + "disable_data_sync": false, + "recent_vulnerability_max_age": "0s", + "disable_win_os_vulnerabilities": false + }, + "license": { + "tier": "free", + "expiration": "0001-01-01T00:00:00Z" + }, + "logging": { + "debug": true, + "json": false, + "result": { + "plugin": "filesystem", + "config": { + "enable_log_compression": false, + "enable_log_rotation": false, + "result_log_file": "/dev/null", + "status_log_file": "/dev/null", + "audit_log_file": "/dev/null", + "max_size": 500, + "max_age": 0, + "max_backups": 0 + } + }, + "status": { + "plugin": "filesystem", + "config": { + "enable_log_compression": false, + "enable_log_rotation": false, + "result_log_file": "/dev/null", + "status_log_file": "/dev/null", + "audit_log_file": "/dev/null", + "max_size": 500, + "max_age": 0, + "max_backups": 0 + } + }, + "audit": { + "plugin": "filesystem", + "config": { + "enable_log_compression": false, + "enable_log_rotation": false, + "result_log_file": "/dev/null", + "status_log_file": "/dev/null", + "audit_log_file": "/dev/null", + "max_size": 500, + "max_age": 0, + "max_backups": 0 + } + } + } + } } diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 1b7eb7ac3..69539a4d2 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -27,6 +27,9 @@ spec: macos_updates: minimum_version: null deadline: null + windows_updates: + deadline_days: 7 + grace_period_days: 3 macos_settings: custom_settings: macos_setup: diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index c078c4f6b..e7617cf75 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -29,6 +29,10 @@ "minimum_version": null, "deadline": null }, + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + }, "macos_settings": { "custom_settings": null }, @@ -93,6 +97,10 @@ "minimum_version": "12.3.1", "deadline": "2021-12-14" }, + "windows_updates": { + "deadline_days": 7, + "grace_period_days": 3 + }, "macos_settings": { "custom_settings": null }, diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 59a5c2196..4ce6f5ef7 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -11,6 +11,9 @@ spec: macos_updates: minimum_version: null deadline: null + windows_updates: + deadline_days: null + grace_period_days: null macos_settings: custom_settings: windows_settings: @@ -43,6 +46,9 @@ spec: macos_updates: minimum_version: "12.3.1" deadline: "2021-12-14" + windows_updates: + deadline_days: 7 + grace_period_days: 3 macos_settings: custom_settings: windows_settings: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 67cb539b3..1ce4b5737 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -35,6 +35,9 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null end_user_authentication: idp_name: "" issuer_uri: "" diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 1c2e8b613..bff74ddc1 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -35,6 +35,9 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null end_user_authentication: idp_name: "" issuer_uri: "" diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 95f847be2..018959e81 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -19,6 +19,9 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null scripts: null name: tm1 --- @@ -41,5 +44,8 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null scripts: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 4acfb2474..da6e6447e 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -19,6 +19,9 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null scripts: null name: tm1 --- @@ -41,5 +44,8 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null scripts: null name: tm2 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index e9972438a..b134d6674 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -17,6 +17,9 @@ spec: macos_updates: deadline: null minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null windows_settings: custom_settings: null scripts: null diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index a8611da68..82e894c5d 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -23,6 +23,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/sso" "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/kit/log" @@ -1020,3 +1021,30 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin }, }, nil } + +func (svc *Service) mdmWindowsEnableOSUpdates(ctx context.Context, teamID *uint, updates fleet.WindowsUpdates) error { + var contents bytes.Buffer + params := windowsOSUpdatesProfileOptions{ + Deadline: updates.DeadlineDays.Value, + GracePeriod: updates.GracePeriodDays.Value, + } + if err := windowsOSUpdatesProfileTemplate.Execute(&contents, params); err != nil { + return ctxerr.Wrap(ctx, err, "enabling Windows OS updates") + } + + err := svc.ds.SetOrUpdateMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{ + TeamID: teamID, + Name: microsoft_mdm.FleetWindowsOSUpdatesProfileName, + SyncML: contents.Bytes(), + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "create Windows OS updates profile") + } + + return nil +} + +func (svc *Service) mdmWindowsDisableOSUpdates(ctx context.Context, teamID *uint) error { + err := svc.ds.DeleteMDMWindowsConfigProfileByTeamAndName(ctx, teamID, microsoft_mdm.FleetWindowsOSUpdatesProfileName) + return ctxerr.Wrap(ctx, err, "delete Windows OS updates profile") +} diff --git a/ee/server/service/mdm_profiles.go b/ee/server/service/mdm_profiles.go index 61d62c908..90aed88f9 100644 --- a/ee/server/service/mdm_profiles.go +++ b/ee/server/service/mdm_profiles.go @@ -91,8 +91,82 @@ var fileVaultProfileTemplate = template.Must(template.New("").Option("missingkey `)) -// TODO(mna): we have a potential issue here with profile names - we need to -// make sure they are unique for a given team, but there is no validation of -// Fleet-reserved profile names, only of identifiers. A user could create a -// "Disk encryption" profile for a custom profile, and then later on Fleet -// would fail to enable disk encryption. See https://github.com/fleetdm/fleet/issues/15133. +type windowsOSUpdatesProfileOptions struct { + Deadline int + GracePeriod int +} + +var windowsOSUpdatesProfileTemplate = template.Must(template.New("").Option("missingkey=error").Parse(` + + + + ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineForFeatureUpdates + + + text/plain + int + + {{ .Deadline }} + + + + + + ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineForQualityUpdates + + + text/plain + int + + {{ .Deadline }} + + + + + + ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineGracePeriod + + + text/plain + int + + {{ .GracePeriod }} + + + + + + ./Device/Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate + + + text/plain + int + + 1 + + + + + + ./Device/Vendor/MSFT/Policy/Config/Update/SetDisablePauseUXAccess + + + text/plain + int + + 1 + + + + + + ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoReboot + + + text/plain + int + + 1 + + +`)) diff --git a/ee/server/service/service.go b/ee/server/service/service.go index 425a8b1d1..58ed553f8 100644 --- a/ee/server/service/service.go +++ b/ee/server/service/service.go @@ -74,6 +74,8 @@ func NewService( DeleteMDMAppleSetupAssistant: eeservice.DeleteMDMAppleSetupAssistant, MDMAppleSyncDEPProfiles: eeservice.mdmAppleSyncDEPProfiles, DeleteMDMAppleBootstrapPackage: eeservice.DeleteMDMAppleBootstrapPackage, + MDMWindowsEnableOSUpdates: eeservice.mdmWindowsEnableOSUpdates, + MDMWindowsDisableOSUpdates: eeservice.mdmWindowsDisableOSUpdates, }) return eeservice, nil diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 4d2f3831a..0f1507280 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -137,7 +137,7 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T return nil, err } - var macOSMinVersionUpdated, macOSDiskEncryptionUpdated, macOSEnableEndUserAuthUpdated bool + var macOSMinVersionUpdated, windowsUpdatesUpdated, macOSDiskEncryptionUpdated, macOSEnableEndUserAuthUpdated bool if payload.MDM != nil { if payload.MDM.MacOSUpdates != nil { if err := payload.MDM.MacOSUpdates.Validate(); err != nil { @@ -150,6 +150,16 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } } + if payload.MDM.WindowsUpdates != nil { + if err := payload.MDM.WindowsUpdates.Validate(); err != nil { + return nil, fleet.NewInvalidArgumentError("windows_updates", err.Error()) + } + if payload.MDM.WindowsUpdates.DeadlineDays.Set || payload.MDM.WindowsUpdates.GracePeriodDays.Set { + windowsUpdatesUpdated = !team.Config.MDM.WindowsUpdates.Equal(*payload.MDM.WindowsUpdates) + team.Config.MDM.WindowsUpdates = *payload.MDM.WindowsUpdates + } + } + if payload.MDM.EnableDiskEncryption.Valid { macOSDiskEncryptionUpdated = team.Config.MDM.EnableDiskEncryption != payload.MDM.EnableDiskEncryption.Value if macOSDiskEncryptionUpdated && !appCfg.MDM.EnabledAndConfigured { @@ -223,6 +233,36 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T return nil, ctxerr.Wrap(ctx, err, "create activity for team macos min version edited") } } + if windowsUpdatesUpdated { + var deadline, grace *int + if team.Config.MDM.WindowsUpdates.DeadlineDays.Valid { + deadline = &team.Config.MDM.WindowsUpdates.DeadlineDays.Value + } + if team.Config.MDM.WindowsUpdates.GracePeriodDays.Valid { + grace = &team.Config.MDM.WindowsUpdates.GracePeriodDays.Value + } + + if deadline != nil { + if err := svc.mdmWindowsEnableOSUpdates(ctx, &team.ID, team.Config.MDM.WindowsUpdates); err != nil { + return nil, ctxerr.Wrap(ctx, err, "enable team windows OS updates") + } + } else if err := svc.mdmWindowsDisableOSUpdates(ctx, &team.ID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "disable team windows OS updates") + } + + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedWindowsUpdates{ + TeamID: &team.ID, + TeamName: &team.Name, + DeadlineDays: deadline, + GracePeriodDays: grace, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for team macos min version edited") + } + } if macOSDiskEncryptionUpdated { var act fleet.ActivityDetails if team.Config.MDM.EnableDiskEncryption { @@ -710,6 +750,9 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, if err := spec.MDM.MacOSUpdates.Validate(); err != nil { return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("macos_updates", err.Error())) } + if err := spec.MDM.WindowsUpdates.Validate(); err != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_updates", err.Error())) + } if create { @@ -826,6 +869,7 @@ func (svc *Service) createTeamFromSpec( MDM: fleet.TeamMDM{ EnableDiskEncryption: enableDiskEncryption, MacOSUpdates: spec.MDM.MacOSUpdates, + WindowsUpdates: spec.MDM.WindowsUpdates, MacOSSettings: macOSSettings, MacOSSetup: macOSSetup, }, @@ -883,6 +927,9 @@ func (svc *Service) editTeamFromSpec( if spec.MDM.MacOSUpdates.Deadline.Set || spec.MDM.MacOSUpdates.MinimumVersion.Set { team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates } + if spec.MDM.WindowsUpdates.DeadlineDays.Set || spec.MDM.WindowsUpdates.GracePeriodDays.Set { + team.Config.MDM.WindowsUpdates = spec.MDM.WindowsUpdates + } oldEnableDiskEncryption := team.Config.MDM.EnableDiskEncryption if err := svc.applyTeamMacOSSettings(ctx, spec, &team.Config.MDM.MacOSSettings); err != nil { @@ -913,6 +960,9 @@ func (svc *Service) editTeamFromSpec( didUpdateBootstrapPackage = oldMacOSSetup.BootstrapPackage.Value != spec.MDM.MacOSSetup.BootstrapPackage.Value team.Config.MDM.MacOSSetup.BootstrapPackage = spec.MDM.MacOSSetup.BootstrapPackage } + // TODO(mna): doesn't look like we create an activity for macos updates when + // modified via spec? Doing the same for Windows, but should we? + if !appCfg.MDM.EnabledAndConfigured && ((didUpdateSetupAssistant && team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value != "") || (didUpdateBootstrapPackage && team.Config.MDM.MacOSSetup.BootstrapPackage.Value != "")) { diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 7b5131a2d..e73ce1a72 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -150,6 +150,10 @@ const DEFAULT_CONFIG_MOCK: IConfig = { mode: "", webhook_url: "", }, + windows_updates: { + deadline_days: null, + grace_period_days: null, + }, end_user_authentication: { entity_id: "", issuer_uri: "", diff --git a/frontend/components/DataError/DataError.tsx b/frontend/components/DataError/DataError.tsx index c84bd8a90..a148ac994 100644 --- a/frontend/components/DataError/DataError.tsx +++ b/frontend/components/DataError/DataError.tsx @@ -9,6 +9,8 @@ const baseClass = "data-error"; interface IDataErrorProps { /** the description text displayed under the header */ description?: string; + /** Excludes the link that asks user to create an issue. Defaults to `false` */ + excludeIssueLink?: boolean; children?: React.ReactNode; card?: boolean; className?: string; @@ -18,6 +20,7 @@ const DEFAULT_DESCRIPTION = "Refresh the page or log in again."; const DataError = ({ description = DEFAULT_DESCRIPTION, + excludeIssueLink = false, children, card, className, @@ -37,14 +40,16 @@ const DataError = ({ {children || ( <> {description} - - If this keeps happening, please  - - + {!excludeIssueLink && ( + + If this keeps happening, please  + + + )} )} diff --git a/frontend/components/SectionHeader/SectionHeader.stories.tsx b/frontend/components/SectionHeader/SectionHeader.stories.tsx index 8cd3d1484..fe5ce3022 100644 --- a/frontend/components/SectionHeader/SectionHeader.stories.tsx +++ b/frontend/components/SectionHeader/SectionHeader.stories.tsx @@ -1,5 +1,8 @@ +import React from "react"; import { Meta, StoryObj } from "@storybook/react"; +import LastUpdatedText from "components/LastUpdatedText"; + import SectionHeader from "."; const meta: Meta = { @@ -13,3 +16,14 @@ export default meta; type Story = StoryObj; export const Basic: Story = {}; + +export const WithSubTitle: Story = { + args: { + subTitle: ( + + ), + }, +}; diff --git a/frontend/components/SectionHeader/SectionHeader.tsx b/frontend/components/SectionHeader/SectionHeader.tsx index fb4e11538..edbe88134 100644 --- a/frontend/components/SectionHeader/SectionHeader.tsx +++ b/frontend/components/SectionHeader/SectionHeader.tsx @@ -4,10 +4,16 @@ const baseClass = "section-header"; interface ISectionHeaderProps { title: string; + subTitle?: React.ReactNode; } -const SectionHeader = ({ title }: ISectionHeaderProps) => { - return

{title}

; +const SectionHeader = ({ title, subTitle }: ISectionHeaderProps) => { + return ( +
+

{title}

+ {subTitle &&
{subTitle}
} +
+ ); }; export default SectionHeader; diff --git a/frontend/components/SectionHeader/_styles.scss b/frontend/components/SectionHeader/_styles.scss index a92926f72..89f7091b4 100644 --- a/frontend/components/SectionHeader/_styles.scss +++ b/frontend/components/SectionHeader/_styles.scss @@ -1,8 +1,14 @@ .section-header { - margin: 0 0 $pad-large; - padding-bottom: $pad-small; - font-size: $medium; - font-weight: $regular; - color: $core-fleet-black; - border-bottom: solid 1px $ui-fleet-black-10; + display: flex; + align-items: center; + gap: $pad-small; + padding-bottom: $pad-medium; + border-bottom: 1px solid $ui-fleet-black-10; + margin-bottom: $pad-xxlarge; + + h2 { + margin: 0; + font-weight: normal; + font-size: 18px; // TODO: update font variables to include 18px; + } } diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index 41ce556d9..675522b9b 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -56,6 +56,7 @@ export enum ActivityType { AddedScript = "added_script", DeletedScript = "deleted_script", EditedScript = "edited_script", + EditedWindowsUpdates = "edited_windows_updates", } export interface IActivity { created_at: string; @@ -101,4 +102,6 @@ export interface IActivityDetails { name?: string; script_execution_id?: string; script_name?: string; + deadline_days?: number; + grace_period_days?: number; } diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index cf15ccca6..8f68613ce 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -37,8 +37,8 @@ export interface IMdmConfig { windows_enabled_and_configured: boolean; end_user_authentication: IEndUserAuthentication; macos_updates: { - minimum_version: string; - deadline: string; + minimum_version: string | null; + deadline: string | null; }; macos_settings: { custom_settings: null; @@ -50,6 +50,10 @@ export interface IMdmConfig { macos_setup_assistant: string | null; }; macos_migration: IMacOsMigrationSettings; + windows_updates: { + deadline_days: number | null; + grace_period_days: number | null; + }; } export interface IDeviceGlobalConfig { diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 4487dba48..e44c93f5f 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -46,8 +46,8 @@ export interface ITeam extends ITeamSummary { mdm?: { enable_disk_encryption: boolean; macos_updates: { - minimum_version: string; - deadline: string; + minimum_version: string | null; + deadline: string | null; }; macos_settings: { custom_settings: null; // TODO: types? @@ -58,6 +58,10 @@ export interface ITeam extends ITeamSummary { enable_end_user_authentication: boolean; macos_setup_assistant: string | null; // TODO: types? }; + windows_updates: { + deadline_days: number | null; + grace_period_days: number | null; + }; }; } diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 20b63c270..0de692d32 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -675,6 +675,27 @@ const TAGGED_TEMPLATES = { ); }, + editedWindowsUpdates: (activity: IActivity) => { + return ( + <> + {" "} + updated the Windows OS update options ( + + Deadline: {activity.details?.deadline_days} days / Grace period:{" "} + {activity.details?.grace_period_days} days + + ) on hosts assigned to{" "} + {activity.details?.team_name ? ( + <> + the {activity.details.team_name} team + + ) : ( + "no team" + )} + . + + ); + }, deletedMultipleSavedQuery: (activity: IActivity) => { return <> deleted multiple queries.; }, @@ -812,6 +833,9 @@ const getDetail = ( case ActivityType.EditedScript: { return TAGGED_TEMPLATES.editedScript(activity); } + case ActivityType.EditedWindowsUpdates: { + return TAGGED_TEMPLATES.editedWindowsUpdates(activity); + } case ActivityType.DeletedMultipleSavedQuery: { return TAGGED_TEMPLATES.deletedMultipleSavedQuery(activity); } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx index 8ef734adc..bd1c132af 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx @@ -8,6 +8,7 @@ import { NotificationContext } from "context/notification"; import PATHS from "router/paths"; import CustomLink from "components/CustomLink"; +import SectionHeader from "components/SectionHeader"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; @@ -152,7 +153,7 @@ const CustomSettings = ({ return (
-

Custom settings

+

Create and upload configuration profiles to apply custom settings.{" "} -

Disk encryption

+ {!isPremiumTier ? ( { + // We dont have the data ready yet so we default to mac. + // This is usually when the users first comes to this page. + if (appConfig === null) return "darwin"; + + // if the mac mdm is enable and configured we check the app config to see if + // the mdm for mac is enabled. If it is, it does not matter if windows is + // enabled and configured and we will always return "mac". + return appConfig.mdm.enabled_and_configured ? "darwin" : "windows"; +}; + interface IOSUpdates { router: InjectedRouter; - teamIdForApi: number; + teamIdForApi?: number; } const OSUpdates = ({ router, teamIdForApi }: IOSUpdates) => { const { config, isPremiumTier } = useContext(AppContext); - const OperatingSystemCard = useInfoCard({ - title: "macOS versions", - children: ( - { - return null; - }} - /> - ), - }); + // the default platform is mac and we later update this value when we have + // done more checks. + const [ + selectedPlatform, + setSelectedPlatform, + ] = useState("darwin"); - if (!config?.mdm.enabled_and_configured) { + // we have to use useEffect here as we need to update our selected platform + // state when the app config is updated. This is usually when we get the app + // config response from the server and it is no longer `null`. + useEffect(() => { + setSelectedPlatform(getSelectedPlatform(config)); + }, [config]); + + if (config === null || teamIdForApi === undefined) return null; + + // mdm is not enabled for mac or windows. + if ( + !config.mdm.enabled_and_configured && + !config.mdm.windows_enabled_and_configured + ) { return ; } - return isPremiumTier ? ( + // Not premium shows premium message + if (!isPremiumTier) { + return ( + + ); + } + + const handleSelectPlatform = (platform: OSUpdatesSupportedPlatform) => { + setSelectedPlatform(platform); + }; + + return (

Remotely encourage the installation of macOS updates on hosts assigned @@ -50,22 +81,17 @@ const OSUpdates = ({ router, teamIdForApi }: IOSUpdates) => {

-
- {OperatingSystemCard} -
-
- -
+ +
- +
- ) : ( - ); }; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/_styles.scss index 6ab1baa87..a94f1df4c 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSUpdates/_styles.scss @@ -9,13 +9,13 @@ max-width: $break-xxl; margin: 0 auto; display: flex; - justify-content: space-between; + justify-content: center; gap: $pad-xxlarge; + } - h2 { - font-size: $small; - margin: 0; - } + &__form-table-content, &__nudge-preview { + flex-grow: 1; + max-width: 640px; } &__os-versions-card { @@ -30,5 +30,10 @@ &__content { flex-direction: column; } + + &__form-table-content, + &__nudge-preview { + max-width: none; + } } } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx new file mode 100644 index 000000000..dfebbb4f2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/CurrentVersionSection.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import { IOperatingSystemVersion } from "interfaces/operating_system"; +import { + getOSVersions, + IOSVersionsResponse, +} from "services/entities/operating_systems"; + +import LastUpdatedText from "components/LastUpdatedText"; +import SectionHeader from "components/SectionHeader"; +import DataError from "components/DataError"; + +import OSVersionTable from "../OSVersionTable"; +import { OSUpdatesSupportedPlatform } from "../../OSUpdates"; +import OSVersionsEmptyState from "../OSVersionsEmptyState"; + +/** This overrides the `platform` attribute on IOperatingSystemVersion so that only our filtered platforms (currently + * "darwin" and "windows") values are included */ +export type IFilteredOperatingSystemVersion = Omit< + IOperatingSystemVersion, + "platform" +> & { + platform: OSUpdatesSupportedPlatform; +}; + +const baseClass = "os-updates-current-version-section"; + +interface ICurrentVersionSectionProps { + currentTeamId: number; +} + +const CurrentVersionSection = ({ + currentTeamId, +}: ICurrentVersionSectionProps) => { + const { data, isError, isLoading: isLoadingOsVersions } = useQuery< + IOSVersionsResponse, + AxiosError + >(["os_versions", currentTeamId], () => getOSVersions(), { + retry: false, + refetchOnWindowFocus: false, + }); + + const generateSubTitleText = () => { + return ( + + ); + }; + + if (!data) { + return null; + } + + const renderTable = () => { + if (isError) { + return ( + + ); + } + + if (!data.os_versions) { + return ; + } + + // We only want to show windows and mac versions atm. + const filteredOSVersionData = data.os_versions.filter((osVersion) => { + return ( + osVersion.platform === "windows" || osVersion.platform === "darwin" + ); + }) as IFilteredOperatingSystemVersion[]; + + return ( + + ); + }; + + return ( +
+ + {renderTable()} +
+ ); +}; + +export default CurrentVersionSection; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/_styles.scss new file mode 100644 index 000000000..1deeb58c2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/_styles.scss @@ -0,0 +1,17 @@ +.os-updates-current-version-section { + margin-bottom: $pad-xxlarge; + + &__title { + display: flex; + align-items: center; + gap: $pad-small; + padding-bottom: $pad-medium; + border-bottom: 1px solid $ui-fleet-black-10; + margin-bottom: $pad-xxlarge; + + h2 { + font-weight: normal; + font-size: 18px; // TODO: update font variables to include 18px; + } + } +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/index.ts new file mode 100644 index 000000000..1c427d2a0 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/CurrentVersionSection/index.ts @@ -0,0 +1 @@ +export { default } from "./CurrentVersionSection"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/OsMinVersionForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/MacOSTargetForm/MacOSTargetForm.tsx similarity index 69% rename from frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/OsMinVersionForm.tsx rename to frontend/pages/ManageControlsPage/OSUpdates/components/MacOSTargetForm/MacOSTargetForm.tsx index 033e9bab0..4e59a7a98 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/OsMinVersionForm.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/MacOSTargetForm/MacOSTargetForm.tsx @@ -1,26 +1,25 @@ import React, { useContext, useState } from "react"; -import { useQuery } from "react-query"; import { isEmpty } from "lodash"; +import classnames from "classnames"; +import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; import { NotificationContext } from "context/notification"; -import { AppContext } from "context/app"; import configAPI from "services/entities/config"; -import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; +import teamsAPI from "services/entities/teams"; // @ts-ignore import InputField from "components/forms/fields/InputField"; import Button from "components/buttons/Button"; import validatePresence from "components/forms/validators/validate_presence"; -import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; -const baseClass = "os-min-version-form"; +const baseClass = "mac-os-target-form"; -interface IMinOsVersionFormData { +interface IMacOSTargetFormData { minOsVersion: string; deadline: string; } -interface IMinOsVersionFormErrors { +interface IMacOSTargetFormErrors { minOsVersion?: string; deadline?: string; } @@ -33,8 +32,8 @@ const validateDeadline = (value: string) => { return /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/.test(value); }; -const validateForm = (formData: IMinOsVersionFormData) => { - const errors: IMinOsVersionFormErrors = {}; +const validateForm = (formData: IMacOSTargetFormData) => { + const errors: IMacOSTargetFormErrors = {}; if (!validatePresence(formData.minOsVersion)) { errors.minOsVersion = "The minimum version is required."; @@ -62,49 +61,32 @@ const createMdmConfigData = (minOsVersion: string, deadline: string) => { }; }; -interface IOsMinVersionForm { - currentTeamId?: number; +interface IMacOSTargetFormProps { + currentTeamId: number; + defaultMinOsVersion: string; + defaultDeadline: string; + inAccordion?: boolean; } -const OsMinVersionForm = ({ - currentTeamId = APP_CONTEXT_NO_TEAM_ID, -}: IOsMinVersionForm) => { +const MacOSTargetForm = ({ + currentTeamId, + defaultMinOsVersion, + defaultDeadline, + inAccordion = false, +}: IMacOSTargetFormProps) => { const { renderFlash } = useContext(NotificationContext); - const { config } = useContext(AppContext); const [isSaving, setIsSaving] = useState(false); - const [minOsVersion, setMinOsVersion] = useState( - currentTeamId === APP_CONTEXT_NO_TEAM_ID - ? config?.mdm.macos_updates.minimum_version ?? "" - : "" - ); - const [deadline, setDeadline] = useState( - currentTeamId === APP_CONTEXT_NO_TEAM_ID - ? config?.mdm.macos_updates.deadline ?? "" - : "" - ); + const [minOsVersion, setMinOsVersion] = useState(defaultMinOsVersion); + const [deadline, setDeadline] = useState(defaultDeadline); const [minOsVersionError, setMinOsVersionError] = useState< string | undefined >(); const [deadlineError, setDeadlineError] = useState(); - useQuery( - ["apple mdm config", currentTeamId], - - // NOTE: this method should never be called with 0 as we sure to have - // a value for current team from the "enabled" option. We add it here - // to fulfill correct typing. - () => teamsAPI.load(currentTeamId || 0), - { - refetchOnWindowFocus: false, - staleTime: Infinity, - enabled: currentTeamId > APP_CONTEXT_NO_TEAM_ID, - onSuccess: (data) => { - setMinOsVersion(data.team?.mdm?.macos_updates?.minimum_version ?? ""); - setDeadline(data.team?.mdm?.macos_updates?.deadline ?? ""); - }, - } - ); + const classNames = classnames(baseClass, { + [`${baseClass}__accordion-form`]: inAccordion, + }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -141,7 +123,7 @@ const OsMinVersionForm = ({ }; return ( -
+ { - return ( -
-

End user experience

+interface INudgeDescriptionProps { + platform: OSUpdatesSupportedPlatform; +} +const NudgeDescription = ({ platform }: INudgeDescriptionProps) => { + return platform === "darwin" ? ( + <>

When a minimum version is saved, the end user sees the below window until their macOS version is at or above the minimum version. @@ -20,11 +25,49 @@ const NudgePreview = () => { url="https://fleetdm.com/docs/using-fleet/mdm-macos-updates" newTab /> - OS update preview screenshot + ) : ( + <> +

+ When a new Windows update is published, the update will be downloaded + and installed automatically before 8am and after 5pm (end user’s local + time). Before the deadline passes, users will be able to defer restarts. + After the deadline passes restart will be forced regardless of active + hours. +

+ + + ); +}; + +type INudgeImageProps = INudgeDescriptionProps; + +const NudgeImage = ({ platform }: INudgeImageProps) => { + return ( + OS update preview screenshot + ); +}; + +interface INudgePreviewProps { + platform: OSUpdatesSupportedPlatform; +} + +const NudgePreview = ({ platform }: INudgePreviewProps) => { + return ( +
+

End user experience

+ +
); }; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/NudgePreview/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/NudgePreview/_styles.scss index fefbbc82f..38b57caa7 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/NudgePreview/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/NudgePreview/_styles.scss @@ -1,8 +1,11 @@ .nudge-preview { + box-sizing: border-box; background-color: $ui-off-white; border-radius: $border-radius; border: 1px solid $ui-fleet-black-10; padding: $pad-xxlarge; + max-width: 640px; + flex-grow: 1; &__preview-img { margin-top: $pad-xxlarge; @@ -11,4 +14,8 @@ max-width: 540px; margin: 40px auto 0; } + + @media (max-width: $break-md) { + max-width: none; + } } diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/OSTypeCell.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/OSTypeCell.tsx new file mode 100644 index 000000000..a778d1dab --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/OSTypeCell.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import Icon from "components/Icon"; + +import { OSUpdatesSupportedPlatform } from "../../OSUpdates"; + +const baseClass = "os-type-cell"; + +interface IOSTypeCellProps { + platform: OSUpdatesSupportedPlatform; + versionName: string; +} + +const OSTypeCell = ({ platform, versionName }: IOSTypeCellProps) => { + return ( +
+ + {versionName} +
+ ); +}; + +export default OSTypeCell; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/_styles.scss new file mode 100644 index 000000000..7ca943545 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/_styles.scss @@ -0,0 +1,5 @@ +.os-type-cell { + display: flex; + align-items: center; + gap: $pad-small; +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/index.ts new file mode 100644 index 000000000..2c2bf3712 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSTypeCell/index.ts @@ -0,0 +1 @@ +export { default } from "./OSTypeCell"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTable.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTable.tsx new file mode 100644 index 000000000..c199b360d --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTable.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import { IOperatingSystemVersion } from "interfaces/operating_system"; + +import TableContainer from "components/TableContainer"; + +import { generateTableHeaders } from "./OSVersionTableConfig"; +import OSVersionsEmptyState from "../OSVersionsEmptyState"; + +const baseClass = "os-version-table"; + +interface IOSVersionTableProps { + osVersionData: IOperatingSystemVersion[]; + currentTeamId: number; + isLoading: boolean; +} + +const DEFAULT_SORT_HEADER = "hosts_count"; +const DEFAULT_SORT_DIRECTION = "desc"; + +const OSVersionTable = ({ + osVersionData, + currentTeamId, + isLoading, +}: IOSVersionTableProps) => { + const columns = generateTableHeaders(currentTeamId); + + return ( +
+ +
+ ); +}; + +export default OSVersionTable; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTableConfig.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTableConfig.tsx new file mode 100644 index 000000000..01fa65c8e --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/OSVersionTableConfig.tsx @@ -0,0 +1,85 @@ +import React from "react"; + +import { IOperatingSystemVersion } from "interfaces/operating_system"; + +import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import ViewAllHostsLink from "components/ViewAllHostsLink"; +import TextCell from "components/TableContainer/DataTable/TextCell"; + +import OSTypeCell from "../OSTypeCell"; +import { IFilteredOperatingSystemVersion } from "../CurrentVersionSection/CurrentVersionSection"; + +interface IOSTypeCellProps { + row: { + original: IFilteredOperatingSystemVersion; + }; +} + +interface IHostCellProps { + row: { + original: IOperatingSystemVersion; + }; +} + +interface IHeaderProps { + column: { + title: string; + isSortedDesc: boolean; + }; +} + +// eslint-disable-next-line import/prefer-default-export +export const generateTableHeaders = (teamId: number) => { + return [ + { + title: "OS type", + Header: "OS type", + disableSortBy: true, + accessor: "platform", + Cell: ({ row }: IOSTypeCellProps) => ( + + ), + }, + { + title: "Version", + Header: "Version", + disableSortBy: true, + accessor: "version", + }, + { + title: "Hosts", + accessor: "hosts_count", + disableSortBy: false, + Header: (cellProps: IHeaderProps) => ( + + ), + Cell: ({ row }: IHostCellProps): JSX.Element => { + const { hosts_count, name_only, version } = row.original; + return ( + + + + + + + + + ); + }, + }, + ]; +}; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/_styles.scss new file mode 100644 index 000000000..1e5eceb61 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/_styles.scss @@ -0,0 +1,18 @@ +.os-version-table { + .hosts-cell__wrapper { + display: flex; + align-items: center; + justify-content: space-between; + } + + .os-hosts-link { + opacity: 0; + transition: 250ms; + } + + tr:hover { + .os-hosts-link { + opacity: 1; + } + } +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/index.ts new file mode 100644 index 000000000..804cd6f6f --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionTable/index.ts @@ -0,0 +1 @@ +export { default } from "./OSVersionTable"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/OSVersionsEmptyState.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/OSVersionsEmptyState.tsx new file mode 100644 index 000000000..583bc80b7 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/OSVersionsEmptyState.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +import EmptyTable from "components/EmptyTable"; + +const baseClass = "os-versions-empty-state"; + +const OSVersionsEmptyState = () => { + return ( + + This report is updated every hour to protect +
the performance of your devices. + + } + /> + ); +}; + +export default OSVersionsEmptyState; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/_styles.scss new file mode 100644 index 000000000..60d56b463 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/_styles.scss @@ -0,0 +1,3 @@ +.os-versions-empty-state { + margin: 0 auto $pad-xxlarge; +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/index.ts new file mode 100644 index 000000000..a98dd1520 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/OSVersionsEmptyState/index.ts @@ -0,0 +1 @@ +export { default } from "./OSVersionsEmptyState"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/index.ts deleted file mode 100644 index 7dbcd286b..000000000 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/OsMinVersionForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./OsMinVersionForm"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/PlatformsAccordion.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/PlatformsAccordion.tsx new file mode 100644 index 000000000..b88d6eb58 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/PlatformsAccordion.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { + Accordion, + AccordionItem, + AccordionItemButton, + AccordionItemHeading, + AccordionItemPanel, + AccordionItemState, +} from "react-accessible-accordion"; +import classnames from "classnames"; + +import Icon from "components/Icon"; + +import MacOSTargetForm from "../MacOSTargetForm"; +import WindowsTargetForm from "../WindowsTargetForm"; +import { OSUpdatesSupportedPlatform } from "../../OSUpdates"; + +const baseClass = "platforms-accordion"; + +const generateIconClassNames = (expanded?: boolean) => { + return classnames(`${baseClass}__item-icon`, { + [`${baseClass}__item-closed`]: !expanded, + }); +}; + +interface IPlatformsAccordionProps { + currentTeamId: number; + defaultMacOSVersion: string; + defaultMacOSDeadline: string; + defaultWindowsDeadlineDays: string; + defaultWindowsGracePeriodDays: string; + onSelectAccordionItem: (platform: OSUpdatesSupportedPlatform) => void; +} + +const PlatformsAccordion = ({ + currentTeamId, + defaultMacOSDeadline, + defaultMacOSVersion, + defaultWindowsDeadlineDays, + defaultWindowsGracePeriodDays, + onSelectAccordionItem, +}: IPlatformsAccordionProps) => { + return ( + + onSelectAccordionItem(selected[0] as OSUpdatesSupportedPlatform) + } + > + + + + macOS + + {({ expanded }) => ( + + )} + + + + + + + + + + + Windows + + {({ expanded }) => ( + + )} + + + + + + + + + ); +}; + +export default PlatformsAccordion; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/_styles.scss new file mode 100644 index 000000000..05c406238 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/_styles.scss @@ -0,0 +1,32 @@ +.platforms-accordion { + &__accordion { + // this was an arbitrary min width to make sure the accordion stays the same + // width regardless of which tab is open. + min-width: 530px; + } + + &__accordion-button { + padding: $pad-medium 0; + border-bottom: 1px solid $ui-fleet-black-10; + display: flex; + align-items: center; + justify-content: space-between; + + >span { + font-weight: $bold; + font-size: $x-small; + } + } + + &__accordion-panel { + border-bottom: 1px solid $ui-fleet-black-10; + } + + &__item-icon { + transition: transform 0.25s ease; + } + + &__item-closed { + transform: rotate(180deg); + } +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/index.ts new file mode 100644 index 000000000..afe8b1670 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformsAccordion/index.ts @@ -0,0 +1 @@ +export { default } from "./PlatformsAccordion"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx new file mode 100644 index 000000000..2c9e861d2 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/TargetSection.tsx @@ -0,0 +1,155 @@ +import React, { useContext } from "react"; +import { useQuery } from "react-query"; + +import { + API_NO_TEAM_ID, + APP_CONTEXT_NO_TEAM_ID, + ITeamConfig, +} from "interfaces/team"; +import { IConfig } from "interfaces/config"; +import { AppContext } from "context/app"; +import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; + +import Spinner from "components/Spinner"; +import SectionHeader from "components/SectionHeader"; + +import MacOSTargetForm from "../MacOSTargetForm"; +import WindowsTargetForm from "../WindowsTargetForm"; +import PlatformsAccordion from "../PlatformsAccordion"; +import { OSUpdatesSupportedPlatform } from "../../OSUpdates"; + +const baseClass = "os-updates-target-section"; + +const getDefaultMacOSVersion = ( + currentTeam: number, + appConfig: IConfig, + teamConfig?: ITeamConfig +) => { + return currentTeam === API_NO_TEAM_ID + ? appConfig?.mdm.macos_updates.minimum_version ?? "" + : teamConfig?.mdm?.macos_updates.minimum_version ?? ""; +}; + +const getDefaultMacOSDeadline = ( + currentTeam: number, + appConfig: IConfig, + teamConfig?: ITeamConfig +) => { + return currentTeam === API_NO_TEAM_ID + ? appConfig?.mdm.macos_updates.deadline || "" + : teamConfig?.mdm?.macos_updates.deadline || ""; +}; + +const getDefaultWindowsDeadlineDays = ( + currentTeam: number, + appConfig: IConfig, + teamConfig?: ITeamConfig +) => { + return currentTeam === API_NO_TEAM_ID + ? appConfig.mdm.windows_updates.deadline_days?.toString() ?? "" + : teamConfig?.mdm?.windows_updates.deadline_days?.toString() ?? ""; +}; + +const getDefaultWindowsGracePeriodDays = ( + currentTeam: number, + appConfig: IConfig, + teamConfig?: ITeamConfig +) => { + return currentTeam === API_NO_TEAM_ID + ? appConfig.mdm.windows_updates.grace_period_days?.toString() ?? "" + : teamConfig?.mdm?.windows_updates.grace_period_days?.toString() ?? ""; +}; + +interface ITargetSectionProps { + currentTeamId: number; + onSelectAccordionItem: (platform: OSUpdatesSupportedPlatform) => void; +} + +const TargetSection = ({ + currentTeamId, + onSelectAccordionItem, +}: ITargetSectionProps) => { + const { config } = useContext(AppContext); + + // We make the call at this component as multiple children components need + // this data. + const { data: teamData, isLoading: isLoadingTeam, isError } = useQuery< + ILoadTeamResponse, + Error, + ITeamConfig + >(["team-config", currentTeamId], () => teamsAPI.load(currentTeamId), { + refetchOnWindowFocus: false, + enabled: currentTeamId > APP_CONTEXT_NO_TEAM_ID, + select: (data) => data.team, + }); + + if (!config) return null; + + const isMacMdmEnabled = config.mdm.enabled_and_configured; + const isWindowsMdmEnabled = config.mdm.windows_enabled_and_configured; + + // Loading state rendering + if (isLoadingTeam) { + return ; + } + + const defaultMacOSVersion = getDefaultMacOSVersion( + currentTeamId, + config, + teamData + ); + const defaultMacOSDeadline = getDefaultMacOSDeadline( + currentTeamId, + config, + teamData + ); + const defaultWindowsDeadlineDays = getDefaultWindowsDeadlineDays( + currentTeamId, + config, + teamData + ); + const defaultWindowsGracePeriodDays = getDefaultWindowsGracePeriodDays( + currentTeamId, + config, + teamData + ); + + const renderTargetForms = () => { + if (isMacMdmEnabled && isWindowsMdmEnabled) { + return ( + + ); + } else if (isMacMdmEnabled) { + return ( + + ); + } + return ( + + ); + }; + + return ( +
+ + {renderTargetForms()} +
+ ); +}; + +export default TargetSection; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/_styles.scss new file mode 100644 index 000000000..ce034966a --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/_styles.scss @@ -0,0 +1,15 @@ +.os-updates-target-section { + &__title { + display: flex; + align-items: center; + gap: $pad-small; + padding-bottom: $pad-medium; + border-bottom: 1px solid $ui-fleet-black-10; + margin-bottom: $pad-xxlarge; + + h2 { + font-weight: normal; + font-size: 18px; + } + } +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/index.ts new file mode 100644 index 000000000..a84c041d7 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/TargetSection/index.ts @@ -0,0 +1 @@ +export { default } from "./TargetSection"; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx new file mode 100644 index 000000000..d4353d5f4 --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/WindowsTargetForm.tsx @@ -0,0 +1,168 @@ +import React, { useContext, useState } from "react"; +import { isEmpty } from "lodash"; +import classnames from "classnames"; + +import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; +import { NotificationContext } from "context/notification"; +import configAPI from "services/entities/config"; +import teamsAPI from "services/entities/teams"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import Button from "components/buttons/Button"; +import validatePresence from "components/forms/validators/validate_presence"; + +const baseClass = "windows-target-form"; + +interface IWindowsTargetFormData { + deadlineDays: string; + gracePeriodDays: string; +} + +interface IWindowsTargetFormErrors { + deadlineDays?: string; + gracePeriodDays?: string; +} + +// validates that a string is a number from 0 to 30 +const validateDeadlineDays = (value: string) => { + if (value === "") return false; + + const parsedValue = Number(value); + return Number.isInteger(parsedValue) && parsedValue >= 0 && parsedValue <= 30; +}; + +// validates string is a number from 0 to 7 +const validateGracePeriodDays = (value: string) => { + if (value === "") return false; + + const parsedValue = Number(value); + return Number.isInteger(parsedValue) && parsedValue >= 0 && parsedValue <= 7; +}; + +const validateForm = (formData: IWindowsTargetFormData) => { + const errors: IWindowsTargetFormErrors = {}; + + if (!validatePresence(formData.deadlineDays)) { + errors.deadlineDays = "The deadline days is required."; + } else if (!validateDeadlineDays(formData.deadlineDays)) { + errors.deadlineDays = "Deadline must meet criteria below."; + } + + if (!validatePresence(formData.gracePeriodDays)) { + errors.gracePeriodDays = "The grace period days is required."; + } else if (!validateGracePeriodDays(formData.gracePeriodDays)) { + errors.gracePeriodDays = "Grace period must meet criteria below."; + } + + return errors; +}; + +const createMdmConfigData = (deadlineDays: string, gracePeriodDays: string) => { + return { + mdm: { + windows_updates: { + deadline_days: parseInt(deadlineDays, 10), + grace_period_days: parseInt(gracePeriodDays, 10), + }, + }, + }; +}; + +interface IWindowsTargetFormProps { + currentTeamId: number; + defaultDeadlineDays: string; + defaultGracePeriodDays: string; + inAccordion?: boolean; +} + +const WindowsTargetForm = ({ + currentTeamId, + defaultDeadlineDays, + defaultGracePeriodDays, + inAccordion = false, +}: IWindowsTargetFormProps) => { + const { renderFlash } = useContext(NotificationContext); + const [isSaving, setIsSaving] = useState(false); + const [deadlineDays, setDeadlineDays] = useState( + defaultDeadlineDays.toString() + ); + const [gracePeriodDays, setGracePeriodDays] = useState( + defaultGracePeriodDays.toString() + ); + const [deadlineDaysError, setDeadlineDaysError] = useState< + string | undefined + >(); + const [gracePeriodDaysError, setGracePeriodDaysError] = useState< + string | undefined + >(); + + const classNames = classnames(baseClass, { + [`${baseClass}__accordion-form`]: inAccordion, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const errors = validateForm({ + deadlineDays, + gracePeriodDays, + }); + + setDeadlineDaysError(errors.deadlineDays); + setGracePeriodDaysError(errors.gracePeriodDays); + + if (isEmpty(errors)) { + setIsSaving(true); + const updateData = createMdmConfigData(deadlineDays, gracePeriodDays); + try { + currentTeamId === APP_CONTEXT_NO_TEAM_ID + ? await configAPI.update(updateData) + : await teamsAPI.update(updateData, currentTeamId); + renderFlash( + "success", + "Successfully updated Windows OS update options." + ); + } catch { + renderFlash("error", "Couldn’t update. Please try again."); + } finally { + setIsSaving(false); + } + } + }; + + const handleDeadlineDaysChange = (val: string) => { + setDeadlineDays(val); + }; + + const handleGracePeriodDays = (val: string) => { + setGracePeriodDays(val); + }; + + return ( + + + + + + ); +}; + +export default WindowsTargetForm; diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/_styles.scss b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/_styles.scss new file mode 100644 index 000000000..613dedafb --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/_styles.scss @@ -0,0 +1,7 @@ +.windows-target-form { + + &__accordion-form { + padding: $pad-large; + background-color: $ui-fleet-blue-10; + } +} diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/index.ts b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/index.ts new file mode 100644 index 000000000..79a82356f --- /dev/null +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/WindowsTargetForm/index.ts @@ -0,0 +1 @@ +export { default } from "./WindowsTargetForm"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx index abfd997d1..abaebe77e 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx @@ -8,6 +8,8 @@ import mdmAPI from "services/entities/mdm"; import { NotificationContext } from "context/notification"; import Spinner from "components/Spinner"; +import SectionHeader from "components/SectionHeader"; + import BootstrapPackagePreview from "./components/BootstrapPackagePreview"; import PackageUploader from "./components/BootstrapPackageUploader"; import UploadedPackageView from "./components/UploadedPackageView"; @@ -65,7 +67,7 @@ const BootstrapPackage = ({ currentTeamId }: IBootstrapPackageProps) => { return (
-

Bootstrap package

+ {isLoading ? ( ) : ( diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/_styles.scss index 6d3923dba..b4e8b0f74 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/_styles.scss @@ -1,13 +1,4 @@ .bootstrap-package { - > h2 { - margin: 0 0 $pad-large; - padding-bottom: $pad-small; - font-size: $medium; - font-weight: $regular; - color: $core-fleet-black; - border-bottom: solid 1px $ui-fleet-black-10; - } - &__content { max-width: $break-xxl; margin: 0 auto; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx index 079228178..30602d704 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/EndUserAuthentication/EndUserAuthentication.tsx @@ -1,16 +1,17 @@ import React from "react"; import { InjectedRouter } from "react-router"; import PATHS from "router/paths"; +import { useQuery } from "react-query"; import configAPI from "services/entities/config"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import { IConfig, IMdmConfig } from "interfaces/config"; +import { ITeamConfig } from "interfaces/team"; import SectionHeader from "components/SectionHeader/SectionHeader"; -import EndUserExperiencePreview from "pages/ManageControlsPage/components/EndUserExperiencePreview"; -import { useQuery } from "react-query"; -import { ITeamConfig } from "interfaces/team"; import Spinner from "components/Spinner"; +import EndUserExperiencePreview from "pages/ManageControlsPage/components/EndUserExperiencePreview"; + import RequireEndUserAuth from "./components/RequireEndUserAuth/RequireEndUserAuth"; import EndUserAuthForm from "./components/EndUserAuthForm/EndUserAuthForm"; diff --git a/frontend/services/entities/operating_systems.ts b/frontend/services/entities/operating_systems.ts index c8a0b44d7..58c8c7548 100644 --- a/frontend/services/entities/operating_systems.ts +++ b/frontend/services/entities/operating_systems.ts @@ -21,12 +21,13 @@ export interface IGetOSVersionsRequest { export interface IGetOSVersionsQueryKey extends IGetOSVersionsRequest { scope: string; } + export interface IOSVersionsResponse { counts_updated_at: string; os_versions: IOperatingSystemVersion[]; } -export const getOSVersions = async ({ +export const getOSVersions = ({ id, platform, teamId, diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index e73baf0e7..7c49b841d 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -41,10 +41,14 @@ export interface IUpdateTeamFormData { webhook_settings: Partial; integrations: IIntegrations; mdm: { - macos_updates: { + macos_updates?: { minimum_version: string; deadline: string; }; + windows_updates?: { + deadline_days: number; + grace_period_days: number; + }; }; } diff --git a/pkg/optjson/optjson.go b/pkg/optjson/optjson.go index 58d77ccad..dbc44dd5e 100644 --- a/pkg/optjson/optjson.go +++ b/pkg/optjson/optjson.go @@ -93,6 +93,45 @@ func (b *Bool) UnmarshalJSON(data []byte) error { return nil } +// Int represents an optional integer value. +type Int struct { + Set bool + Valid bool + Value int +} + +func SetInt(v int) Int { + return Int{Set: true, Valid: true, Value: v} +} + +func (i Int) MarshalJSON() ([]byte, error) { + if !i.Valid { + return []byte("null"), nil + } + return json.Marshal(i.Value) +} + +func (i *Int) UnmarshalJSON(data []byte) error { + // If this method was called, the value was set. + i.Set = true + i.Valid = false + + if bytes.Equal(data, []byte("null")) { + // The key was set to null, blank the value + i.Value = 0 + return nil + } + + // The key isn't set to null + var v int + if err := json.Unmarshal(data, &v); err != nil { + return err + } + i.Value = v + i.Valid = true + return nil +} + type Slice[T any] struct { Set bool Valid bool diff --git a/pkg/optjson/optjson_test.go b/pkg/optjson/optjson_test.go index 98b285082..2a1d01e14 100644 --- a/pkg/optjson/optjson_test.go +++ b/pkg/optjson/optjson_test.go @@ -90,7 +90,7 @@ func TestString(t *testing.T) { } func TestBool(t *testing.T) { - t.Run("plain string", func(t *testing.T) { + t.Run("plain bool", func(t *testing.T) { cases := []struct { data string wantErr string @@ -170,6 +170,90 @@ func TestBool(t *testing.T) { }) } +func TestInt(t *testing.T) { + t.Run("plain int", func(t *testing.T) { + cases := []struct { + data string + wantErr string + wantRes Int + marshalAs string + }{ + {`1`, "", Int{Set: true, Valid: true, Value: 1}, `1`}, + {`-1`, "", Int{Set: true, Valid: true, Value: -1}, `-1`}, + {`0`, "", Int{Set: true, Valid: true, Value: 0}, `0`}, + {`1.23`, "cannot unmarshal number 1.23 into Go value of type int", Int{Set: true, Valid: false, Value: 0}, `null`}, + {`null`, "", Int{Set: true, Valid: false, Value: 0}, `null`}, + {`"x"`, "cannot unmarshal string into Go value of type int", Int{Set: true, Valid: false, Value: 0}, `null`}, + {`{"v": "foo"}`, "cannot unmarshal object into Go value of type int", Int{Set: true, Valid: false, Value: 0}, `null`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var i Int + err := json.Unmarshal([]byte(c.data), &i) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, i) + + b, err := json.Marshal(i) + require.NoError(t, err) + require.Equal(t, c.marshalAs, string(b)) + }) + } + }) + + t.Run("struct", func(t *testing.T) { + type N struct { + I2 Int `json:"i2"` + } + type T struct { + I Int `json:"i"` + B bool `json:"b"` + N N `json:"n"` + } + + cases := []struct { + data string + wantErr string + wantRes T + marshalAs string + }{ + {`{}`, "", T{}, `{"i": null, "b": false, "n": {"i2": null}}`}, + {`{"x": "nope"}`, "", T{}, `{"i": null, "b": false, "n": {"i2": null}}`}, + {`{"i": 1, "b": true}`, "", T{I: Int{Set: true, Valid: true, Value: 1}, B: true}, `{"i": 1, "b": true, "n": {"i2": null}}`}, + {`{"i": null, "b": true, "n": {}}`, "", T{I: Int{Set: true, Valid: false, Value: 0}, B: true}, `{"i": null, "b": true, "n": {"i2": null}}`}, + {`{"i": 1, "b": true, "n": {"i2": 2}}`, "", T{I: Int{Set: true, Valid: true, Value: 1}, B: true, N: N{I2: Int{Set: true, Valid: true, Value: 2}}}, `{"i": 1, "b": true, "n": {"i2": 2}}`}, + {`{"i": 1, "b": true, "n": {"i2": null}}`, "", T{I: Int{Set: true, Valid: true, Value: 1}, B: true, N: N{I2: Int{Set: true, Valid: false, Value: 0}}}, `{"i": 1, "b": true, "n": {"i2": null}}`}, + {`{"i": "", "b": true}`, "cannot unmarshal string into Go struct", T{I: Int{Set: true, Valid: false, Value: 0}, B: false}, `{"i": null, "b": false, "n": {"i2": null}}`}, + {`{"b": true, "n": {"i2": true}}`, "cannot unmarshal bool into Go struct", T{I: Int{Set: false, Valid: false, Value: 0}, B: true, N: N{I2: Int{Set: true, Valid: false, Value: 0}}}, `{"i": null, "b": true, "n": {"i2": null}}`}, + } + + for _, c := range cases { + t.Run(c.data, func(t *testing.T) { + var tt T + err := json.Unmarshal([]byte(c.data), &tt) + + if c.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, c.wantRes, tt) + + b, err := json.Marshal(tt) + require.NoError(t, err) + require.JSONEq(t, c.marshalAs, string(b)) + }) + } + }) +} + func TestSlice(t *testing.T) { t.Run("slice of ints", func(t *testing.T) { cases := []struct { diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 13f667b93..e1553f430 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -841,7 +841,6 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) { func expectAppleProfiles( t *testing.T, ds *Datastore, - newSet []*fleet.MDMAppleConfigProfile, tmID *uint, want []*fleet.MDMAppleConfigProfile, ) map[string]uint { @@ -878,7 +877,7 @@ func testBatchSetMDMAppleProfiles(t *testing.T, ds *Datastore) { ctx := context.Background() err := ds.BatchSetMDMAppleProfiles(ctx, tmID, newSet) require.NoError(t, err) - return expectAppleProfiles(t, ds, newSet, tmID, want) + return expectAppleProfiles(t, ds, tmID, want) } withTeamID := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile { diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index b87348a6b..8b73aff9a 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -8,6 +8,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/go-kit/kit/log/level" "github.com/jmoiron/sqlx" ) @@ -100,6 +101,9 @@ func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macPro func (ds *Datastore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) { + // this lists custom profiles, it explicitly filters out the fleet-reserved + // ones (reserved identifiers for Apple profiles, reserved names for Windows). + var profs []*fleet.MDMConfigProfilePayload const selectStmt = ` @@ -142,7 +146,8 @@ FROM ( FROM mdm_windows_configuration_profiles WHERE - team_id = ? + team_id = ? AND + name NOT IN (?) ) as combined_profiles ` @@ -156,8 +161,13 @@ FROM ( for k := range fleetIdentsMap { fleetIdentifiers = append(fleetIdentifiers, k) } + fleetNamesMap := microsoft_mdm.FleetReservedProfileNames() + fleetNames := make([]string, 0, len(fleetNamesMap)) + for k := range fleetNamesMap { + fleetNames = append(fleetNames, k) + } - args := []any{globalOrTeamID, fleetIdentifiers, globalOrTeamID} + args := []any{globalOrTeamID, fleetIdentifiers, globalOrTeamID, fleetNames} stmt, args := appendListOptionsWithCursorToSQL(selectStmt, args, &opt) stmt, args, err := sqlx.In(stmt, args...) diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index 259de5aec..b898da8ca 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -9,6 +9,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" @@ -183,8 +184,8 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) { ctx := context.Background() err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet) require.NoError(t, err) - expectAppleProfiles(t, ds, newAppleSet, tmID, wantApple) - expectWindowsProfiles(t, ds, newWindowsSet, tmID, wantWindows) + expectAppleProfiles(t, ds, tmID, wantApple) + expectWindowsProfiles(t, ds, tmID, wantWindows) } withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile { @@ -305,7 +306,7 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { require.Len(t, profs, 0) require.Equal(t, *meta, fleet.PaginationMetadata{}) - // add fleet-managed profiles for the team and globally + // add fleet-managed Apple profiles for the team and globally for idf := range mobileconfig.FleetPayloadIdentifiers() { _, err = ds.NewMDMAppleConfigProfile(ctx, *generateCP("name_"+idf, idf, team.ID)) require.NoError(t, err) @@ -324,6 +325,25 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) { require.Len(t, profs, 0) require.Equal(t, *meta, fleet.PaginationMetadata{}) + // add fleet-managed Windows profiles for the team and globally + for name := range microsoft_mdm.FleetReservedProfileNames() { + _, err = ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, TeamID: &team.ID, SyncML: winProf}) + require.NoError(t, err) + _, err = ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, TeamID: nil, SyncML: winProf}) + require.NoError(t, err) + } + + // still returns no result + profs, meta, err = ds.ListMDMConfigProfiles(ctx, nil, opts) + require.NoError(t, err) + require.Len(t, profs, 0) + require.Equal(t, *meta, fleet.PaginationMetadata{}) + + profs, meta, err = ds.ListMDMConfigProfiles(ctx, &team.ID, opts) + require.NoError(t, err) + require.Len(t, profs, 0) + require.Equal(t, *meta, fleet.PaginationMetadata{}) + // create a mac profile for global and a Windows profile for team profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("A", "A", 0)) require.NoError(t, err) diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index ea93dd7ba..58eca2063 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -700,6 +700,18 @@ func (ds *Datastore) DeleteMDMWindowsConfigProfile(ctx context.Context, profileU return nil } +func (ds *Datastore) DeleteMDMWindowsConfigProfileByTeamAndName(ctx context.Context, teamID *uint, profileName string) error { + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM mdm_windows_configuration_profiles WHERE team_id=? AND name=?`, globalOrTeamID, profileName) + if err != nil { + return ctxerr.Wrap(ctx, err) + } + return nil +} + func subqueryHostsMDMWindowsOSSettingsStatusFailed() (string, []interface{}) { sql := ` SELECT @@ -1358,6 +1370,51 @@ INSERT INTO }, nil } +func (ds *Datastore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error { + profileUUID := uuid.New().String() + stmt := ` +INSERT INTO + mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) +(SELECT ?, ?, ?, ? FROM DUAL WHERE + NOT EXISTS ( + SELECT 1 FROM mdm_apple_configuration_profiles WHERE name = ? AND team_id = ? + ) +) +ON DUPLICATE KEY UPDATE + syncml = VALUES(syncml) +` + + var teamID uint + if cp.TeamID != nil { + teamID = *cp.TeamID + } + + res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID) + if err != nil { + switch { + case isDuplicate(err): + return &existsError{ + ResourceType: "MDMWindowsConfigProfile.Name", + Identifier: cp.Name, + TeamID: cp.TeamID, + } + default: + return ctxerr.Wrap(ctx, err, "creating new windows mdm config profile") + } + } + + aff, _ := res.RowsAffected() + if aff == 0 { + return &existsError{ + ResourceType: "MDMWindowsConfigProfile.Name", + Identifier: cp.Name, + TeamID: cp.TeamID, + } + } + + return nil +} + func (ds *Datastore) batchSetMDMWindowsProfilesDB( ctx context.Context, tx sqlx.ExtContext, diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 30ba14fe0..3aceb49b0 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -33,6 +33,7 @@ func TestMDMWindows(t *testing.T) { {"TestBulkOperationsMDMWindowsHostProfilesBatch3", testBulkOperationsMDMWindowsHostProfilesBatch3}, {"TestGetMDMWindowsProfilesContents", testGetMDMWindowsProfilesContents}, {"TestMDMWindowsConfigProfiles", testMDMWindowsConfigProfiles}, + {"TestSetOrReplaceMDMWindowsConfigProfile", testSetOrReplaceMDMWindowsConfigProfile}, {"TestMDMWindowsDiskEncryption", testMDMWindowsDiskEncryption}, {"TestMDMWindowsProfilesSummary", testMDMWindowsProfilesSummary}, {"TestBatchSetMDMWindowsProfiles", testBatchSetMDMWindowsProfiles}, @@ -1800,10 +1801,74 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) { require.NoError(t, err) } +func testSetOrReplaceMDMWindowsConfigProfile(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // nothing for no-team, nothing for team 1 + expectWindowsProfiles(t, ds, nil, nil) + expectWindowsProfiles(t, ds, ptr.Uint(1), nil) + + // create a profile for no-team + cp1 := *windowsConfigProfileForTest(t, "N1", "N1") + err := ds.SetOrUpdateMDMWindowsConfigProfile(ctx, cp1) + require.NoError(t, err) + + // creating the same profile for Apple / no-team fails + _, err = ds.NewMDMAppleConfigProfile(ctx, *generateCP("N1", "I1", 0)) + require.Error(t, err) + + profs1 := expectWindowsProfiles(t, ds, nil, []*fleet.MDMWindowsConfigProfile{&cp1}) + + // update the profile for no-team + cp2 := *windowsConfigProfileForTest(t, "N1", "N1.modified") + err = ds.SetOrUpdateMDMWindowsConfigProfile(ctx, cp2) + require.NoError(t, err) + + profs2 := expectWindowsProfiles(t, ds, nil, []*fleet.MDMWindowsConfigProfile{&cp2}) + + // profile UUIDs are the same + require.Equal(t, profs1["N1"], profs2["N1"]) + + // create a profile for Apple and team 1 with that name works + _, err = ds.NewMDMAppleConfigProfile(ctx, *generateCP("N1", "I1", 1)) + require.NoError(t, err) + + // try to create that profile for Windows and team 1 fails + cp3 := *windowsConfigProfileForTest(t, "N1", "N1") + cp3.TeamID = ptr.Uint(1) + err = ds.SetOrUpdateMDMWindowsConfigProfile(ctx, cp3) + require.Error(t, err) + + expectWindowsProfiles(t, ds, ptr.Uint(1), nil) + + // create a profile with the same name for team 2 works + cp4 := *windowsConfigProfileForTest(t, "N1", "N1") + cp4.TeamID = ptr.Uint(2) + err = ds.SetOrUpdateMDMWindowsConfigProfile(ctx, cp4) + require.NoError(t, err) + + profs3 := expectWindowsProfiles(t, ds, ptr.Uint(2), []*fleet.MDMWindowsConfigProfile{&cp4}) + // profile UUIDs are not the same as for no-team + require.NotEqual(t, profs3["N1"], profs2["N1"]) + + // create a different profile for no-team + cp5 := *windowsConfigProfileForTest(t, "N2", "N2") + err = ds.SetOrUpdateMDMWindowsConfigProfile(ctx, cp5) + require.NoError(t, err) + + expectWindowsProfiles(t, ds, nil, []*fleet.MDMWindowsConfigProfile{&cp2, &cp5}) + + // update that profile for no-team + cp6 := *windowsConfigProfileForTest(t, "N2", "N2.modified") + err = ds.SetOrUpdateMDMWindowsConfigProfile(ctx, cp6) + require.NoError(t, err) + + expectWindowsProfiles(t, ds, nil, []*fleet.MDMWindowsConfigProfile{&cp2, &cp6}) +} + func expectWindowsProfiles( t *testing.T, ds *Datastore, - newSet []*fleet.MDMWindowsConfigProfile, tmID *uint, want []*fleet.MDMWindowsConfigProfile, ) map[string]string { @@ -1844,7 +1909,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) { return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) }) require.NoError(t, err) - return expectWindowsProfiles(t, ds, newSet, tmID, want) + return expectWindowsProfiles(t, ds, tmID, want) } withTeamID := func(p *fleet.MDMWindowsConfigProfile, tmID uint) *fleet.MDMWindowsConfigProfile { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 27ff6bbab..04ba2906c 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 5b5fd2279..8c55ca1fa 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -586,6 +586,10 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2025-10-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(7), + GracePeriodDays: optjson.SetInt(3), + }, MacOSSetup: fleet.MacOSSetup{ BootstrapPackage: optjson.SetString("bootstrap"), MacOSSetupAssistant: optjson.SetString("assistant"), @@ -605,6 +609,10 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2025-10-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(7), + GracePeriodDays: optjson.SetInt(3), + }, MacOSSetup: fleet.MacOSSetup{ BootstrapPackage: optjson.SetString("bootstrap"), MacOSSetupAssistant: optjson.SetString("assistant"), diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 0dca4b7a7..5c5fa79e4 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -49,6 +49,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeMDMUnenrolled{}, ActivityTypeEditedMacOSMinVersion{}, + ActivityTypeEditedWindowsUpdates{}, ActivityTypeReadHostDiskEncryptionKey{}, @@ -769,6 +770,31 @@ func (a ActivityTypeEditedMacOSMinVersion) Documentation() (activity string, det }` } +type ActivityTypeEditedWindowsUpdates struct { + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` + DeadlineDays *int `json:"deadline_days"` + GracePeriodDays *int `json:"grace_period_days"` +} + +func (a ActivityTypeEditedWindowsUpdates) ActivityName() string { + return "edited_windows_updates" +} + +func (a ActivityTypeEditedWindowsUpdates) Documentation() (activity string, details string, detailsExample string) { + return `Generated when the Windows OS updates deadline or grace period is modified.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the Windows OS updates settings applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "team_name": The name of the team that the Windows OS updates settings applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "deadline_days": The number of days before updates are installed, ` + "`null`" + ` if the requirement was removed. +- "grace_period_days": The number of days after the deadline before the host is forced to restart, ` + "`null`" + ` if the requirement was removed.`, `{ + "team_id": 3, + "team_name": "Workstations", + "deadline_days": 5, + "grace_period_days": 2 +}` +} + type ActivityTypeReadHostDiskEncryptionKey struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` diff --git a/server/fleet/app.go b/server/fleet/app.go index 98580abad..0f12e8853 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -147,7 +147,9 @@ type MDM struct { // backend, should be done only after careful analysis. EnabledAndConfigured bool `json:"enabled_and_configured"` - MacOSUpdates MacOSUpdates `json:"macos_updates"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` + WindowsUpdates WindowsUpdates `json:"windows_updates"` + MacOSSettings MacOSSettings `json:"macos_settings"` MacOSSetup MacOSSetup `json:"macos_setup"` MacOSMigration MacOSMigration `json:"macos_migration"` @@ -231,6 +233,68 @@ func (m MacOSUpdates) Validate() error { return nil } +// WindowsUpdates is part of AppConfig and defines the Windows update settings. +type WindowsUpdates struct { + DeadlineDays optjson.Int `json:"deadline_days"` + GracePeriodDays optjson.Int `json:"grace_period_days"` +} + +// EnabledForHost returns a boolean indicating if enforced Windows OS updates +// are enabled for the host. Note that the provided Host needs to be loaded +// with full MDMInfo data for the check to be valid. +func (w WindowsUpdates) EnabledForHost(h *Host) bool { + return w.DeadlineDays.Valid && + w.GracePeriodDays.Valid && + h.IsOsqueryEnrolled() && + h.MDMInfo.IsFleetEnrolled() +} + +// Equal returns true if the values of the fields of w and other are equal. It +// returns false otherwise. If e.g. w.DeadlineDays.Value == 0 but its .Valid +// field == false (i.e. it is null), it is not equal to +// other.DeadlineDays.Value == 0 with its .Valid field == true. +func (w WindowsUpdates) Equal(other WindowsUpdates) bool { + if w.DeadlineDays.Value != other.DeadlineDays.Value || w.DeadlineDays.Valid != other.DeadlineDays.Valid { + return false + } + if w.GracePeriodDays.Value != other.GracePeriodDays.Value || w.GracePeriodDays.Valid != other.GracePeriodDays.Valid { + return false + } + return true +} + +func (w WindowsUpdates) Validate() error { + const ( + minDeadline = 0 + maxDeadline = 30 + minGracePeriod = 0 + maxGracePeriod = 7 + ) + + // both must be specified or not specified + if w.DeadlineDays.Valid != w.GracePeriodDays.Valid { + if w.DeadlineDays.Valid && !w.GracePeriodDays.Valid { + return errors.New("grace_period_days is required when deadline_days is provided") + } else if !w.DeadlineDays.Valid && w.GracePeriodDays.Valid { + return errors.New("deadline_days is required when grace_period_days is provided") + } + } + + // if both are unspecified, nothing more to validate, updates are not enforced. + if !w.DeadlineDays.Valid { + return nil + } + + // at this point, both fields are set + if w.DeadlineDays.Value < minDeadline || w.DeadlineDays.Value > maxDeadline { + return fmt.Errorf("deadline_days must be an integer between %d and %d", minDeadline, maxDeadline) + } + if w.GracePeriodDays.Value < minGracePeriod || w.GracePeriodDays.Value > maxGracePeriod { + return fmt.Errorf("grace_period_days must be an integer between %d and %d", minGracePeriod, maxGracePeriod) + } + return nil +} + // MacOSSettings contains settings specific to macOS. type MacOSSettings struct { // CustomSettings is a slice of configuration profile file paths. diff --git a/server/fleet/app_test.go b/server/fleet/app_test.go index 79ac1847f..bc03b3f09 100644 --- a/server/fleet/app_test.go +++ b/server/fleet/app_test.go @@ -117,6 +117,88 @@ func TestMacOSUpdatesValidate(t *testing.T) { }) } +func TestWindowsUpdatesValidate(t *testing.T) { + cases := []struct { + name string + w WindowsUpdates + wantErr string + }{ + {"empty", WindowsUpdates{}, ""}, + {"explicitly unset", WindowsUpdates{DeadlineDays: optjson.Int{Set: false}, GracePeriodDays: optjson.Int{Set: false}}, ""}, + {"explicitly null", WindowsUpdates{DeadlineDays: optjson.Int{Set: true, Valid: false}, GracePeriodDays: optjson.Int{Set: true, Valid: false}}, ""}, + {"explicitly set to 0", WindowsUpdates{DeadlineDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(0)}, ""}, + {"set to valid values", WindowsUpdates{DeadlineDays: optjson.SetInt(20), GracePeriodDays: optjson.SetInt(4)}, ""}, + {"deadline null grace set", WindowsUpdates{DeadlineDays: optjson.Int{Set: true, Valid: false}, GracePeriodDays: optjson.SetInt(2)}, "deadline_days is required when grace_period_days is provided"}, + {"grace null deadline set", WindowsUpdates{DeadlineDays: optjson.SetInt(10), GracePeriodDays: optjson.Int{Set: true, Valid: false}}, "grace_period_days is required when deadline_days is provided"}, + {"negative deadline", WindowsUpdates{DeadlineDays: optjson.SetInt(-1), GracePeriodDays: optjson.SetInt(2)}, "deadline_days must be an integer between 0 and 30"}, + {"negative grace", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(-2)}, "grace_period_days must be an integer between 0 and 7"}, + {"deadline out of range", WindowsUpdates{DeadlineDays: optjson.SetInt(1000), GracePeriodDays: optjson.SetInt(2)}, "deadline_days must be an integer between 0 and 30"}, + {"grace out of range", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1000)}, "grace_period_days must be an integer between 0 and 7"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.w.Validate() + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestWindowsUpdatesEqual(t *testing.T) { + cases := []struct { + name string + w1, w2 WindowsUpdates + want bool + }{ + {"both empty", WindowsUpdates{}, WindowsUpdates{}, true}, + {"both all set", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, true}, + {"both all null", WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, true}, + {"both all set to 0", WindowsUpdates{DeadlineDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(0)}, WindowsUpdates{DeadlineDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(0)}, true}, + {"different all set", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, WindowsUpdates{DeadlineDays: optjson.SetInt(3), GracePeriodDays: optjson.SetInt(4)}, false}, + {"different set deadline", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, WindowsUpdates{DeadlineDays: optjson.SetInt(3), GracePeriodDays: optjson.SetInt(2)}, false}, + {"different set grace", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(3)}, false}, + {"different null deadline", WindowsUpdates{DeadlineDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(2)}, WindowsUpdates{DeadlineDays: optjson.Int{Set: true, Valid: false}, GracePeriodDays: optjson.SetInt(2)}, false}, + {"different null grace", WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(0)}, WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.Int{Set: true, Valid: false}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.w1.Equal(tc.w2) + require.Equal(t, tc.want, got) + }) + } +} + +func TestWIndowsUpdatesEnabledForHost(t *testing.T) { + hostWithRequirements := &Host{ + OsqueryHostID: ptr.String("notempty"), + Platform: "windows", + MDMInfo: &HostMDM{ + IsServer: false, + Enrolled: true, + Name: WellKnownMDMFleet, + }, + } + cases := []struct { + w WindowsUpdates + host *Host + want bool + }{ + {WindowsUpdates{}, &Host{}, false}, + {WindowsUpdates{DeadlineDays: optjson.Int{Set: true, Valid: false}, GracePeriodDays: optjson.Int{Set: true, Valid: false}}, hostWithRequirements, false}, + {WindowsUpdates{DeadlineDays: optjson.Int{Set: true, Valid: true}, GracePeriodDays: optjson.Int{Set: true, Valid: false}}, hostWithRequirements, false}, + {WindowsUpdates{DeadlineDays: optjson.Int{Set: true, Valid: true}, GracePeriodDays: optjson.Int{Set: true, Valid: true}}, hostWithRequirements, true}, + {WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, &Host{}, false}, + {WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(2)}, hostWithRequirements, true}, + } + + for _, tc := range cases { + require.Equal(t, tc.want, tc.w.EnabledForHost(tc.host)) + } +} + func TestMacOSUpdatesEnabledForHost(t *testing.T) { hostWithRequirements := &Host{ OsqueryHostID: ptr.String("notempty"), diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index e8b378893..130eed4d4 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1077,6 +1077,10 @@ type Datastore interface { // the specified profile uuid. DeleteMDMWindowsConfigProfile(ctx context.Context, profileUUID string) error + // DeleteMDMWindowsConfigProfileByTeamAndName deletes the Windows MDM profile corresponding to + // the specified team ID (or no team if nil) and profile name. + DeleteMDMWindowsConfigProfileByTeamAndName(ctx context.Context, teamID *uint, profileName string) error + // GetHostMDMWindowsProfiles returns the MDM profile information for the specified Windows host UUID. GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]HostMDMWindowsProfile, error) @@ -1137,6 +1141,11 @@ type Datastore interface { // NewMDMWindowsConfigProfile creates and returns a new configuration profile. NewMDMWindowsConfigProfile(ctx context.Context, cp MDMWindowsConfigProfile) (*MDMWindowsConfigProfile, error) + // SetOrUpdateMDMWindowsConfigProfile creates or replaces a Windows profile. + // The profile gets replaced if it already exists for the same team and name + // combination. + SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp MDMWindowsConfigProfile) error + // BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or // no team in a single transaction. BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile) error diff --git a/server/fleet/service.go b/server/fleet/service.go index d6acb40d7..bfeaaaa40 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -32,6 +32,8 @@ type EnterpriseOverrides struct { DeleteMDMAppleSetupAssistant func(ctx context.Context, teamID *uint) error MDMAppleSyncDEPProfiles func(ctx context.Context) error DeleteMDMAppleBootstrapPackage func(ctx context.Context, teamID *uint) error + MDMWindowsEnableOSUpdates func(ctx context.Context, teamID *uint, updates WindowsUpdates) error + MDMWindowsDisableOSUpdates func(ctx context.Context, teamID *uint) error } type OsqueryService interface { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index a3a4546e0..e6e08d463 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -34,6 +34,7 @@ type TeamPayload struct { type TeamPayloadMDM struct { EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` MacOSUpdates *MacOSUpdates `json:"macos_updates"` + WindowsUpdates *WindowsUpdates `json:"windows_updates"` MacOSSettings *MacOSSettings `json:"macos_settings"` MacOSSetup *MacOSSetup `json:"macos_setup"` WindowsSettings *WindowsSettings `json:"windows_settings"` @@ -149,10 +150,11 @@ type TeamWebhookSettings struct { } type TeamMDM struct { - EnableDiskEncryption bool `json:"enable_disk_encryption"` - MacOSUpdates MacOSUpdates `json:"macos_updates"` - MacOSSettings MacOSSettings `json:"macos_settings"` - MacOSSetup MacOSSetup `json:"macos_setup"` + EnableDiskEncryption bool `json:"enable_disk_encryption"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` + WindowsUpdates WindowsUpdates `json:"windows_updates"` + MacOSSettings MacOSSettings `json:"macos_settings"` + MacOSSetup MacOSSetup `json:"macos_setup"` WindowsSettings WindowsSettings `json:"windows_settings"` // NOTE: TeamSpecMDM must be kept in sync with TeamMDM. @@ -199,7 +201,8 @@ func (t *TeamMDM) Copy() *TeamMDM { type TeamSpecMDM struct { EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` - MacOSUpdates MacOSUpdates `json:"macos_updates"` + MacOSUpdates MacOSUpdates `json:"macos_updates"` + WindowsUpdates WindowsUpdates `json:"windows_updates"` // A map is used for the macos settings so that we can easily detect if its // sub-keys were provided or not in an "apply" call. E.g. if the @@ -415,6 +418,7 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { var mdmSpec TeamSpecMDM mdmSpec.MacOSUpdates = t.Config.MDM.MacOSUpdates + mdmSpec.WindowsUpdates = t.Config.MDM.WindowsUpdates mdmSpec.MacOSSettings = t.Config.MDM.MacOSSettings.ToMap() delete(mdmSpec.MacOSSettings, "enable_disk_encryption") mdmSpec.MacOSSetup = t.Config.MDM.MacOSSetup diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index 68f12a002..08b719c7e 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -45,6 +45,10 @@ type MDMWindowsConfigProfile struct { // // Returns an error if these conditions are not met. func (m *MDMWindowsConfigProfile) ValidateUserProvided() error { + if _, ok := microsoft_mdm.FleetReservedProfileNames()[m.Name]; ok { + return fmt.Errorf("Profile name %q is not allowed.", m.Name) + } + if mdm.GetRawProfilePlatform(m.SyncML) != "windows" { // it doesn't start with , check if it is still valid XML. if len(bytes.TrimSpace(m.SyncML)) == 0 { diff --git a/server/fleet/windows_mdm_test.go b/server/fleet/windows_mdm_test.go index f4b5334b4..0f5704279 100644 --- a/server/fleet/windows_mdm_test.go +++ b/server/fleet/windows_mdm_test.go @@ -3,6 +3,7 @@ package fleet import ( "testing" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/stretchr/testify/require" ) @@ -140,6 +141,14 @@ func TestValidateUserProvided(t *testing.T) { }, wantErr: true, }, + { + name: "Valid XML with reserved name", + profile: MDMWindowsConfigProfile{ + Name: microsoft_mdm.FleetWindowsOSUpdatesProfileName, + SyncML: []byte(`Custom/URI`), + }, + wantErr: true, + }, } for _, tt := range tests { diff --git a/server/mdm/microsoft/microsoft_mdm.go b/server/mdm/microsoft/microsoft_mdm.go index 6bd63a4f2..9b41df3ce 100644 --- a/server/mdm/microsoft/microsoft_mdm.go +++ b/server/mdm/microsoft/microsoft_mdm.go @@ -398,8 +398,16 @@ const ( const ( FleetBitLockerTargetLocURI = "/Vendor/MSFT/BitLocker" FleetOSUpdateTargetLocURI = "/Vendor/MSFT/Policy/Config/Update" + + FleetWindowsOSUpdatesProfileName = "Windows OS Updates" ) +func FleetReservedProfileNames() map[string]struct{} { + return map[string]struct{}{ + FleetWindowsOSUpdatesProfileName: {}, + } +} + func ResolveWindowsMDMDiscovery(serverURL string) (string, error) { return commonmdm.ResolveURL(serverURL, MDE2DiscoveryPath, false) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 31e1f24a6..68eead187 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -704,6 +704,8 @@ type GetMDMWindowsConfigProfileFunc func(ctx context.Context, profileUUID string type DeleteMDMWindowsConfigProfileFunc func(ctx context.Context, profileUUID string) error +type DeleteMDMWindowsConfigProfileByTeamAndNameFunc func(ctx context.Context, teamID *uint, profileName string) error + type GetHostMDMWindowsProfilesFunc func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) type ListMDMConfigProfilesFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error) @@ -730,6 +732,8 @@ type BulkDeleteMDMWindowsHostsConfigProfilesFunc func(ctx context.Context, paylo type NewMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) (*fleet.MDMWindowsConfigProfile, error) +type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error + type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) @@ -1784,6 +1788,9 @@ type DataStore struct { DeleteMDMWindowsConfigProfileFunc DeleteMDMWindowsConfigProfileFunc DeleteMDMWindowsConfigProfileFuncInvoked bool + DeleteMDMWindowsConfigProfileByTeamAndNameFunc DeleteMDMWindowsConfigProfileByTeamAndNameFunc + DeleteMDMWindowsConfigProfileByTeamAndNameFuncInvoked bool + GetHostMDMWindowsProfilesFunc GetHostMDMWindowsProfilesFunc GetHostMDMWindowsProfilesFuncInvoked bool @@ -1823,6 +1830,9 @@ type DataStore struct { NewMDMWindowsConfigProfileFunc NewMDMWindowsConfigProfileFunc NewMDMWindowsConfigProfileFuncInvoked bool + SetOrUpdateMDMWindowsConfigProfileFunc SetOrUpdateMDMWindowsConfigProfileFunc + SetOrUpdateMDMWindowsConfigProfileFuncInvoked bool + BatchSetMDMProfilesFunc BatchSetMDMProfilesFunc BatchSetMDMProfilesFuncInvoked bool @@ -4263,6 +4273,13 @@ func (s *DataStore) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU return s.DeleteMDMWindowsConfigProfileFunc(ctx, profileUUID) } +func (s *DataStore) DeleteMDMWindowsConfigProfileByTeamAndName(ctx context.Context, teamID *uint, profileName string) error { + s.mu.Lock() + s.DeleteMDMWindowsConfigProfileByTeamAndNameFuncInvoked = true + s.mu.Unlock() + return s.DeleteMDMWindowsConfigProfileByTeamAndNameFunc(ctx, teamID, profileName) +} + func (s *DataStore) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { s.mu.Lock() s.GetHostMDMWindowsProfilesFuncInvoked = true @@ -4354,6 +4371,13 @@ func (s *DataStore) NewMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDM return s.NewMDMWindowsConfigProfileFunc(ctx, cp) } +func (s *DataStore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error { + s.mu.Lock() + s.SetOrUpdateMDMWindowsConfigProfileFuncInvoked = true + s.mu.Unlock() + return s.SetOrUpdateMDMWindowsConfigProfileFunc(ctx, cp) +} + func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error { s.mu.Lock() s.BatchSetMDMProfilesFuncInvoked = true diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 00fc08060..fb170ddae 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -553,6 +553,37 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } + // if the Windows updates requirements changed, create the corresponding + // activity. + if !oldAppConfig.MDM.WindowsUpdates.Equal(appConfig.MDM.WindowsUpdates) { + var deadline, grace *int + if appConfig.MDM.WindowsUpdates.DeadlineDays.Valid { + deadline = &appConfig.MDM.WindowsUpdates.DeadlineDays.Value + } + if appConfig.MDM.WindowsUpdates.GracePeriodDays.Valid { + grace = &appConfig.MDM.WindowsUpdates.GracePeriodDays.Value + } + + if deadline != nil { + if err := svc.EnterpriseOverrides.MDMWindowsEnableOSUpdates(ctx, nil, appConfig.MDM.WindowsUpdates); err != nil { + return nil, ctxerr.Wrap(ctx, err, "enable no-team windows OS updates") + } + } else if err := svc.EnterpriseOverrides.MDMWindowsDisableOSUpdates(ctx, nil); err != nil { + return nil, ctxerr.Wrap(ctx, err, "disable no-team windows OS updates") + } + + if err := svc.ds.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedWindowsUpdates{ + DeadlineDays: deadline, + GracePeriodDays: grace, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos min version modification") + } + } + if appConfig.MDM.EnableDiskEncryption.Valid && oldAppConfig.MDM.EnableDiskEncryption.Value != appConfig.MDM.EnableDiskEncryption.Value { if oldAppConfig.MDM.EnabledAndConfigured { var act fleet.ActivityDetails @@ -691,6 +722,20 @@ func (svc *Service) validateMDM( invalid.Append("macos_updates", err.Error()) } + // WindowsUpdates + updatingWindowsUpdates := !mdm.WindowsUpdates.Equal(oldMdm.WindowsUpdates) + if updatingWindowsUpdates { + // TODO: Should we validate MDM configured on here too? + + if !license.IsPremium() { + invalid.Append("windows_updates.deadline_days", ErrMissingLicense.Error()) + return + } + } + if err := mdm.WindowsUpdates.Validate(); err != nil { + invalid.Append("windows_updates", err.Error()) + } + // EndUserAuthentication // only validate SSO settings if they changed if mdm.EndUserAuthentication.SSOProviderSettings != oldMdm.EndUserAuthentication.SSOProviderSettings { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 2df35922b..47fb74d6e 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -812,6 +812,7 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, @@ -840,6 +841,7 @@ func TestMDMAppleConfig(t *testing.T) { AppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, @@ -853,6 +855,7 @@ func TestMDMAppleConfig(t *testing.T) { AppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, @@ -872,6 +875,7 @@ func TestMDMAppleConfig(t *testing.T) { EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, @@ -894,6 +898,7 @@ func TestMDMAppleConfig(t *testing.T) { }}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index a74e23136..6a8225c2e 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5270,6 +5270,13 @@ func (s *integrationTestSuite) TestAppConfig() { "mdm": { "apple_bm_default_team": "xyz" } }`), http.StatusUnprocessableEntity, &acResp) + // try to set the windows updates, which is premium only + res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 0} } + }`), http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + assert.Contains(t, errMsg, "missing or invalid license") + // try to enable Windows MDM, impossible without the feature flag // (only set in mdm integrations tests) res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 71cb37585..58426766d 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -3,8 +3,10 @@ package service import ( "bytes" "context" + "database/sql" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -22,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/live_query/live_query_mock" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/go-kit/log" @@ -130,6 +133,10 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.Int{Set: true}, + GracePeriodDays: optjson.Int{Set: true}, + }, MacOSSetup: fleet.MacOSSetup{ // because the MacOSSetup was marshalled to JSON to be saved in the DB, // it did get marshalled, and then when unmarshalled it was set (but @@ -148,6 +155,105 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { // an activity was created for team spec applied s.lastActivityMatches(fleet.ActivityTypeAppliedSpecTeam{}.ActivityName(), fmt.Sprintf(`{"teams": [{"id": %d, "name": %q}]}`, team.ID, team.Name), 0) + // dry-run with invalid windows updates + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": -1, + "grace_period_days": 1, + }, + }, + }, + }, + } + res := s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true") + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "deadline_days must be an integer between 0 and 30") + + // apply valid windows updates settings + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": 1, + "grace_period_days": 1, + }, + }, + }, + }, + } + s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp) + require.Len(t, applyResp.TeamIDsByName, 1) + team, err = s.ds.TeamByName(context.Background(), teamName) + require.NoError(t, err) + require.Equal(t, applyResp.TeamIDsByName[teamName], team.ID) + require.Equal(t, fleet.TeamMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("10.15.0"), + Deadline: optjson.SetString("2021-01-01"), + }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(1), + GracePeriodDays: optjson.SetInt(1), + }, + MacOSSetup: fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + }, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, + }, + }, team.Config.MDM) + + // get the team via the GET endpoint, check that it properly returns the mdm settings + var getTmResp getTeamResponse + s.DoJSON("GET", "/api/latest/fleet/teams/"+fmt.Sprint(team.ID), nil, http.StatusOK, &getTmResp) + require.Equal(t, fleet.TeamMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("10.15.0"), + Deadline: optjson.SetString("2021-01-01"), + }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(1), + GracePeriodDays: optjson.SetInt(1), + }, + MacOSSetup: fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + }, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, + }, + }, getTmResp.Team.Config.MDM) + + // get the team via the list teams endpoint, check that it properly returns the mdm settings + var listTmResp listTeamsResponse + s.DoJSON("GET", "/api/latest/fleet/teams", nil, http.StatusOK, &listTmResp, "query", teamName) + require.True(t, len(listTmResp.Teams) > 0) + require.Equal(t, team.ID, listTmResp.Teams[0].ID) + require.Equal(t, fleet.TeamMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("10.15.0"), + Deadline: optjson.SetString("2021-01-01"), + }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(1), + GracePeriodDays: optjson.SetInt(1), + }, + MacOSSetup: fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + }, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, + }, + }, listTmResp.Teams[0].Config.MDM) + // dry-run with invalid agent options agentOpts = json.RawMessage(`{"config": {"nope": 1}}`) teamSpecs = map[string]any{ @@ -161,7 +267,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest, "dry_run", "true") // dry-run with empty body - res := s.DoRaw("POST", "/api/latest/fleet/spec/teams", nil, http.StatusBadRequest, "force", "true") + res = s.DoRaw("POST", "/api/latest/fleet/spec/teams", nil, http.StatusBadRequest, "force", "true") errBody, err := io.ReadAll(res.Body) require.NoError(t, err) require.Contains(t, string(errBody), `"Expected JSON Body"`) @@ -193,7 +299,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { }, } res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true") - errMsg := extractServerErrorText(res.Body) + errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn't update macos_settings because MDM features aren't turned on in Fleet.") // dry-run with macos disk encryption set to false, no error @@ -1694,7 +1800,191 @@ func (s *integrationEnterpriseTestSuite) TestExternalIntegrationsTeamConfig() { require.Len(t, appCfgResp.Integrations.Zendesk, 0) } -func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesConfig() { +func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { + t := s.T() + + // Create a team + team := &fleet.Team{ + Name: t.Name(), + Description: "Team description", + Secrets: []*fleet.EnrollSecret{{Secret: "XYZ"}}, + } + var tmResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &tmResp) + require.Equal(t, team.Name, tmResp.Team.Name) + team.ID = tmResp.Team.ID + + checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, nil) + + // modify the team's config + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(2), + }, + }, + }, http.StatusOK, &tmResp) + require.Equal(t, 5, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) + s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "deadline_days": 5, "grace_period_days": 2}`, team.ID, team.Name), 0) + + checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(2), + }) + + // get the team via the GET endpoint, check that it properly returns the mdm + // settings. + var getTmResp getTeamResponse + s.DoJSON("GET", "/api/latest/fleet/teams/"+fmt.Sprint(team.ID), nil, http.StatusOK, &getTmResp) + require.Equal(t, fleet.TeamMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.String{Set: true}, + Deadline: optjson.String{Set: true}, + }, + WindowsUpdates: fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(2), + }, + MacOSSetup: fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + }, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, + }, + }, getTmResp.Team.Config.MDM) + + // only update the deadline + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(6), + GracePeriodDays: optjson.SetInt(2), + }, + }, + }, http.StatusOK, &tmResp) + require.Equal(t, 6, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) + lastActivity := s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "deadline_days": 6, "grace_period_days": 2}`, team.ID, team.Name), 0) + + checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(6), + GracePeriodDays: optjson.SetInt(2), + }) + + // setting the macos updates doesn't alter the windows updates + tmResp = teamResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "macos_updates": &fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("10.15.0"), + Deadline: optjson.SetString("2021-01-01"), + }, + }, + }, http.StatusOK, &tmResp) + require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, 6, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) + // did not create a new activity for windows updates + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), "", lastActivity) + lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), ``, 0) + + checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(6), + GracePeriodDays: optjson.SetInt(2), + }) + + // sending a nil MDM or WindowsUpdates config doesn't modify anything + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": nil, + }, http.StatusOK, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": nil, + }, + }, http.StatusOK, &tmResp) + require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, 6, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) + // no new activity is created + s.lastActivityMatches("", "", lastActivity) + + checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(6), + GracePeriodDays: optjson.SetInt(2), + }) + + // sending empty WindowsUpdates fields empties both fields + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": nil, + "grace_period_days": nil, + }, + }, + }, http.StatusOK, &tmResp) + require.False(t, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Valid) + require.False(t, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Valid) + s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "deadline_days": null, "grace_period_days": null}`, team.ID, team.Name), 0) + + checkWindowsOSUpdatesProfile(t, s.ds, &team.ID, nil) + + // error checks: + + // try to set an invalid deadline + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": 1000, + "grace_period_days": 1, + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + + // try to set an invalid grace period + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": 1, + "grace_period_days": 1000, + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + + // try to set a deadline but not a grace period + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": 1, + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + + // try to set a grace period but no deadline + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "grace_period_days": 1, + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + + // try to set an empty grace period but a non-empty deadline + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": map[string]any{ + "deadline_days": 1, + "grace_period_days": nil, + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) +} + +func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { t := s.T() // Create a team @@ -1734,6 +2024,24 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesConfig() { require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) lastActivity := s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2025-10-01"}`, team.ID, team.Name), 0) + // setting the windows updates doesn't alter the macos updates + tmResp = teamResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "windows_updates": &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(10), + GracePeriodDays: optjson.SetInt(2), + }, + }, + }, http.StatusOK, &tmResp) + require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, 10, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) + // did not create a new activity for macos updates + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), "", lastActivity) + lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), ``, 0) + // sending a nil MDM or MacOSUpdate config doesn't modify anything s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ "mdm": nil, @@ -2040,6 +2348,165 @@ func (s *integrationEnterpriseTestSuite) TestDefaultAppleBMTeam() { require.Equal(t, tm.Name, acResp.MDM.AppleBMDefaultTeam) } +func (s *integrationEnterpriseTestSuite) TestMDMWindowsUpdates() { + t := s.T() + + // keep the last activity, to detect newly created ones + var activitiesResp listActivitiesResponse + s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp, "order_key", "a.id", "order_direction", "desc") + var lastActivity uint + if len(activitiesResp.Activities) > 0 { + lastActivity = activitiesResp.Activities[0].ID + } + + checkInvalidConfig := func(config string) { + // try to set an invalid config + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(config), http.StatusUnprocessableEntity, &acResp) + + // get the appconfig, nothing changed + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.Equal(t, fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, acResp.MDM.WindowsUpdates) + + // no activity got created + activitiesResp = listActivitiesResponse{} + s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activitiesResp, "order_key", "a.id", "order_direction", "desc") + require.Condition(t, func() bool { + return (lastActivity == 0 && len(activitiesResp.Activities) == 0) || + (len(activitiesResp.Activities) > 0 && activitiesResp.Activities[0].ID == lastActivity) + }) + } + + // missing grace period + checkInvalidConfig(`{"mdm": { + "windows_updates": { + "deadline_days": 1 + } + }}`) + + // missing deadline + checkInvalidConfig(`{"mdm": { + "windows_updates": { + "grace_period_days": 1 + } + }}`) + + // invalid deadline + checkInvalidConfig(`{"mdm": { + "windows_updates": { + "grace_period_days": 1, + "deadline_days": -1 + } + }}`) + + // invalid grace period + checkInvalidConfig(`{"mdm": { + "windows_updates": { + "grace_period_days": -1, + "deadline_days": 1 + } + }}`) + + checkWindowsOSUpdatesProfile(t, s.ds, nil, nil) + + // valid config + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "windows_updates": { + "deadline_days": 5, + "grace_period_days": 1 + } + } + }`), http.StatusOK, &acResp) + require.Equal(t, 5, acResp.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value) + + checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(5), + GracePeriodDays: optjson.SetInt(1), + }) + + // edited windows updates activity got created + s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), `{"deadline_days":5, "grace_period_days":1, "team_id": null, "team_name": null}`, 0) + + // get the appconfig + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.Equal(t, 5, acResp.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value) + + // update the deadline + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "windows_updates": { + "deadline_days": 6, + "grace_period_days": 1 + } + } + }`), http.StatusOK, &acResp) + require.Equal(t, 6, acResp.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value) + + checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(6), + GracePeriodDays: optjson.SetInt(1), + }) + + // another edited windows updates activity got created + lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), `{"deadline_days":6, "grace_period_days":1, "team_id": null, "team_name": null}`, 0) + + // update something unrelated - the transparency url + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"fleet_desktop":{"transparency_url": "customURL"}}`), http.StatusOK, &acResp) + require.Equal(t, 6, acResp.MDM.WindowsUpdates.DeadlineDays.Value) + require.Equal(t, 1, acResp.MDM.WindowsUpdates.GracePeriodDays.Value) + + // no activity got created + s.lastActivityMatches("", ``, lastActivity) + + checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{ + DeadlineDays: optjson.SetInt(6), + GracePeriodDays: optjson.SetInt(1), + }) + + // clear the Windows requirement + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + } + } + }`), http.StatusOK, &acResp) + require.False(t, acResp.MDM.WindowsUpdates.DeadlineDays.Valid) + require.False(t, acResp.MDM.WindowsUpdates.GracePeriodDays.Valid) + + // edited windows updates activity got created with empty requirement + lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), `{"deadline_days":null, "grace_period_days":null, "team_id": null, "team_name": null}`, 0) + + checkWindowsOSUpdatesProfile(t, s.ds, nil, nil) + + // update again with empty windows requirement + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "windows_updates": { + "deadline_days": null, + "grace_period_days": null + } + } + }`), http.StatusOK, &acResp) + require.False(t, acResp.MDM.WindowsUpdates.DeadlineDays.Valid) + require.False(t, acResp.MDM.WindowsUpdates.GracePeriodDays.Valid) + + // no activity got created + s.lastActivityMatches("", ``, lastActivity) +} + func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { t := s.T() @@ -5193,3 +5660,33 @@ func (s *integrationEnterpriseTestSuite) TestTeamConfigDetailQueriesOverrides() require.Contains(t, dqResp.Queries, "fleet_detail_query_software_linux") require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_distributed_query_%s", t.Name())) } + +// checks that the specified team/no-team has the Windows OS Updates profile with +// the specified deadline/grace settings (or checks that it doesn't have the +// profile if wantSettings is nil). It returns the profile_uuid if it exists, +// empty string otherwise. +func checkWindowsOSUpdatesProfile(t *testing.T, ds *mysql.Datastore, teamID *uint, wantSettings *fleet.WindowsUpdates) string { + ctx := context.Background() + + var prof fleet.MDMWindowsConfigProfile + mysql.ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + err := sqlx.GetContext(ctx, tx, &prof, `SELECT profile_uuid, syncml FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, microsoft_mdm.FleetWindowsOSUpdatesProfileName) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + }) + if wantSettings == nil { + require.Empty(t, prof.ProfileUUID) + } else { + require.NotEmpty(t, prof.ProfileUUID) + require.Contains(t, string(prof.SyncML), fmt.Sprintf(`%d`, wantSettings.DeadlineDays.Value)) + require.Contains(t, string(prof.SyncML), fmt.Sprintf(`%d`, wantSettings.GracePeriodDays.Value)) + } + + return prof.ProfileUUID +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index b0aefd236..1f59b6fba 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -8043,7 +8043,7 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { return id } - assertWindowsProfile := func(filename, name, locURI string, teamID uint, wantStatus int, wantErrMsg string) string { + assertWindowsProfile := func(filename, locURI string, teamID uint, wantStatus int, wantErrMsg string) string { var tmPtr *uint if teamID > 0 { tmPtr = &teamID @@ -8065,7 +8065,7 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { return resp.ProfileID } createWindowsProfile := func(name string, teamID uint) string { - id := assertWindowsProfile(name+".xml", name, "./Test", teamID, http.StatusOK, "") + id := assertWindowsProfile(name+".xml", "./Test", teamID, http.StatusOK, "") var wantJSON string if teamID == 0 { @@ -8086,29 +8086,31 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { teamWinProfID := createWindowsProfile("win-team-profile", testTeam.ID) // Windows profile name conflicts with Apple's for no team - assertWindowsProfile("apple-global-profile.xml", "apple-global-profile", "./Test", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") + assertWindowsProfile("apple-global-profile.xml", "./Test", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") // but no conflict for team 1 - assertWindowsProfile("apple-global-profile.xml", "apple-global-profile", "./Test", testTeam.ID, http.StatusOK, "") + assertWindowsProfile("apple-global-profile.xml", "./Test", testTeam.ID, http.StatusOK, "") // Apple profile name conflicts with Windows' for no team assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") // but no conflict for team 1 assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, http.StatusOK, "") // Windows profile name conflicts with Apple's for team 1 - assertWindowsProfile("apple-team-profile.xml", "apple-team-profile", "./Test", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") + assertWindowsProfile("apple-team-profile.xml", "./Test", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") // but no conflict for no-team - assertWindowsProfile("apple-team-profile.xml", "apple-team-profile", "./Test", 0, http.StatusOK, "") + assertWindowsProfile("apple-team-profile.xml", "./Test", 0, http.StatusOK, "") // Apple profile name conflicts with Windows' for team 1 assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") // but no conflict for no-team assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, http.StatusOK, "") // not an xml nor mobileconfig file - assertWindowsProfile("foo.txt", "foo", "./Test", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") + assertWindowsProfile("foo.txt", "./Test", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") assertAppleProfile("foo.txt", "foo", "foo-ident", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") // Windows-reserved LocURI - assertWindowsProfile("bitlocker.xml", "bitlocker", microsoft_mdm.FleetBitLockerTargetLocURI, 0, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.") - assertWindowsProfile("updates.xml", "updates", microsoft_mdm.FleetOSUpdateTargetLocURI, testTeam.ID, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include Windows updates settings.") + assertWindowsProfile("bitlocker.xml", microsoft_mdm.FleetBitLockerTargetLocURI, 0, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.") + assertWindowsProfile("updates.xml", microsoft_mdm.FleetOSUpdateTargetLocURI, testTeam.ID, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include Windows updates settings.") + // Windows-reserved profile name + assertWindowsProfile(microsoft_mdm.FleetWindowsOSUpdatesProfileName+".xml", "./Test", 0, http.StatusBadRequest, `Couldn't upload. Profile name "Windows OS Updates" is not allowed.`) // Windows invalid content body, headers := generateNewProfileMultipartRequest(t, nil, "win.xml", []byte("\x00\x01\x02"), s.token) @@ -8218,6 +8220,16 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { // try to delete the profile s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", profile.ProfileID), nil, http.StatusBadRequest, &deleteResp) + + // make fleet add a Windows OS Updates profile + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} } + }`), http.StatusOK, &acResp) + profUUID := checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1)}) + + // try to delete the profile + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp) } func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { diff --git a/server/service/mdm.go b/server/service/mdm.go index 7585d1dda..4c5b5ea9d 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/go-kit/kit/log/level" "github.com/go-sql-driver/mysql" @@ -1116,6 +1117,12 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU return ctxerr.Wrap(ctx, err) } + // prevent deleting Windows OS Updates profile (controlled by the OS Updates settings) + if _, ok := microsoft_mdm.FleetReservedProfileNames()[prof.Name]; ok { + err := &fleet.BadRequestError{Message: "Profiles managed by Fleet can't be deleted using this endpoint."} + return ctxerr.Wrap(ctx, err, "validate profile") + } + if err := svc.ds.DeleteMDMWindowsConfigProfile(ctx, profileUUID); err != nil { return ctxerr.Wrap(ctx, err) }