Implement Windows OS Updates (feature branch). (#15359)

This commit is contained in:
Martin Angers 2023-11-29 11:07:24 -05:00 committed by GitHub
parent 0b5eedb801
commit 2f927df4f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 3058 additions and 483 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -0,0 +1 @@
- add ability to change and view windows os updates in Fleet UI

View File

@ -0,0 +1 @@
* Added support to configure Windows OS updates requirements for hosts enrolled in Fleet MDM.

View File

@ -0,0 +1 @@
* Added deployment of Windows OS updates settings to the targeted hosts so that they take effect.

View File

@ -0,0 +1 @@
- add window os updates activites to Fleet UI.

View File

@ -213,6 +213,32 @@ spec:
assert.True(t, ds.ApplyEnrollSecretsFuncInvoked) assert.True(t, ds.ApplyEnrollSecretsFuncInvoked)
ds.ApplyEnrollSecretsFuncInvoked = false 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") mobileCfgPath := writeTmpMobileconfig(t, "N1")
filename = writeTmpYml(t, fmt.Sprintf(` filename = writeTmpYml(t, fmt.Sprintf(`
apiVersion: v1 apiVersion: v1
@ -231,6 +257,10 @@ spec:
MinimumVersion: optjson.SetString("12.3.1"), MinimumVersion: optjson.SetString("12.3.1"),
Deadline: optjson.SetString("2011-03-01"), Deadline: optjson.SetString("2011-03-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(5),
GracePeriodDays: optjson.SetInt(1),
},
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileCfgPath}, CustomSettings: []string{mobileCfgPath},
}, },
@ -268,6 +298,10 @@ spec:
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
Deadline: optjson.SetString("1992-03-01"), 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 MacOSSettings: fleet.MacOSSettings{ // macos settings not provided, so not cleared
CustomSettings: []string{mobileCfgPath}, CustomSettings: []string{mobileCfgPath},
}, },
@ -325,6 +359,9 @@ spec:
macos_updates: macos_updates:
minimum_version: minimum_version:
deadline: deadline:
windows_updates:
deadline_days:
grace_period_days:
macos_settings: macos_settings:
custom_settings: custom_settings:
`) `)
@ -334,6 +371,10 @@ spec:
MinimumVersion: optjson.String{Set: true}, MinimumVersion: optjson.String{Set: true},
Deadline: 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{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{}, CustomSettings: []string{},
}, },
@ -385,6 +426,12 @@ func TestApplyAppConfig(t *testing.T) {
ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
return &fleet.Team{ID: 123}, nil 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"}}`) defaultAgentOpts := json.RawMessage(`{"config":{"foo":"bar"}}`)
savedAppConfig := &fleet.AppConfig{ savedAppConfig := &fleet.AppConfig{
@ -413,6 +460,9 @@ spec:
macos_updates: macos_updates:
minimum_version: 12.1.1 minimum_version: 12.1.1
deadline: 2011-02-01 deadline: 2011-02-01
windows_updates:
deadline_days: 5
grace_period_days: 1
`) `)
newMDMSettings := fleet.MDM{ newMDMSettings := fleet.MDM{
@ -422,6 +472,10 @@ spec:
MinimumVersion: optjson.SetString("12.1.1"), MinimumVersion: optjson.SetString("12.1.1"),
Deadline: optjson.SetString("2011-02-01"), 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})) assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name}))
require.NotNil(t, savedAppConfig) require.NotNil(t, savedAppConfig)
@ -441,6 +495,7 @@ spec:
agent_options: agent_options:
mdm: mdm:
macos_updates: macos_updates:
windows_updates:
`) `)
assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name})) assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name}))
@ -449,6 +504,33 @@ spec:
assert.True(t, savedAppConfig.Features.EnableSoftwareInventory) assert.True(t, savedAppConfig.Features.EnableSoftwareInventory)
// agent options were cleared, provided but empty // agent options were cleared, provided but empty
assert.Nil(t, savedAppConfig.AgentOptions) 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) 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 { ds.InsertMDMAppleBootstrapPackageFunc = func(ctx context.Context, bp *fleet.MDMAppleBootstrapPackage) error {
return nil 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. // Apply global config.
name := writeTmpYml(t, `--- name := writeTmpYml(t, `---
@ -977,6 +1065,9 @@ spec:
macos_updates: macos_updates:
minimum_version: 10.10.10 minimum_version: 10.10.10
deadline: 2020-02-02 deadline: 2020-02-02
windows_updates:
deadline_days: 1
grace_period_days: 0
macos_settings: macos_settings:
custom_settings: custom_settings:
- %s - %s
@ -1001,6 +1092,10 @@ spec:
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
Deadline: optjson.SetString("2020-02-02"), Deadline: optjson.SetString("2020-02-02"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(1),
GracePeriodDays: optjson.SetInt(0),
},
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []string{mobileConfigPath},
}, },
@ -1039,6 +1134,10 @@ spec:
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
Deadline: optjson.SetString("2020-02-02"), Deadline: optjson.SetString("2020-02-02"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(1),
GracePeriodDays: optjson.SetInt(0),
},
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []string{mobileConfigPath},
}, },
@ -1061,6 +1160,9 @@ spec:
macos_updates: macos_updates:
minimum_version: 10.10.10 minimum_version: 10.10.10
deadline: 1992-03-01 deadline: 1992-03-01
windows_updates:
deadline_days: 0
grace_period_days: 1
macos_settings: macos_settings:
custom_settings: custom_settings:
- %s - %s
@ -1083,6 +1185,10 @@ spec:
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
Deadline: optjson.SetString("1992-03-01"), Deadline: optjson.SetString("1992-03-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(0),
GracePeriodDays: optjson.SetInt(1),
},
}, savedTeam.Config.MDM) }, savedTeam.Config.MDM)
assert.Equal(t, []*fleet.EnrollSecret{{Secret: "BBB"}}, teamEnrollSecrets) assert.Equal(t, []*fleet.EnrollSecret{{Secret: "BBB"}}, teamEnrollSecrets)
assert.True(t, ds.ApplyEnrollSecretsFuncInvoked) assert.True(t, ds.ApplyEnrollSecretsFuncInvoked)
@ -1118,6 +1224,10 @@ spec:
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
Deadline: optjson.SetString("1992-03-01"), Deadline: optjson.SetString("1992-03-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(0),
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{ MacOSSetup: fleet.MacOSSetup{
MacOSSetupAssistant: optjson.SetString(emptySetupAsst), MacOSSetupAssistant: optjson.SetString(emptySetupAsst),
}, },
@ -1150,6 +1260,10 @@ spec:
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
Deadline: optjson.SetString("1992-03-01"), Deadline: optjson.SetString("1992-03-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(0),
GracePeriodDays: optjson.SetInt(1),
},
MacOSSetup: fleet.MacOSSetup{ MacOSSetup: fleet.MacOSSetup{
MacOSSetupAssistant: optjson.SetString(emptySetupAsst), MacOSSetupAssistant: optjson.SetString(emptySetupAsst),
BootstrapPackage: optjson.SetString(bootstrapURL), BootstrapPackage: optjson.SetString(bootstrapURL),
@ -2196,6 +2310,12 @@ func TestApplySpecs(t *testing.T) {
ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error {
return nil 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 { cases := []struct {
@ -2665,6 +2785,124 @@ spec:
`, `,
wantErr: `422 Validation Failed: deadline accepts YYYY-MM-DD format only (E.g., "2023-06-01.")`, 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", desc: "missing required sso entity_id",
spec: ` spec: `
@ -2868,6 +3106,108 @@ spec:
`, `,
wantErr: `422 Validation Failed: deadline accepts YYYY-MM-DD format only (E.g., "2023-06-01.")`, 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", desc: "app config macos_settings.enable_disk_encryption without a value",
spec: ` spec: `

View File

@ -161,6 +161,10 @@ func TestGetTeams(t *testing.T) {
MinimumVersion: optjson.SetString("12.3.1"), MinimumVersion: optjson.SetString("12.3.1"),
Deadline: optjson.SetString("2021-12-14"), 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"}, VulnerabilitySettings: fleet.VulnerabilitySettings{DatabasesPath: "/some/path"},
SMTPSettings: &fleet.SMTPSettings{}, SMTPSettings: &fleet.SMTPSettings{},
SSOSettings: &fleet.SSOSettings{}, SSOSettings: &fleet.SSOSettings{},
MDM: fleet.MDM{
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(7),
GracePeriodDays: optjson.SetInt(3),
},
},
}, nil }, nil
} }
@ -1989,6 +1999,10 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
MinimumVersion: optjson.SetString("12.3.1"), MinimumVersion: optjson.SetString("12.3.1"),
Deadline: optjson.SetString("2021-12-14"), Deadline: optjson.SetString("2021-12-14"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(7),
GracePeriodDays: optjson.SetInt(3),
},
}, },
}, },
} }

View File

@ -91,6 +91,10 @@
"minimum_version": null, "minimum_version": null,
"deadline": null "deadline": null
}, },
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_migration": { "macos_migration": {
"enable": false, "enable": false,
"mode": "", "mode": "",

View File

@ -27,6 +27,9 @@ spec:
macos_updates: macos_updates:
minimum_version: null minimum_version: null
deadline: null deadline: null
windows_updates:
deadline_days: 7
grace_period_days: 3
macos_settings: macos_settings:
custom_settings: custom_settings:
macos_setup: macos_setup:

View File

@ -49,6 +49,10 @@
"minimum_version": null, "minimum_version": null,
"deadline": null "deadline": null
}, },
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_migration": { "macos_migration": {
"enable": false, "enable": false,
"mode": "", "mode": "",

View File

@ -27,6 +27,9 @@ spec:
macos_updates: macos_updates:
minimum_version: null minimum_version: null
deadline: null deadline: null
windows_updates:
deadline_days: 7
grace_period_days: 3
macos_settings: macos_settings:
custom_settings: custom_settings:
macos_setup: macos_setup:

View File

@ -29,6 +29,10 @@
"minimum_version": null, "minimum_version": null,
"deadline": null "deadline": null
}, },
"windows_updates": {
"deadline_days": null,
"grace_period_days": null
},
"macos_settings": { "macos_settings": {
"custom_settings": null "custom_settings": null
}, },
@ -93,6 +97,10 @@
"minimum_version": "12.3.1", "minimum_version": "12.3.1",
"deadline": "2021-12-14" "deadline": "2021-12-14"
}, },
"windows_updates": {
"deadline_days": 7,
"grace_period_days": 3
},
"macos_settings": { "macos_settings": {
"custom_settings": null "custom_settings": null
}, },

View File

@ -11,6 +11,9 @@ spec:
macos_updates: macos_updates:
minimum_version: null minimum_version: null
deadline: null deadline: null
windows_updates:
deadline_days: null
grace_period_days: null
macos_settings: macos_settings:
custom_settings: custom_settings:
windows_settings: windows_settings:
@ -43,6 +46,9 @@ spec:
macos_updates: macos_updates:
minimum_version: "12.3.1" minimum_version: "12.3.1"
deadline: "2021-12-14" deadline: "2021-12-14"
windows_updates:
deadline_days: 7
grace_period_days: 3
macos_settings: macos_settings:
custom_settings: custom_settings:
windows_settings: windows_settings:

View File

@ -35,6 +35,9 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
end_user_authentication: end_user_authentication:
idp_name: "" idp_name: ""
issuer_uri: "" issuer_uri: ""

View File

@ -35,6 +35,9 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
end_user_authentication: end_user_authentication:
idp_name: "" idp_name: ""
issuer_uri: "" issuer_uri: ""

View File

@ -19,6 +19,9 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
scripts: null scripts: null
name: tm1 name: tm1
--- ---
@ -41,5 +44,8 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
scripts: null scripts: null
name: tm2 name: tm2

View File

@ -19,6 +19,9 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
scripts: null scripts: null
name: tm1 name: tm1
--- ---
@ -41,5 +44,8 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
scripts: null scripts: null
name: tm2 name: tm2

View File

@ -17,6 +17,9 @@ spec:
macos_updates: macos_updates:
deadline: null deadline: null
minimum_version: null minimum_version: null
windows_updates:
deadline_days: null
grace_period_days: null
windows_settings: windows_settings:
custom_settings: null custom_settings: null
scripts: null scripts: null

View File

@ -23,6 +23,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "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/sso"
"github.com/fleetdm/fleet/v4/server/worker" "github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/kit/log" kitlog "github.com/go-kit/kit/log"
@ -1020,3 +1021,30 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin
}, },
}, nil }, 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")
}

View File

@ -91,8 +91,82 @@ var fileVaultProfileTemplate = template.Must(template.New("").Option("missingkey
</dict> </dict>
</plist>`)) </plist>`))
// TODO(mna): we have a potential issue here with profile names - we need to type windowsOSUpdatesProfileOptions struct {
// make sure they are unique for a given team, but there is no validation of Deadline int
// Fleet-reserved profile names, only of identifiers. A user could create a GracePeriod int
// "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.
var windowsOSUpdatesProfileTemplate = template.Must(template.New("").Option("missingkey=error").Parse(`
<Replace>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineForFeatureUpdates</LocURI>
</Target>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>{{ .Deadline }}</Data>
</Item>
</Replace>
<Replace>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineForQualityUpdates</LocURI>
</Target>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>{{ .Deadline }}</Data>
</Item>
</Replace>
<Replace>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineGracePeriod</LocURI>
</Target>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>{{ .GracePeriod }}</Data>
</Item>
</Replace>
<Replace>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/Update/AllowAutoUpdate</LocURI>
</Target>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>1</Data>
</Item>
</Replace>
<Replace>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/Update/SetDisablePauseUXAccess</LocURI>
</Target>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>1</Data>
</Item>
</Replace>
<Replace>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoReboot</LocURI>
</Target>
<Meta>
<Type xmlns="syncml:metinf">text/plain</Type>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Data>1</Data>
</Item>
</Replace>
`))

View File

@ -74,6 +74,8 @@ func NewService(
DeleteMDMAppleSetupAssistant: eeservice.DeleteMDMAppleSetupAssistant, DeleteMDMAppleSetupAssistant: eeservice.DeleteMDMAppleSetupAssistant,
MDMAppleSyncDEPProfiles: eeservice.mdmAppleSyncDEPProfiles, MDMAppleSyncDEPProfiles: eeservice.mdmAppleSyncDEPProfiles,
DeleteMDMAppleBootstrapPackage: eeservice.DeleteMDMAppleBootstrapPackage, DeleteMDMAppleBootstrapPackage: eeservice.DeleteMDMAppleBootstrapPackage,
MDMWindowsEnableOSUpdates: eeservice.mdmWindowsEnableOSUpdates,
MDMWindowsDisableOSUpdates: eeservice.mdmWindowsDisableOSUpdates,
}) })
return eeservice, nil return eeservice, nil

View File

@ -137,7 +137,7 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T
return nil, err return nil, err
} }
var macOSMinVersionUpdated, macOSDiskEncryptionUpdated, macOSEnableEndUserAuthUpdated bool var macOSMinVersionUpdated, windowsUpdatesUpdated, macOSDiskEncryptionUpdated, macOSEnableEndUserAuthUpdated bool
if payload.MDM != nil { if payload.MDM != nil {
if payload.MDM.MacOSUpdates != nil { if payload.MDM.MacOSUpdates != nil {
if err := payload.MDM.MacOSUpdates.Validate(); err != 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 { if payload.MDM.EnableDiskEncryption.Valid {
macOSDiskEncryptionUpdated = team.Config.MDM.EnableDiskEncryption != payload.MDM.EnableDiskEncryption.Value macOSDiskEncryptionUpdated = team.Config.MDM.EnableDiskEncryption != payload.MDM.EnableDiskEncryption.Value
if macOSDiskEncryptionUpdated && !appCfg.MDM.EnabledAndConfigured { 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") 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 { if macOSDiskEncryptionUpdated {
var act fleet.ActivityDetails var act fleet.ActivityDetails
if team.Config.MDM.EnableDiskEncryption { 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 { if err := spec.MDM.MacOSUpdates.Validate(); err != nil {
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("macos_updates", err.Error())) 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 { if create {
@ -826,6 +869,7 @@ func (svc *Service) createTeamFromSpec(
MDM: fleet.TeamMDM{ MDM: fleet.TeamMDM{
EnableDiskEncryption: enableDiskEncryption, EnableDiskEncryption: enableDiskEncryption,
MacOSUpdates: spec.MDM.MacOSUpdates, MacOSUpdates: spec.MDM.MacOSUpdates,
WindowsUpdates: spec.MDM.WindowsUpdates,
MacOSSettings: macOSSettings, MacOSSettings: macOSSettings,
MacOSSetup: macOSSetup, MacOSSetup: macOSSetup,
}, },
@ -883,6 +927,9 @@ func (svc *Service) editTeamFromSpec(
if spec.MDM.MacOSUpdates.Deadline.Set || spec.MDM.MacOSUpdates.MinimumVersion.Set { if spec.MDM.MacOSUpdates.Deadline.Set || spec.MDM.MacOSUpdates.MinimumVersion.Set {
team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates 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 oldEnableDiskEncryption := team.Config.MDM.EnableDiskEncryption
if err := svc.applyTeamMacOSSettings(ctx, spec, &team.Config.MDM.MacOSSettings); err != nil { 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 didUpdateBootstrapPackage = oldMacOSSetup.BootstrapPackage.Value != spec.MDM.MacOSSetup.BootstrapPackage.Value
team.Config.MDM.MacOSSetup.BootstrapPackage = spec.MDM.MacOSSetup.BootstrapPackage 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 && if !appCfg.MDM.EnabledAndConfigured &&
((didUpdateSetupAssistant && team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value != "") || ((didUpdateSetupAssistant && team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value != "") ||
(didUpdateBootstrapPackage && team.Config.MDM.MacOSSetup.BootstrapPackage.Value != "")) { (didUpdateBootstrapPackage && team.Config.MDM.MacOSSetup.BootstrapPackage.Value != "")) {

View File

@ -150,6 +150,10 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
mode: "", mode: "",
webhook_url: "", webhook_url: "",
}, },
windows_updates: {
deadline_days: null,
grace_period_days: null,
},
end_user_authentication: { end_user_authentication: {
entity_id: "", entity_id: "",
issuer_uri: "", issuer_uri: "",

View File

@ -9,6 +9,8 @@ const baseClass = "data-error";
interface IDataErrorProps { interface IDataErrorProps {
/** the description text displayed under the header */ /** the description text displayed under the header */
description?: string; description?: string;
/** Excludes the link that asks user to create an issue. Defaults to `false` */
excludeIssueLink?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
card?: boolean; card?: boolean;
className?: string; className?: string;
@ -18,6 +20,7 @@ const DEFAULT_DESCRIPTION = "Refresh the page or log in again.";
const DataError = ({ const DataError = ({
description = DEFAULT_DESCRIPTION, description = DEFAULT_DESCRIPTION,
excludeIssueLink = false,
children, children,
card, card,
className, className,
@ -37,6 +40,7 @@ const DataError = ({
{children || ( {children || (
<> <>
<span className="info__data">{description}</span> <span className="info__data">{description}</span>
{!excludeIssueLink && (
<span className="info__data"> <span className="info__data">
If this keeps happening, please&nbsp; If this keeps happening, please&nbsp;
<CustomLink <CustomLink
@ -45,6 +49,7 @@ const DataError = ({
newTab newTab
/> />
</span> </span>
)}
</> </>
)} )}
</> </>

View File

@ -1,5 +1,8 @@
import React from "react";
import { Meta, StoryObj } from "@storybook/react"; import { Meta, StoryObj } from "@storybook/react";
import LastUpdatedText from "components/LastUpdatedText";
import SectionHeader from "."; import SectionHeader from ".";
const meta: Meta<typeof SectionHeader> = { const meta: Meta<typeof SectionHeader> = {
@ -13,3 +16,14 @@ export default meta;
type Story = StoryObj<typeof SectionHeader>; type Story = StoryObj<typeof SectionHeader>;
export const Basic: Story = {}; export const Basic: Story = {};
export const WithSubTitle: Story = {
args: {
subTitle: (
<LastUpdatedText
lastUpdatedAt={new Date().toISOString()}
whatToRetrieve={"operating systems"}
/>
),
},
};

View File

@ -4,10 +4,16 @@ const baseClass = "section-header";
interface ISectionHeaderProps { interface ISectionHeaderProps {
title: string; title: string;
subTitle?: React.ReactNode;
} }
const SectionHeader = ({ title }: ISectionHeaderProps) => { const SectionHeader = ({ title, subTitle }: ISectionHeaderProps) => {
return <h2 className={baseClass}>{title}</h2>; return (
<div className={baseClass}>
<h2>{title}</h2>
{subTitle && <div className={`${baseClass}__sub-title`}>{subTitle}</div>}
</div>
);
}; };
export default SectionHeader; export default SectionHeader;

View File

@ -1,8 +1,14 @@
.section-header { .section-header {
margin: 0 0 $pad-large; display: flex;
padding-bottom: $pad-small; align-items: center;
font-size: $medium; gap: $pad-small;
font-weight: $regular; padding-bottom: $pad-medium;
color: $core-fleet-black; border-bottom: 1px solid $ui-fleet-black-10;
border-bottom: solid 1px $ui-fleet-black-10; margin-bottom: $pad-xxlarge;
h2 {
margin: 0;
font-weight: normal;
font-size: 18px; // TODO: update font variables to include 18px;
}
} }

View File

@ -56,6 +56,7 @@ export enum ActivityType {
AddedScript = "added_script", AddedScript = "added_script",
DeletedScript = "deleted_script", DeletedScript = "deleted_script",
EditedScript = "edited_script", EditedScript = "edited_script",
EditedWindowsUpdates = "edited_windows_updates",
} }
export interface IActivity { export interface IActivity {
created_at: string; created_at: string;
@ -101,4 +102,6 @@ export interface IActivityDetails {
name?: string; name?: string;
script_execution_id?: string; script_execution_id?: string;
script_name?: string; script_name?: string;
deadline_days?: number;
grace_period_days?: number;
} }

View File

@ -37,8 +37,8 @@ export interface IMdmConfig {
windows_enabled_and_configured: boolean; windows_enabled_and_configured: boolean;
end_user_authentication: IEndUserAuthentication; end_user_authentication: IEndUserAuthentication;
macos_updates: { macos_updates: {
minimum_version: string; minimum_version: string | null;
deadline: string; deadline: string | null;
}; };
macos_settings: { macos_settings: {
custom_settings: null; custom_settings: null;
@ -50,6 +50,10 @@ export interface IMdmConfig {
macos_setup_assistant: string | null; macos_setup_assistant: string | null;
}; };
macos_migration: IMacOsMigrationSettings; macos_migration: IMacOsMigrationSettings;
windows_updates: {
deadline_days: number | null;
grace_period_days: number | null;
};
} }
export interface IDeviceGlobalConfig { export interface IDeviceGlobalConfig {

View File

@ -46,8 +46,8 @@ export interface ITeam extends ITeamSummary {
mdm?: { mdm?: {
enable_disk_encryption: boolean; enable_disk_encryption: boolean;
macos_updates: { macos_updates: {
minimum_version: string; minimum_version: string | null;
deadline: string; deadline: string | null;
}; };
macos_settings: { macos_settings: {
custom_settings: null; // TODO: types? custom_settings: null; // TODO: types?
@ -58,6 +58,10 @@ export interface ITeam extends ITeamSummary {
enable_end_user_authentication: boolean; enable_end_user_authentication: boolean;
macos_setup_assistant: string | null; // TODO: types? macos_setup_assistant: string | null; // TODO: types?
}; };
windows_updates: {
deadline_days: number | null;
grace_period_days: number | null;
};
}; };
} }

View File

@ -675,6 +675,27 @@ const TAGGED_TEMPLATES = {
</> </>
); );
}, },
editedWindowsUpdates: (activity: IActivity) => {
return (
<>
{" "}
updated the Windows OS update options (
<b>
Deadline: {activity.details?.deadline_days} days / Grace period:{" "}
{activity.details?.grace_period_days} days
</b>
) on hosts assigned to{" "}
{activity.details?.team_name ? (
<>
the <b>{activity.details.team_name}</b> team
</>
) : (
"no team"
)}
.
</>
);
},
deletedMultipleSavedQuery: (activity: IActivity) => { deletedMultipleSavedQuery: (activity: IActivity) => {
return <> deleted multiple queries.</>; return <> deleted multiple queries.</>;
}, },
@ -812,6 +833,9 @@ const getDetail = (
case ActivityType.EditedScript: { case ActivityType.EditedScript: {
return TAGGED_TEMPLATES.editedScript(activity); return TAGGED_TEMPLATES.editedScript(activity);
} }
case ActivityType.EditedWindowsUpdates: {
return TAGGED_TEMPLATES.editedWindowsUpdates(activity);
}
case ActivityType.DeletedMultipleSavedQuery: { case ActivityType.DeletedMultipleSavedQuery: {
return TAGGED_TEMPLATES.deletedMultipleSavedQuery(activity); return TAGGED_TEMPLATES.deletedMultipleSavedQuery(activity);
} }

View File

@ -8,6 +8,7 @@ import { NotificationContext } from "context/notification";
import PATHS from "router/paths"; import PATHS from "router/paths";
import CustomLink from "components/CustomLink"; import CustomLink from "components/CustomLink";
import SectionHeader from "components/SectionHeader";
import Spinner from "components/Spinner"; import Spinner from "components/Spinner";
import DataError from "components/DataError"; import DataError from "components/DataError";
@ -152,7 +153,7 @@ const CustomSettings = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
<h2>Custom settings</h2> <SectionHeader title="Custom settings" />
<p className={`${baseClass}__description`}> <p className={`${baseClass}__description`}>
Create and upload configuration profiles to apply custom settings.{" "} Create and upload configuration profiles to apply custom settings.{" "}
<CustomLink <CustomLink

View File

@ -1,14 +1,4 @@
.custom-settings { .custom-settings {
h2 {
margin-top: 0;
padding-bottom: $pad-small;
font-size: $medium;
font-weight: $regular;
color: $core-fleet-black;
border-bottom: solid 1px $ui-fleet-black-10;
}
&__description { &__description {
font-size: $x-small; font-size: $x-small;
margin: $pad-xxlarge 0; margin: $pad-xxlarge 0;

View File

@ -13,6 +13,7 @@ import CustomLink from "components/CustomLink";
import Checkbox from "components/forms/fields/Checkbox"; import Checkbox from "components/forms/fields/Checkbox";
import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import Spinner from "components/Spinner"; import Spinner from "components/Spinner";
import SectionHeader from "components/SectionHeader";
import DiskEncryptionTable from "./components/DiskEncryptionTable"; import DiskEncryptionTable from "./components/DiskEncryptionTable";
@ -114,7 +115,7 @@ const DiskEncryption = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
<h2>Disk encryption</h2> <SectionHeader title="Disk encryption" />
{!isPremiumTier ? ( {!isPremiumTier ? (
<PremiumFeatureMessage <PremiumFeatureMessage
className={`${baseClass}__premium-feature-message`} className={`${baseClass}__premium-feature-message`}

View File

@ -1,14 +1,4 @@
.disk-encryption { .disk-encryption {
h2 {
margin-top: 0;
padding-bottom: $pad-small;
margin-bottom: $pad-xxlarge;
font-size: $medium;
font-weight: $regular;
color: $core-fleet-black;
border-bottom: solid 1px $ui-fleet-black-10;
}
&__premium-feature-message { &__premium-feature-message {
margin-top: 80px; margin-top: 80px;
text-align: center; text-align: center;

View File

@ -1,48 +1,79 @@
import React, { useContext } from "react"; import React, { useContext, useEffect, useState } from "react";
import { InjectedRouter } from "react-router"; import { InjectedRouter } from "react-router";
import { IConfig } from "interfaces/config";
import { AppContext } from "context/app"; import { AppContext } from "context/app";
import OperatingSystems from "pages/DashboardPage/cards/OperatingSystems";
import useInfoCard from "pages/DashboardPage/components/InfoCard";
import PremiumFeatureMessage from "components/PremiumFeatureMessage"; import PremiumFeatureMessage from "components/PremiumFeatureMessage";
import OsMinVersionForm from "./components/OsMinVersionForm";
import NudgePreview from "./components/NudgePreview"; import NudgePreview from "./components/NudgePreview";
import TurnOnMdmMessage from "../components/TurnOnMdmMessage/TurnOnMdmMessage"; import TurnOnMdmMessage from "../components/TurnOnMdmMessage/TurnOnMdmMessage";
import CurrentVersionSection from "./components/CurrentVersionSection";
import TargetSection from "./components/TargetSection";
export type OSUpdatesSupportedPlatform = "darwin" | "windows";
const baseClass = "os-updates"; const baseClass = "os-updates";
const getSelectedPlatform = (
appConfig: IConfig | null
): OSUpdatesSupportedPlatform => {
// 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 { interface IOSUpdates {
router: InjectedRouter; router: InjectedRouter;
teamIdForApi: number; teamIdForApi?: number;
} }
const OSUpdates = ({ router, teamIdForApi }: IOSUpdates) => { const OSUpdates = ({ router, teamIdForApi }: IOSUpdates) => {
const { config, isPremiumTier } = useContext(AppContext); const { config, isPremiumTier } = useContext(AppContext);
const OperatingSystemCard = useInfoCard({ // the default platform is mac and we later update this value when we have
title: "macOS versions", // done more checks.
children: ( const [
<OperatingSystems selectedPlatform,
currentTeamId={teamIdForApi} setSelectedPlatform,
selectedPlatform="darwin" ] = useState<OSUpdatesSupportedPlatform>("darwin");
showTitle
showDescription={false}
includeNameColumn={false}
setShowTitle={() => {
return null;
}}
/>
),
});
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 <TurnOnMdmMessage router={router} />; return <TurnOnMdmMessage router={router} />;
} }
return isPremiumTier ? ( // Not premium shows premium message
if (!isPremiumTier) {
return (
<PremiumFeatureMessage
className={`${baseClass}__premium-feature-message`}
/>
);
}
const handleSelectPlatform = (platform: OSUpdatesSupportedPlatform) => {
setSelectedPlatform(platform);
};
return (
<div className={baseClass}> <div className={baseClass}>
<p className={`${baseClass}__description`}> <p className={`${baseClass}__description`}>
Remotely encourage the installation of macOS updates on hosts assigned Remotely encourage the installation of macOS updates on hosts assigned
@ -50,22 +81,17 @@ const OSUpdates = ({ router, teamIdForApi }: IOSUpdates) => {
</p> </p>
<div className={`${baseClass}__content`}> <div className={`${baseClass}__content`}>
<div className={`${baseClass}__form-table-content`}> <div className={`${baseClass}__form-table-content`}>
<div className={`${baseClass}__os-versions-card`}> <CurrentVersionSection currentTeamId={teamIdForApi} />
{OperatingSystemCard} <TargetSection
</div> currentTeamId={teamIdForApi}
<div className={`${baseClass}__os-version-form`}> onSelectAccordionItem={handleSelectPlatform}
<OsMinVersionForm currentTeamId={teamIdForApi} key={teamIdForApi} /> />
</div>
</div> </div>
<div className={`${baseClass}__nudge-preview`}> <div className={`${baseClass}__nudge-preview`}>
<NudgePreview /> <NudgePreview platform={selectedPlatform} />
</div> </div>
</div> </div>
</div> </div>
) : (
<PremiumFeatureMessage
className={`${baseClass}__premium-feature-message`}
/>
); );
}; };

View File

@ -9,13 +9,13 @@
max-width: $break-xxl; max-width: $break-xxl;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
justify-content: space-between; justify-content: center;
gap: $pad-xxlarge; gap: $pad-xxlarge;
h2 {
font-size: $small;
margin: 0;
} }
&__form-table-content, &__nudge-preview {
flex-grow: 1;
max-width: 640px;
} }
&__os-versions-card { &__os-versions-card {
@ -30,5 +30,10 @@
&__content { &__content {
flex-direction: column; flex-direction: column;
} }
&__form-table-content,
&__nudge-preview {
max-width: none;
}
} }
} }

View File

@ -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 (
<LastUpdatedText
lastUpdatedAt={data?.counts_updated_at}
whatToRetrieve={"operating systems"}
/>
);
};
if (!data) {
return null;
}
const renderTable = () => {
if (isError) {
return (
<DataError
description="Refresh the page to try again."
excludeIssueLink
/>
);
}
if (!data.os_versions) {
return <OSVersionsEmptyState />;
}
// 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 (
<OSVersionTable
osVersionData={filteredOSVersionData}
currentTeamId={currentTeamId}
isLoading={isLoadingOsVersions}
/>
);
};
return (
<div className={baseClass}>
<SectionHeader
title="Current versions"
subTitle={generateSubTitleText()}
/>
{renderTable()}
</div>
);
};
export default CurrentVersionSection;

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./CurrentVersionSection";

View File

@ -1,26 +1,25 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { useQuery } from "react-query";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import classnames from "classnames";
import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team";
import { NotificationContext } from "context/notification"; import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import configAPI from "services/entities/config"; import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import teamsAPI from "services/entities/teams";
// @ts-ignore // @ts-ignore
import InputField from "components/forms/fields/InputField"; import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
import validatePresence from "components/forms/validators/validate_presence"; 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; minOsVersion: string;
deadline: string; deadline: string;
} }
interface IMinOsVersionFormErrors { interface IMacOSTargetFormErrors {
minOsVersion?: string; minOsVersion?: string;
deadline?: 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); return /^\d{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$/.test(value);
}; };
const validateForm = (formData: IMinOsVersionFormData) => { const validateForm = (formData: IMacOSTargetFormData) => {
const errors: IMinOsVersionFormErrors = {}; const errors: IMacOSTargetFormErrors = {};
if (!validatePresence(formData.minOsVersion)) { if (!validatePresence(formData.minOsVersion)) {
errors.minOsVersion = "The minimum version is required."; errors.minOsVersion = "The minimum version is required.";
@ -62,49 +61,32 @@ const createMdmConfigData = (minOsVersion: string, deadline: string) => {
}; };
}; };
interface IOsMinVersionForm { interface IMacOSTargetFormProps {
currentTeamId?: number; currentTeamId: number;
defaultMinOsVersion: string;
defaultDeadline: string;
inAccordion?: boolean;
} }
const OsMinVersionForm = ({ const MacOSTargetForm = ({
currentTeamId = APP_CONTEXT_NO_TEAM_ID, currentTeamId,
}: IOsMinVersionForm) => { defaultMinOsVersion,
defaultDeadline,
inAccordion = false,
}: IMacOSTargetFormProps) => {
const { renderFlash } = useContext(NotificationContext); const { renderFlash } = useContext(NotificationContext);
const { config } = useContext(AppContext);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [minOsVersion, setMinOsVersion] = useState( const [minOsVersion, setMinOsVersion] = useState(defaultMinOsVersion);
currentTeamId === APP_CONTEXT_NO_TEAM_ID const [deadline, setDeadline] = useState(defaultDeadline);
? config?.mdm.macos_updates.minimum_version ?? ""
: ""
);
const [deadline, setDeadline] = useState(
currentTeamId === APP_CONTEXT_NO_TEAM_ID
? config?.mdm.macos_updates.deadline ?? ""
: ""
);
const [minOsVersionError, setMinOsVersionError] = useState< const [minOsVersionError, setMinOsVersionError] = useState<
string | undefined string | undefined
>(); >();
const [deadlineError, setDeadlineError] = useState<string | undefined>(); const [deadlineError, setDeadlineError] = useState<string | undefined>();
useQuery<ILoadTeamResponse, Error>( const classNames = classnames(baseClass, {
["apple mdm config", currentTeamId], [`${baseClass}__accordion-form`]: inAccordion,
});
// 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 handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@ -141,7 +123,7 @@ const OsMinVersionForm = ({
}; };
return ( return (
<form className={baseClass} onSubmit={handleSubmit}> <form className={classNames} onSubmit={handleSubmit}>
<InputField <InputField
label="Minimum version" label="Minimum version"
tooltip="The end user sees the window until their macOS is at or above this version." tooltip="The end user sees the window until their macOS is at or above this version."
@ -167,4 +149,4 @@ const OsMinVersionForm = ({
); );
}; };
export default OsMinVersionForm; export default MacOSTargetForm;

View File

@ -0,0 +1,6 @@
.mac-os-target-form {
&__accordion-form {
padding: $pad-large;
background-color: $ui-fleet-blue-10;
}
}

View File

@ -0,0 +1 @@
export { default } from "./MacOSTargetForm";

View File

@ -2,14 +2,19 @@ import React from "react";
import CustomLink from "components/CustomLink"; import CustomLink from "components/CustomLink";
import OsUpdateScreenshot from "../../../../../../assets/images/nudge-screenshot.png"; import { OSUpdatesSupportedPlatform } from "../../OSUpdates";
import MacOSUpdateScreenshot from "../../../../../../assets/images/nudge-screenshot.png";
import WindowsUpdateScreenshot from "../../../../../../assets/images/windows-nudge-screenshot.png";
const baseClass = "nudge-preview"; const baseClass = "nudge-preview";
const NudgePreview = () => { interface INudgeDescriptionProps {
return ( platform: OSUpdatesSupportedPlatform;
<div className={baseClass}> }
<h2>End user experience</h2> const NudgeDescription = ({ platform }: INudgeDescriptionProps) => {
return platform === "darwin" ? (
<>
<p> <p>
When a minimum version is saved, the end user sees the below window When a minimum version is saved, the end user sees the below window
until their macOS version is at or above the minimum version. 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" url="https://fleetdm.com/docs/using-fleet/mdm-macos-updates"
newTab newTab
/> />
</>
) : (
<>
<p>
When a new Windows update is published, the update will be downloaded
and installed automatically before 8am and after 5pm (end users 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.
</p>
<CustomLink
text="Learn more about Windows updates in Fleet"
url="Links to: https://fleetdm.com/docs/using-fleet/mdm-windows-updates"
newTab
/>
</>
);
};
type INudgeImageProps = INudgeDescriptionProps;
const NudgeImage = ({ platform }: INudgeImageProps) => {
return (
<img <img
className={`${baseClass}__preview-img`} className={`${baseClass}__preview-img`}
src={OsUpdateScreenshot} src={
platform === "darwin" ? MacOSUpdateScreenshot : WindowsUpdateScreenshot
}
alt="OS update preview screenshot" alt="OS update preview screenshot"
/> />
);
};
interface INudgePreviewProps {
platform: OSUpdatesSupportedPlatform;
}
const NudgePreview = ({ platform }: INudgePreviewProps) => {
return (
<div className={baseClass}>
<h2>End user experience</h2>
<NudgeDescription platform={platform} />
<NudgeImage platform={platform} />
</div> </div>
); );
}; };

View File

@ -1,8 +1,11 @@
.nudge-preview { .nudge-preview {
box-sizing: border-box;
background-color: $ui-off-white; background-color: $ui-off-white;
border-radius: $border-radius; border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10; border: 1px solid $ui-fleet-black-10;
padding: $pad-xxlarge; padding: $pad-xxlarge;
max-width: 640px;
flex-grow: 1;
&__preview-img { &__preview-img {
margin-top: $pad-xxlarge; margin-top: $pad-xxlarge;
@ -11,4 +14,8 @@
max-width: 540px; max-width: 540px;
margin: 40px auto 0; margin: 40px auto 0;
} }
@media (max-width: $break-md) {
max-width: none;
}
} }

View File

@ -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 (
<div className={baseClass}>
<Icon name={platform} />
<span>{versionName}</span>
</div>
);
};
export default OSTypeCell;

View File

@ -0,0 +1,5 @@
.os-type-cell {
display: flex;
align-items: center;
gap: $pad-small;
}

View File

@ -0,0 +1 @@
export { default } from "./OSTypeCell";

View File

@ -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 (
<div className={baseClass}>
<TableContainer
columns={columns}
data={osVersionData}
isLoading={isLoading}
resultsTitle=""
emptyComponent={OSVersionsEmptyState}
showMarkAllPages={false}
isAllPagesSelected={false}
defaultSortHeader={DEFAULT_SORT_HEADER}
defaultSortDirection={DEFAULT_SORT_DIRECTION}
disableTableHeader
disableCount
disablePagination
/>
</div>
);
};
export default OSVersionTable;

View File

@ -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) => (
<OSTypeCell
platform={row.original.platform}
versionName={row.original.name_only}
/>
),
},
{
title: "Version",
Header: "Version",
disableSortBy: true,
accessor: "version",
},
{
title: "Hosts",
accessor: "hosts_count",
disableSortBy: false,
Header: (cellProps: IHeaderProps) => (
<HeaderCell
value={cellProps.column.title}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
Cell: ({ row }: IHostCellProps): JSX.Element => {
const { hosts_count, name_only, version } = row.original;
return (
<span className="hosts-cell__wrapper">
<span className="hosts-cell__count">
<TextCell value={hosts_count} />
</span>
<span className="hosts-cell__link">
<ViewAllHostsLink
queryParams={{
os_name: name_only,
os_version: version,
team_id: teamId,
}}
condensed
className="os-hosts-link"
/>
</span>
</span>
);
},
},
];
};

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./OSVersionTable";

View File

@ -0,0 +1,22 @@
import React from "react";
import EmptyTable from "components/EmptyTable";
const baseClass = "os-versions-empty-state";
const OSVersionsEmptyState = () => {
return (
<EmptyTable
className={`${baseClass}__empty-table`}
header="No OS versions detected."
info={
<span>
This report is updated every hour to protect
<br /> the performance of your devices.
</span>
}
/>
);
};
export default OSVersionsEmptyState;

View File

@ -0,0 +1,3 @@
.os-versions-empty-state {
margin: 0 auto $pad-xxlarge;
}

View File

@ -0,0 +1 @@
export { default } from "./OSVersionsEmptyState";

View File

@ -1 +0,0 @@
export { default } from "./OsMinVersionForm";

View File

@ -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 (
<Accordion
className={`${baseClass}__accordion`}
preExpanded={["mac"]}
onChange={(selected) =>
onSelectAccordionItem(selected[0] as OSUpdatesSupportedPlatform)
}
>
<AccordionItem uuid="mac">
<AccordionItemHeading>
<AccordionItemButton className={`${baseClass}__accordion-button`}>
<span>macOS</span>
<AccordionItemState>
{({ expanded }) => (
<Icon
name="chevron-up"
className={generateIconClassNames(expanded)}
/>
)}
</AccordionItemState>
</AccordionItemButton>
</AccordionItemHeading>
<AccordionItemPanel className={`${baseClass}__accordion-panel`}>
<MacOSTargetForm
currentTeamId={currentTeamId}
defaultMinOsVersion={defaultMacOSVersion}
defaultDeadline={defaultMacOSDeadline}
key={currentTeamId}
inAccordion
/>
</AccordionItemPanel>
</AccordionItem>
<AccordionItem uuid="windows">
<AccordionItemHeading>
<AccordionItemButton className={`${baseClass}__accordion-button`}>
<span>Windows</span>
<AccordionItemState>
{({ expanded }) => (
<Icon
name="chevron-up"
className={generateIconClassNames(expanded)}
/>
)}
</AccordionItemState>
</AccordionItemButton>
</AccordionItemHeading>
<AccordionItemPanel className={`${baseClass}__accordion-panel`}>
<WindowsTargetForm
currentTeamId={currentTeamId}
defaultDeadlineDays={defaultWindowsDeadlineDays}
defaultGracePeriodDays={defaultWindowsGracePeriodDays}
key={currentTeamId}
inAccordion
/>
</AccordionItemPanel>
</AccordionItem>
</Accordion>
);
};
export default PlatformsAccordion;

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
export { default } from "./PlatformsAccordion";

View File

@ -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 <Spinner />;
}
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 (
<PlatformsAccordion
currentTeamId={currentTeamId}
defaultMacOSVersion={defaultMacOSVersion}
defaultMacOSDeadline={defaultMacOSDeadline}
defaultWindowsDeadlineDays={defaultWindowsDeadlineDays}
defaultWindowsGracePeriodDays={defaultWindowsGracePeriodDays}
onSelectAccordionItem={onSelectAccordionItem}
/>
);
} else if (isMacMdmEnabled) {
return (
<MacOSTargetForm
currentTeamId={currentTeamId}
defaultMinOsVersion={defaultMacOSVersion}
defaultDeadline={defaultMacOSDeadline}
/>
);
}
return (
<WindowsTargetForm
currentTeamId={currentTeamId}
defaultDeadlineDays={defaultWindowsDeadlineDays}
defaultGracePeriodDays={defaultWindowsGracePeriodDays}
/>
);
};
return (
<div className={baseClass}>
<SectionHeader title="Target" />
{renderTargetForms()}
</div>
);
};
export default TargetSection;

View File

@ -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;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./TargetSection";

View File

@ -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<HTMLFormElement>) => {
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", "Couldnt update. Please try again.");
} finally {
setIsSaving(false);
}
}
};
const handleDeadlineDaysChange = (val: string) => {
setDeadlineDays(val);
};
const handleGracePeriodDays = (val: string) => {
setGracePeriodDays(val);
};
return (
<form className={classNames} onSubmit={handleSubmit}>
<InputField
label="Deadline"
tooltip="Number of days the end user has before updates are installed and the host is forced to restart."
hint="Number of days from 0 to 30."
placeholder="5"
value={deadlineDays}
error={deadlineDaysError}
onChange={handleDeadlineDaysChange}
/>
<InputField
label="Grace period"
tooltip="Number of days after the deadline the end user has before the host is forced to restart (only if end user was offline when deadline passed)."
hint="Number of days from 0 to 7."
placeholder="2"
value={gracePeriodDays}
error={gracePeriodDaysError}
onChange={handleGracePeriodDays}
/>
<Button type="submit" isLoading={isSaving}>
Save
</Button>
</form>
);
};
export default WindowsTargetForm;

View File

@ -0,0 +1,7 @@
.windows-target-form {
&__accordion-form {
padding: $pad-large;
background-color: $ui-fleet-blue-10;
}
}

View File

@ -0,0 +1 @@
export { default } from "./WindowsTargetForm";

View File

@ -8,6 +8,8 @@ import mdmAPI from "services/entities/mdm";
import { NotificationContext } from "context/notification"; import { NotificationContext } from "context/notification";
import Spinner from "components/Spinner"; import Spinner from "components/Spinner";
import SectionHeader from "components/SectionHeader";
import BootstrapPackagePreview from "./components/BootstrapPackagePreview"; import BootstrapPackagePreview from "./components/BootstrapPackagePreview";
import PackageUploader from "./components/BootstrapPackageUploader"; import PackageUploader from "./components/BootstrapPackageUploader";
import UploadedPackageView from "./components/UploadedPackageView"; import UploadedPackageView from "./components/UploadedPackageView";
@ -65,7 +67,7 @@ const BootstrapPackage = ({ currentTeamId }: IBootstrapPackageProps) => {
return ( return (
<div className={baseClass}> <div className={baseClass}>
<h2>Bootstrap package</h2> <SectionHeader title="Bootstrap package" />
{isLoading ? ( {isLoading ? (
<Spinner /> <Spinner />
) : ( ) : (

View File

@ -1,13 +1,4 @@
.bootstrap-package { .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 { &__content {
max-width: $break-xxl; max-width: $break-xxl;
margin: 0 auto; margin: 0 auto;

View File

@ -1,16 +1,17 @@
import React from "react"; import React from "react";
import { InjectedRouter } from "react-router"; import { InjectedRouter } from "react-router";
import PATHS from "router/paths"; import PATHS from "router/paths";
import { useQuery } from "react-query";
import configAPI from "services/entities/config"; import configAPI from "services/entities/config";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { IConfig, IMdmConfig } from "interfaces/config"; import { IConfig, IMdmConfig } from "interfaces/config";
import { ITeamConfig } from "interfaces/team";
import SectionHeader from "components/SectionHeader/SectionHeader"; 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 Spinner from "components/Spinner";
import EndUserExperiencePreview from "pages/ManageControlsPage/components/EndUserExperiencePreview";
import RequireEndUserAuth from "./components/RequireEndUserAuth/RequireEndUserAuth"; import RequireEndUserAuth from "./components/RequireEndUserAuth/RequireEndUserAuth";
import EndUserAuthForm from "./components/EndUserAuthForm/EndUserAuthForm"; import EndUserAuthForm from "./components/EndUserAuthForm/EndUserAuthForm";

View File

@ -21,12 +21,13 @@ export interface IGetOSVersionsRequest {
export interface IGetOSVersionsQueryKey extends IGetOSVersionsRequest { export interface IGetOSVersionsQueryKey extends IGetOSVersionsRequest {
scope: string; scope: string;
} }
export interface IOSVersionsResponse { export interface IOSVersionsResponse {
counts_updated_at: string; counts_updated_at: string;
os_versions: IOperatingSystemVersion[]; os_versions: IOperatingSystemVersion[];
} }
export const getOSVersions = async ({ export const getOSVersions = ({
id, id,
platform, platform,
teamId, teamId,

View File

@ -41,10 +41,14 @@ export interface IUpdateTeamFormData {
webhook_settings: Partial<ITeamWebhookSettings>; webhook_settings: Partial<ITeamWebhookSettings>;
integrations: IIntegrations; integrations: IIntegrations;
mdm: { mdm: {
macos_updates: { macos_updates?: {
minimum_version: string; minimum_version: string;
deadline: string; deadline: string;
}; };
windows_updates?: {
deadline_days: number;
grace_period_days: number;
};
}; };
} }

View File

@ -93,6 +93,45 @@ func (b *Bool) UnmarshalJSON(data []byte) error {
return nil 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 { type Slice[T any] struct {
Set bool Set bool
Valid bool Valid bool

View File

@ -90,7 +90,7 @@ func TestString(t *testing.T) {
} }
func TestBool(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 { cases := []struct {
data string data string
wantErr 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) { func TestSlice(t *testing.T) {
t.Run("slice of ints", func(t *testing.T) { t.Run("slice of ints", func(t *testing.T) {
cases := []struct { cases := []struct {

View File

@ -841,7 +841,6 @@ func testUpdateHostTablesOnMDMUnenroll(t *testing.T, ds *Datastore) {
func expectAppleProfiles( func expectAppleProfiles(
t *testing.T, t *testing.T,
ds *Datastore, ds *Datastore,
newSet []*fleet.MDMAppleConfigProfile,
tmID *uint, tmID *uint,
want []*fleet.MDMAppleConfigProfile, want []*fleet.MDMAppleConfigProfile,
) map[string]uint { ) map[string]uint {
@ -878,7 +877,7 @@ func testBatchSetMDMAppleProfiles(t *testing.T, ds *Datastore) {
ctx := context.Background() ctx := context.Background()
err := ds.BatchSetMDMAppleProfiles(ctx, tmID, newSet) err := ds.BatchSetMDMAppleProfiles(ctx, tmID, newSet)
require.NoError(t, err) 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 { withTeamID := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile {

View File

@ -8,6 +8,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "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/go-kit/kit/log/level"
"github.com/jmoiron/sqlx" "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) { 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 var profs []*fleet.MDMConfigProfilePayload
const selectStmt = ` const selectStmt = `
@ -142,7 +146,8 @@ FROM (
FROM FROM
mdm_windows_configuration_profiles mdm_windows_configuration_profiles
WHERE WHERE
team_id = ? team_id = ? AND
name NOT IN (?)
) as combined_profiles ) as combined_profiles
` `
@ -156,8 +161,13 @@ FROM (
for k := range fleetIdentsMap { for k := range fleetIdentsMap {
fleetIdentifiers = append(fleetIdentifiers, k) 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 := appendListOptionsWithCursorToSQL(selectStmt, args, &opt)
stmt, args, err := sqlx.In(stmt, args...) stmt, args, err := sqlx.In(stmt, args...)

View File

@ -9,6 +9,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "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/ptr"
"github.com/fleetdm/fleet/v4/server/test" "github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid" "github.com/google/uuid"
@ -183,8 +184,8 @@ func testBatchSetMDMProfiles(t *testing.T, ds *Datastore) {
ctx := context.Background() ctx := context.Background()
err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet) err := ds.BatchSetMDMProfiles(ctx, tmID, newAppleSet, newWindowsSet)
require.NoError(t, err) require.NoError(t, err)
expectAppleProfiles(t, ds, newAppleSet, tmID, wantApple) expectAppleProfiles(t, ds, tmID, wantApple)
expectWindowsProfiles(t, ds, newWindowsSet, tmID, wantWindows) expectWindowsProfiles(t, ds, tmID, wantWindows)
} }
withTeamIDApple := func(p *fleet.MDMAppleConfigProfile, tmID uint) *fleet.MDMAppleConfigProfile { 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.Len(t, profs, 0)
require.Equal(t, *meta, fleet.PaginationMetadata{}) 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() { for idf := range mobileconfig.FleetPayloadIdentifiers() {
_, err = ds.NewMDMAppleConfigProfile(ctx, *generateCP("name_"+idf, idf, team.ID)) _, err = ds.NewMDMAppleConfigProfile(ctx, *generateCP("name_"+idf, idf, team.ID))
require.NoError(t, err) require.NoError(t, err)
@ -324,6 +325,25 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
require.Len(t, profs, 0) require.Len(t, profs, 0)
require.Equal(t, *meta, fleet.PaginationMetadata{}) 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 // create a mac profile for global and a Windows profile for team
profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("A", "A", 0)) profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("A", "A", 0))
require.NoError(t, err) require.NoError(t, err)

View File

@ -700,6 +700,18 @@ func (ds *Datastore) DeleteMDMWindowsConfigProfile(ctx context.Context, profileU
return nil 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{}) { func subqueryHostsMDMWindowsOSSettingsStatusFailed() (string, []interface{}) {
sql := ` sql := `
SELECT SELECT
@ -1358,6 +1370,51 @@ INSERT INTO
}, nil }, 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( func (ds *Datastore) batchSetMDMWindowsProfilesDB(
ctx context.Context, ctx context.Context,
tx sqlx.ExtContext, tx sqlx.ExtContext,

View File

@ -33,6 +33,7 @@ func TestMDMWindows(t *testing.T) {
{"TestBulkOperationsMDMWindowsHostProfilesBatch3", testBulkOperationsMDMWindowsHostProfilesBatch3}, {"TestBulkOperationsMDMWindowsHostProfilesBatch3", testBulkOperationsMDMWindowsHostProfilesBatch3},
{"TestGetMDMWindowsProfilesContents", testGetMDMWindowsProfilesContents}, {"TestGetMDMWindowsProfilesContents", testGetMDMWindowsProfilesContents},
{"TestMDMWindowsConfigProfiles", testMDMWindowsConfigProfiles}, {"TestMDMWindowsConfigProfiles", testMDMWindowsConfigProfiles},
{"TestSetOrReplaceMDMWindowsConfigProfile", testSetOrReplaceMDMWindowsConfigProfile},
{"TestMDMWindowsDiskEncryption", testMDMWindowsDiskEncryption}, {"TestMDMWindowsDiskEncryption", testMDMWindowsDiskEncryption},
{"TestMDMWindowsProfilesSummary", testMDMWindowsProfilesSummary}, {"TestMDMWindowsProfilesSummary", testMDMWindowsProfilesSummary},
{"TestBatchSetMDMWindowsProfiles", testBatchSetMDMWindowsProfiles}, {"TestBatchSetMDMWindowsProfiles", testBatchSetMDMWindowsProfiles},
@ -1800,10 +1801,74 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
require.NoError(t, err) 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( func expectWindowsProfiles(
t *testing.T, t *testing.T,
ds *Datastore, ds *Datastore,
newSet []*fleet.MDMWindowsConfigProfile,
tmID *uint, tmID *uint,
want []*fleet.MDMWindowsConfigProfile, want []*fleet.MDMWindowsConfigProfile,
) map[string]string { ) map[string]string {
@ -1844,7 +1909,7 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet) return ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, newSet)
}) })
require.NoError(t, err) 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 { withTeamID := func(p *fleet.MDMWindowsConfigProfile, tmID uint) *fleet.MDMWindowsConfigProfile {

View File

@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` (
UNIQUE KEY `id` (`id`) UNIQUE KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */; /*!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 @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */; /*!40101 SET character_set_client = utf8 */;
CREATE TABLE `carve_blocks` ( CREATE TABLE `carve_blocks` (

View File

@ -586,6 +586,10 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
MinimumVersion: optjson.SetString("10.15.0"), MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2025-10-01"), Deadline: optjson.SetString("2025-10-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(7),
GracePeriodDays: optjson.SetInt(3),
},
MacOSSetup: fleet.MacOSSetup{ MacOSSetup: fleet.MacOSSetup{
BootstrapPackage: optjson.SetString("bootstrap"), BootstrapPackage: optjson.SetString("bootstrap"),
MacOSSetupAssistant: optjson.SetString("assistant"), MacOSSetupAssistant: optjson.SetString("assistant"),
@ -605,6 +609,10 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
MinimumVersion: optjson.SetString("10.15.0"), MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2025-10-01"), Deadline: optjson.SetString("2025-10-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(7),
GracePeriodDays: optjson.SetInt(3),
},
MacOSSetup: fleet.MacOSSetup{ MacOSSetup: fleet.MacOSSetup{
BootstrapPackage: optjson.SetString("bootstrap"), BootstrapPackage: optjson.SetString("bootstrap"),
MacOSSetupAssistant: optjson.SetString("assistant"), MacOSSetupAssistant: optjson.SetString("assistant"),

View File

@ -49,6 +49,7 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeMDMUnenrolled{}, ActivityTypeMDMUnenrolled{},
ActivityTypeEditedMacOSMinVersion{}, ActivityTypeEditedMacOSMinVersion{},
ActivityTypeEditedWindowsUpdates{},
ActivityTypeReadHostDiskEncryptionKey{}, 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 { type ActivityTypeReadHostDiskEncryptionKey struct {
HostID uint `json:"host_id"` HostID uint `json:"host_id"`
HostDisplayName string `json:"host_display_name"` HostDisplayName string `json:"host_display_name"`

View File

@ -148,6 +148,8 @@ type MDM struct {
EnabledAndConfigured bool `json:"enabled_and_configured"` 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"` MacOSSettings MacOSSettings `json:"macos_settings"`
MacOSSetup MacOSSetup `json:"macos_setup"` MacOSSetup MacOSSetup `json:"macos_setup"`
MacOSMigration MacOSMigration `json:"macos_migration"` MacOSMigration MacOSMigration `json:"macos_migration"`
@ -231,6 +233,68 @@ func (m MacOSUpdates) Validate() error {
return nil 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. // MacOSSettings contains settings specific to macOS.
type MacOSSettings struct { type MacOSSettings struct {
// CustomSettings is a slice of configuration profile file paths. // CustomSettings is a slice of configuration profile file paths.

View File

@ -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) { func TestMacOSUpdatesEnabledForHost(t *testing.T) {
hostWithRequirements := &Host{ hostWithRequirements := &Host{
OsqueryHostID: ptr.String("notempty"), OsqueryHostID: ptr.String("notempty"),

View File

@ -1077,6 +1077,10 @@ type Datastore interface {
// the specified profile uuid. // the specified profile uuid.
DeleteMDMWindowsConfigProfile(ctx context.Context, profileUUID string) error 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 returns the MDM profile information for the specified Windows host UUID.
GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]HostMDMWindowsProfile, error) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]HostMDMWindowsProfile, error)
@ -1137,6 +1141,11 @@ type Datastore interface {
// NewMDMWindowsConfigProfile creates and returns a new configuration profile. // NewMDMWindowsConfigProfile creates and returns a new configuration profile.
NewMDMWindowsConfigProfile(ctx context.Context, cp MDMWindowsConfigProfile) (*MDMWindowsConfigProfile, error) 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 // BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or
// no team in a single transaction. // no team in a single transaction.
BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile) error BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile) error

View File

@ -32,6 +32,8 @@ type EnterpriseOverrides struct {
DeleteMDMAppleSetupAssistant func(ctx context.Context, teamID *uint) error DeleteMDMAppleSetupAssistant func(ctx context.Context, teamID *uint) error
MDMAppleSyncDEPProfiles func(ctx context.Context) error MDMAppleSyncDEPProfiles func(ctx context.Context) error
DeleteMDMAppleBootstrapPackage func(ctx context.Context, teamID *uint) 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 { type OsqueryService interface {

View File

@ -34,6 +34,7 @@ type TeamPayload struct {
type TeamPayloadMDM struct { type TeamPayloadMDM struct {
EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"`
MacOSUpdates *MacOSUpdates `json:"macos_updates"` MacOSUpdates *MacOSUpdates `json:"macos_updates"`
WindowsUpdates *WindowsUpdates `json:"windows_updates"`
MacOSSettings *MacOSSettings `json:"macos_settings"` MacOSSettings *MacOSSettings `json:"macos_settings"`
MacOSSetup *MacOSSetup `json:"macos_setup"` MacOSSetup *MacOSSetup `json:"macos_setup"`
WindowsSettings *WindowsSettings `json:"windows_settings"` WindowsSettings *WindowsSettings `json:"windows_settings"`
@ -151,6 +152,7 @@ type TeamWebhookSettings struct {
type TeamMDM struct { type TeamMDM struct {
EnableDiskEncryption bool `json:"enable_disk_encryption"` EnableDiskEncryption bool `json:"enable_disk_encryption"`
MacOSUpdates MacOSUpdates `json:"macos_updates"` MacOSUpdates MacOSUpdates `json:"macos_updates"`
WindowsUpdates WindowsUpdates `json:"windows_updates"`
MacOSSettings MacOSSettings `json:"macos_settings"` MacOSSettings MacOSSettings `json:"macos_settings"`
MacOSSetup MacOSSetup `json:"macos_setup"` MacOSSetup MacOSSetup `json:"macos_setup"`
@ -200,6 +202,7 @@ type TeamSpecMDM struct {
EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` 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 // 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 // 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 var mdmSpec TeamSpecMDM
mdmSpec.MacOSUpdates = t.Config.MDM.MacOSUpdates mdmSpec.MacOSUpdates = t.Config.MDM.MacOSUpdates
mdmSpec.WindowsUpdates = t.Config.MDM.WindowsUpdates
mdmSpec.MacOSSettings = t.Config.MDM.MacOSSettings.ToMap() mdmSpec.MacOSSettings = t.Config.MDM.MacOSSettings.ToMap()
delete(mdmSpec.MacOSSettings, "enable_disk_encryption") delete(mdmSpec.MacOSSettings, "enable_disk_encryption")
mdmSpec.MacOSSetup = t.Config.MDM.MacOSSetup mdmSpec.MacOSSetup = t.Config.MDM.MacOSSetup

View File

@ -45,6 +45,10 @@ type MDMWindowsConfigProfile struct {
// //
// Returns an error if these conditions are not met. // Returns an error if these conditions are not met.
func (m *MDMWindowsConfigProfile) ValidateUserProvided() error { 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" { if mdm.GetRawProfilePlatform(m.SyncML) != "windows" {
// it doesn't start with <Replace>, check if it is still valid XML. // it doesn't start with <Replace>, check if it is still valid XML.
if len(bytes.TrimSpace(m.SyncML)) == 0 { if len(bytes.TrimSpace(m.SyncML)) == 0 {

View File

@ -3,6 +3,7 @@ package fleet
import ( import (
"testing" "testing"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -140,6 +141,14 @@ func TestValidateUserProvided(t *testing.T) {
}, },
wantErr: true, wantErr: true,
}, },
{
name: "Valid XML with reserved name",
profile: MDMWindowsConfigProfile{
Name: microsoft_mdm.FleetWindowsOSUpdatesProfileName,
SyncML: []byte(`<Replace><Target><LocURI>Custom/URI</LocURI></Target></Replace>`),
},
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -398,8 +398,16 @@ const (
const ( const (
FleetBitLockerTargetLocURI = "/Vendor/MSFT/BitLocker" FleetBitLockerTargetLocURI = "/Vendor/MSFT/BitLocker"
FleetOSUpdateTargetLocURI = "/Vendor/MSFT/Policy/Config/Update" 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) { func ResolveWindowsMDMDiscovery(serverURL string) (string, error) {
return commonmdm.ResolveURL(serverURL, MDE2DiscoveryPath, false) return commonmdm.ResolveURL(serverURL, MDE2DiscoveryPath, false)
} }

View File

@ -704,6 +704,8 @@ type GetMDMWindowsConfigProfileFunc func(ctx context.Context, profileUUID string
type DeleteMDMWindowsConfigProfileFunc func(ctx context.Context, profileUUID string) error 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 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) 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 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 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) type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error)
@ -1784,6 +1788,9 @@ type DataStore struct {
DeleteMDMWindowsConfigProfileFunc DeleteMDMWindowsConfigProfileFunc DeleteMDMWindowsConfigProfileFunc DeleteMDMWindowsConfigProfileFunc
DeleteMDMWindowsConfigProfileFuncInvoked bool DeleteMDMWindowsConfigProfileFuncInvoked bool
DeleteMDMWindowsConfigProfileByTeamAndNameFunc DeleteMDMWindowsConfigProfileByTeamAndNameFunc
DeleteMDMWindowsConfigProfileByTeamAndNameFuncInvoked bool
GetHostMDMWindowsProfilesFunc GetHostMDMWindowsProfilesFunc GetHostMDMWindowsProfilesFunc GetHostMDMWindowsProfilesFunc
GetHostMDMWindowsProfilesFuncInvoked bool GetHostMDMWindowsProfilesFuncInvoked bool
@ -1823,6 +1830,9 @@ type DataStore struct {
NewMDMWindowsConfigProfileFunc NewMDMWindowsConfigProfileFunc NewMDMWindowsConfigProfileFunc NewMDMWindowsConfigProfileFunc
NewMDMWindowsConfigProfileFuncInvoked bool NewMDMWindowsConfigProfileFuncInvoked bool
SetOrUpdateMDMWindowsConfigProfileFunc SetOrUpdateMDMWindowsConfigProfileFunc
SetOrUpdateMDMWindowsConfigProfileFuncInvoked bool
BatchSetMDMProfilesFunc BatchSetMDMProfilesFunc BatchSetMDMProfilesFunc BatchSetMDMProfilesFunc
BatchSetMDMProfilesFuncInvoked bool BatchSetMDMProfilesFuncInvoked bool
@ -4263,6 +4273,13 @@ func (s *DataStore) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU
return s.DeleteMDMWindowsConfigProfileFunc(ctx, profileUUID) 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) { func (s *DataStore) GetHostMDMWindowsProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) {
s.mu.Lock() s.mu.Lock()
s.GetHostMDMWindowsProfilesFuncInvoked = true s.GetHostMDMWindowsProfilesFuncInvoked = true
@ -4354,6 +4371,13 @@ func (s *DataStore) NewMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDM
return s.NewMDMWindowsConfigProfileFunc(ctx, cp) 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 { func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
s.mu.Lock() s.mu.Lock()
s.BatchSetMDMProfilesFuncInvoked = true s.BatchSetMDMProfilesFuncInvoked = true

View File

@ -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 appConfig.MDM.EnableDiskEncryption.Valid && oldAppConfig.MDM.EnableDiskEncryption.Value != appConfig.MDM.EnableDiskEncryption.Value {
if oldAppConfig.MDM.EnabledAndConfigured { if oldAppConfig.MDM.EnabledAndConfigured {
var act fleet.ActivityDetails var act fleet.ActivityDetails
@ -691,6 +722,20 @@ func (svc *Service) validateMDM(
invalid.Append("macos_updates", err.Error()) 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 // EndUserAuthentication
// only validate SSO settings if they changed // only validate SSO settings if they changed
if mdm.EndUserAuthentication.SSOProviderSettings != oldMdm.EndUserAuthentication.SSOProviderSettings { if mdm.EndUserAuthentication.SSOProviderSettings != oldMdm.EndUserAuthentication.SSOProviderSettings {

View File

@ -812,6 +812,7 @@ func TestMDMAppleConfig(t *testing.T) {
expectedMDM: fleet.MDM{ expectedMDM: fleet.MDM{
MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, 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}}, 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}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
}, },
@ -840,6 +841,7 @@ func TestMDMAppleConfig(t *testing.T) {
AppleBMDefaultTeam: "foobar", AppleBMDefaultTeam: "foobar",
MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, 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}}, 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}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
}, },
@ -853,6 +855,7 @@ func TestMDMAppleConfig(t *testing.T) {
AppleBMDefaultTeam: "foobar", AppleBMDefaultTeam: "foobar",
MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, 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}}, 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}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, 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"}}, EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}},
MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, 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}}, 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}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, 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}}, 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}}, 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}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
}, },

View File

@ -5270,6 +5270,13 @@ func (s *integrationTestSuite) TestAppConfig() {
"mdm": { "apple_bm_default_team": "xyz" } "mdm": { "apple_bm_default_team": "xyz" }
}`), http.StatusUnprocessableEntity, &acResp) }`), 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 // try to enable Windows MDM, impossible without the feature flag
// (only set in mdm integrations tests) // (only set in mdm integrations tests)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{

View File

@ -3,8 +3,10 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -22,6 +24,7 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/live_query/live_query_mock" "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/ptr"
"github.com/fleetdm/fleet/v4/server/test" "github.com/fleetdm/fleet/v4/server/test"
"github.com/go-kit/log" "github.com/go-kit/log"
@ -130,6 +133,10 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
MinimumVersion: optjson.SetString("10.15.0"), MinimumVersion: optjson.SetString("10.15.0"),
Deadline: optjson.SetString("2021-01-01"), Deadline: optjson.SetString("2021-01-01"),
}, },
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.Int{Set: true},
GracePeriodDays: optjson.Int{Set: true},
},
MacOSSetup: fleet.MacOSSetup{ MacOSSetup: fleet.MacOSSetup{
// because the MacOSSetup was marshalled to JSON to be saved in the DB, // 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 // 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 // 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) 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 // dry-run with invalid agent options
agentOpts = json.RawMessage(`{"config": {"nope": 1}}`) agentOpts = json.RawMessage(`{"config": {"nope": 1}}`)
teamSpecs = map[string]any{ 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") s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest, "dry_run", "true")
// dry-run with empty body // 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) errBody, err := io.ReadAll(res.Body)
require.NoError(t, err) require.NoError(t, err)
require.Contains(t, string(errBody), `"Expected JSON Body"`) 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") 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.") 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 // 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) 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() t := s.T()
// Create a team // Create a team
@ -1734,6 +2024,24 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesConfig() {
require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) 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) 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 // 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{ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{
"mdm": nil, "mdm": nil,
@ -2040,6 +2348,165 @@ func (s *integrationEnterpriseTestSuite) TestDefaultAppleBMTeam() {
require.Equal(t, tm.Name, acResp.MDM.AppleBMDefaultTeam) 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() { func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() {
t := s.T() 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, "fleet_detail_query_software_linux")
require.Contains(t, dqResp.Queries, fmt.Sprintf("fleet_distributed_query_%s", t.Name())) 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(`<Data>%d</Data>`, wantSettings.DeadlineDays.Value))
require.Contains(t, string(prof.SyncML), fmt.Sprintf(`<Data>%d</Data>`, wantSettings.GracePeriodDays.Value))
}
return prof.ProfileUUID
}

View File

@ -8043,7 +8043,7 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
return id 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 var tmPtr *uint
if teamID > 0 { if teamID > 0 {
tmPtr = &teamID tmPtr = &teamID
@ -8065,7 +8065,7 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
return resp.ProfileID return resp.ProfileID
} }
createWindowsProfile := func(name string, teamID uint) string { 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 var wantJSON string
if teamID == 0 { if teamID == 0 {
@ -8086,29 +8086,31 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
teamWinProfID := createWindowsProfile("win-team-profile", testTeam.ID) teamWinProfID := createWindowsProfile("win-team-profile", testTeam.ID)
// Windows profile name conflicts with Apple's for no team // 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 // 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 // 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.") 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 // but no conflict for team 1
assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, http.StatusOK, "") 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 // 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 // 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 // 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.") 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 // but no conflict for no-team
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, http.StatusOK, "") assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, http.StatusOK, "")
// not an xml nor mobileconfig file // 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.") assertAppleProfile("foo.txt", "foo", "foo-ident", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.")
// Windows-reserved LocURI // 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("bitlocker.xml", 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("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 // Windows invalid content
body, headers := generateNewProfileMultipartRequest(t, nil, "win.xml", []byte("\x00\x01\x02"), s.token) 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 // try to delete the profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", profile.ProfileID), nil, http.StatusBadRequest, &deleteResp) 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() { func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() {

View File

@ -24,6 +24,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" 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/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/kit/log/level" "github.com/go-kit/kit/log/level"
"github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
@ -1116,6 +1117,12 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU
return ctxerr.Wrap(ctx, err) 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 { if err := svc.ds.DeleteMDMWindowsConfigProfile(ctx, profileUUID); err != nil {
return ctxerr.Wrap(ctx, err) return ctxerr.Wrap(ctx, err)
} }