From b0ab7bbdc421599e8c3e4ff355d4c77550f196f6 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 19 Mar 2024 13:21:16 -0400 Subject: [PATCH 1/6] Add enable_release_device_manually setting to team and no-team (#17698) --- .../17401-add-enable-release-device-manually | 1 + cmd/fleetctl/apply_test.go | 104 +++++++++-- .../expectedGetConfigAppConfigJson.json | 3 +- .../expectedGetConfigAppConfigYaml.yml | 1 + ...ectedGetConfigIncludeServerConfigJson.json | 3 +- ...pectedGetConfigIncludeServerConfigYaml.yml | 1 + .../testdata/expectedGetTeamsJson.json | 6 +- .../testdata/expectedGetTeamsYaml.yml | 2 + .../macosSetupExpectedAppConfigEmpty.yml | 1 + .../macosSetupExpectedAppConfigSet.yml | 1 + .../macosSetupExpectedTeam1And2Empty.yml | 2 + .../macosSetupExpectedTeam1And2Set.yml | 2 + .../testdata/macosSetupExpectedTeam1Empty.yml | 1 + .../testdata/macosSetupExpectedTeam1Set.yml | 31 ++++ ee/server/service/mdm.go | 13 +- ee/server/service/teams.go | 26 ++- server/datastore/mysql/schema.sql | 2 +- server/datastore/mysql/teams_test.go | 5 +- server/fleet/app.go | 4 + server/fleet/apple_mdm.go | 1 + server/fleet/teams.go | 7 + server/service/appconfig.go | 22 +++ server/service/appconfig_test.go | 12 +- server/service/integration_core_test.go | 13 ++ server/service/integration_enterprise_test.go | 73 ++++++-- server/service/integration_mdm_test.go | 168 +++++++++++++++++- .../generated_files/appconfig.txt | 7 +- .../cloner-check/generated_files/teammdm.txt | 4 + 28 files changed, 463 insertions(+), 53 deletions(-) create mode 100644 changes/17401-add-enable-release-device-manually create mode 100644 cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml diff --git a/changes/17401-add-enable-release-device-manually b/changes/17401-add-enable-release-device-manually new file mode 100644 index 0000000000..8ad195e74d --- /dev/null +++ b/changes/17401-add-enable-release-device-manually @@ -0,0 +1 @@ +* Added the `enable_release_device_manually` configuration setting for a team and no team. **Note** that the macOS automatic enrollment profile cannot set the `await_device_configured` option anymore, this setting is controlled by Fleet via the new `enable_release_device_manually` option. diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index cd9340a19c..eeeeeb8026 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -14,6 +14,7 @@ import ( "path/filepath" "sort" "strconv" + "strings" "sync" "testing" "time" @@ -201,12 +202,19 @@ spec: MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2011-03-01"), }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, } require.Equal(t, "[+] applied 2 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) assert.JSONEq(t, string(agentOpts), string(*teamsByName["team2"].Config.AgentOptions)) assert.JSONEq(t, string(newAgentOpts), string(*teamsByName["team1"].Config.AgentOptions)) assert.Equal(t, []*fleet.EnrollSecret{{Secret: "AAA"}}, enrolledSecretsCalled[uint(42)]) - assert.Equal(t, fleet.TeamMDM{}, teamsByName["team2"].Config.MDM) + assert.Equal(t, fleet.TeamMDM{ + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, + }, teamsByName["team2"].Config.MDM) assert.Equal(t, newMDMSettings, teamsByName["team1"].Config.MDM) assert.True(t, ds.ApplyEnrollSecretsFuncInvoked) ds.ApplyEnrollSecretsFuncInvoked = false @@ -234,6 +242,9 @@ spec: DeadlineDays: optjson.SetInt(5), GracePeriodDays: optjson.SetInt(1), }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, } assert.Equal(t, newMDMSettings, teamsByName["team1"].Config.MDM) @@ -262,6 +273,9 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []fleet.MDMProfileSpec{{Path: mobileCfgPath}}, }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, } assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n") @@ -303,6 +317,9 @@ spec: MacOSSettings: fleet.MacOSSettings{ // macos settings not provided, so not cleared CustomSettings: []fleet.MDMProfileSpec{{Path: mobileCfgPath}}, }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, } newAgentOpts = json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`) require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) @@ -376,6 +393,9 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []fleet.MDMProfileSpec{}, }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, } assert.Contains(t, runAppForTest(t, []string{"apply", "-f", filename}), "[+] applied 1 teams\n") @@ -524,6 +544,9 @@ spec: MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(5), GracePeriodDays: optjson.SetInt(1), @@ -576,6 +599,9 @@ spec: MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}, @@ -1136,7 +1162,8 @@ spec: assert.Equal(t, fleet.MDM{ EnabledAndConfigured: true, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.SetString(emptySetupAsst), + MacOSSetupAssistant: optjson.SetString(emptySetupAsst), + EnableReleaseDeviceManually: optjson.SetBool(false), }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -1177,8 +1204,9 @@ spec: assert.Equal(t, fleet.MDM{ EnabledAndConfigured: true, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.SetString(emptySetupAsst), - BootstrapPackage: optjson.SetString(bootstrapURL), + MacOSSetupAssistant: optjson.SetString(emptySetupAsst), + BootstrapPackage: optjson.SetString(bootstrapURL), + EnableReleaseDeviceManually: optjson.SetBool(false), }, MacOSUpdates: fleet.MacOSUpdates{ MinimumVersion: optjson.SetString("10.10.10"), @@ -1235,6 +1263,9 @@ spec: MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, + MacOSSetup: fleet.MacOSSetup{ + EnableReleaseDeviceManually: optjson.SetBool(false), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(1), @@ -1279,7 +1310,8 @@ spec: GracePeriodDays: optjson.SetInt(1), }, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.SetString(emptySetupAsst), + MacOSSetupAssistant: optjson.SetString(emptySetupAsst), + EnableReleaseDeviceManually: optjson.SetBool(false), }, }, savedTeam.Config.MDM) @@ -1315,8 +1347,9 @@ spec: GracePeriodDays: optjson.SetInt(1), }, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.SetString(emptySetupAsst), - BootstrapPackage: optjson.SetString(bootstrapURL), + MacOSSetupAssistant: optjson.SetString(emptySetupAsst), + BootstrapPackage: optjson.SetString(bootstrapURL), + EnableReleaseDeviceManually: optjson.SetBool(false), }, }, savedTeam.Config.MDM) @@ -1728,6 +1761,9 @@ func TestApplyMacosSetup(t *testing.T) { invalidURLMacosSetup := writeTmpJSON(t, map[string]any{ "url": "https://example.com", }) + invalidAwaitMacosSetup := writeTmpJSON(t, map[string]any{ + "await_device_configured": true, + }) const ( appConfigSpec = ` @@ -1738,6 +1774,9 @@ spec: macos_setup: bootstrap_package: %s macos_setup_assistant: %s +` + appConfigEnableReleaseSpec = appConfigSpec + ` + enable_release_device_manually: %s ` appConfigNoKeySpec = ` apiVersion: v1 @@ -1764,6 +1803,9 @@ spec: macos_setup: bootstrap_package: %s macos_setup_assistant: %s +` + team1EnableReleaseSpec = team1Spec + ` + enable_release_device_manually: %s ` team1NoKeySpec = ` apiVersion: v1 @@ -1925,11 +1967,17 @@ spec: b, err = os.ReadFile(filepath.Join("testdata", "macosSetupExpectedAppConfigSet.yml")) require.NoError(t, err) expectedAppCfgSet := fmt.Sprintf(string(b), "", emptyMacosSetup) + expectedAppCfgSetReleaseEnabled := strings.ReplaceAll(expectedAppCfgSet, `enable_release_device_manually: false`, `enable_release_device_manually: true`) b, err = os.ReadFile(filepath.Join("testdata", "macosSetupExpectedTeam1Empty.yml")) require.NoError(t, err) expectedEmptyTm1 := string(b) + b, err = os.ReadFile(filepath.Join("testdata", "macosSetupExpectedTeam1Set.yml")) + require.NoError(t, err) + expectedTm1Set := fmt.Sprintf(string(b), "", "") + expectedTm1SetReleaseEnabled := strings.ReplaceAll(expectedTm1Set, `enable_release_device_manually: false`, `enable_release_device_manually: true`) + b, err = os.ReadFile(filepath.Join("testdata", "macosSetupExpectedTeam1And2Empty.yml")) require.NoError(t, err) expectedEmptyTm1And2 := string(b) @@ -1958,8 +2006,8 @@ spec: assert.YAMLEq(t, expectedEmptyAppCfg, runAppForTest(t, []string{"get", "config", "--yaml"})) assert.YAMLEq(t, expectedEmptyTm1, runAppForTest(t, []string{"get", "teams", "--yaml"})) - // apply appconfig for real - name = writeTmpYml(t, fmt.Sprintf(appConfigSpec, "", emptyMacosSetup)) + // apply appconfig for real, and enable release device + name = writeTmpYml(t, fmt.Sprintf(appConfigEnableReleaseSpec, "", emptyMacosSetup, "true")) assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name})) assert.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) assert.True(t, ds.SaveAppConfigFuncInvoked) @@ -1972,7 +2020,7 @@ spec: assert.True(t, ds.SaveTeamFuncInvoked) // get, setup assistant is now set - assert.YAMLEq(t, expectedAppCfgSet, runAppForTest(t, []string{"get", "config", "--yaml"})) + assert.YAMLEq(t, expectedAppCfgSetReleaseEnabled, runAppForTest(t, []string{"get", "config", "--yaml"})) assert.YAMLEq(t, expectedTm1And2Set, runAppForTest(t, []string{"get", "teams", "--yaml"})) // clear with dry-run, appconfig @@ -2008,11 +2056,11 @@ spec: assert.True(t, ds.SaveTeamFuncInvoked) // get, results unchanged - assert.YAMLEq(t, expectedAppCfgSet, runAppForTest(t, []string{"get", "config", "--yaml"})) + assert.YAMLEq(t, expectedAppCfgSetReleaseEnabled, runAppForTest(t, []string{"get", "config", "--yaml"})) assert.YAMLEq(t, expectedTm1And2Set, runAppForTest(t, []string{"get", "teams", "--yaml"})) // clear appconfig for real - name = writeTmpYml(t, fmt.Sprintf(appConfigSpec, "", "")) + name = writeTmpYml(t, fmt.Sprintf(appConfigEnableReleaseSpec, "", "", "false")) ds.SaveAppConfigFuncInvoked = false assert.Equal(t, "[+] applied fleet config\n", runAppForTest(t, []string{"apply", "-f", name})) assert.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) @@ -2031,16 +2079,40 @@ spec: assert.YAMLEq(t, expectedEmptyAppCfg, runAppForTest(t, []string{"get", "config", "--yaml"})) assert.YAMLEq(t, expectedEmptyTm1And2, runAppForTest(t, []string{"get", "teams", "--yaml"})) - // apply appconfig with invalid key + // apply team 1 without the setup assistant key but enable device release + name = writeTmpYml(t, fmt.Sprintf(team1EnableReleaseSpec, "", "", "true")) + ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked = false + ds.DeleteMDMAppleSetupAssistantFuncInvoked = false + ds.SaveTeamFuncInvoked = false + assert.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", name})) + assert.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) + assert.False(t, ds.DeleteMDMAppleSetupAssistantFuncInvoked) + assert.True(t, ds.SaveTeamFuncInvoked) + + assert.YAMLEq(t, expectedTm1SetReleaseEnabled, runAppForTest(t, []string{"get", "teams", "--yaml"})) + + // apply appconfig with invalid URL key name = writeTmpYml(t, fmt.Sprintf(appConfigSpec, "", invalidURLMacosSetup)) _, err = runAppNoChecks([]string{"apply", "-f", name}) - require.ErrorContains(t, err, "The automatic enrollment profile can’t include url.") + require.ErrorContains(t, err, "The automatic enrollment profile can't include url.") assert.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) - // apply teams with invalid key + // apply teams with invalid URL key name = writeTmpYml(t, fmt.Sprintf(team1And2Spec, "", invalidURLMacosSetup, "", invalidURLMacosSetup)) _, err = runAppNoChecks([]string{"apply", "-f", name}) - require.ErrorContains(t, err, "The automatic enrollment profile can’t include url.") + require.ErrorContains(t, err, "The automatic enrollment profile can't include url.") + assert.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) + + // apply appconfig with invalid await_device_configured key + name = writeTmpYml(t, fmt.Sprintf(appConfigSpec, "", invalidAwaitMacosSetup)) + _, err = runAppNoChecks([]string{"apply", "-f", name}) + require.ErrorContains(t, err, `The profile can't include "await_device_configured" option.`) + assert.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) + + // apply teams with invalid await_device_configured key + name = writeTmpYml(t, fmt.Sprintf(team1And2Spec, "", invalidAwaitMacosSetup, "", invalidAwaitMacosSetup)) + _, err = runAppNoChecks([]string{"apply", "-f", name}) + require.ErrorContains(t, err, `The profile can't include "await_device_configured" option.`) assert.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) }) diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index cf01e91342..cbf639713f 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -107,7 +107,8 @@ "macos_setup": { "bootstrap_package": null, "enable_end_user_authentication": false, - "macos_setup_assistant": null + "macos_setup_assistant": null, + "enable_release_device_manually": false }, "windows_settings": { "custom_settings": null diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 7597345851..624e722962 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -35,6 +35,7 @@ spec: macos_setup: bootstrap_package: enable_end_user_authentication: false + enable_release_device_manually: false macos_setup_assistant: windows_settings: custom_settings: null diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 216dbd75b3..70a8e5c295 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -65,7 +65,8 @@ "macos_setup": { "bootstrap_package": null, "enable_end_user_authentication": false, - "macos_setup_assistant": null + "macos_setup_assistant": null, + "enable_release_device_manually": false }, "windows_settings": { "custom_settings": null diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 3d614097fa..29623d2d0c 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -35,6 +35,7 @@ spec: macos_setup: bootstrap_package: enable_end_user_authentication: false + enable_release_device_manually: false macos_setup_assistant: windows_settings: custom_settings: diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index d19784f2fd..8612dfbad6 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -44,7 +44,8 @@ "macos_setup": { "bootstrap_package": null, "enable_end_user_authentication": false, - "macos_setup_assistant": null + "macos_setup_assistant": null, + "enable_release_device_manually": false }, "windows_settings": { "custom_settings": null @@ -117,7 +118,8 @@ "macos_setup": { "bootstrap_package": null, "enable_end_user_authentication": false, - "macos_setup_assistant": null + "macos_setup_assistant": null, + "enable_release_device_manually": false }, "windows_settings": { "custom_settings": null diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 1249b3e5fd..f609d46576 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -24,6 +24,7 @@ spec: macos_setup: bootstrap_package: enable_end_user_authentication: false + enable_release_device_manually: false macos_setup_assistant: scripts: null webhook_settings: @@ -64,6 +65,7 @@ spec: macos_setup: bootstrap_package: enable_end_user_authentication: false + enable_release_device_manually: false macos_setup_assistant: scripts: null webhook_settings: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index a453a2f7f5..66e53edd0f 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -32,6 +32,7 @@ spec: bootstrap_package: null enable_end_user_authentication: false macos_setup_assistant: null + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 5826922729..668edf56c7 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -32,6 +32,7 @@ spec: bootstrap_package: %s enable_end_user_authentication: false macos_setup_assistant: %s + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 7315325b48..ac0c802ece 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -19,6 +19,7 @@ spec: bootstrap_package: null enable_end_user_authentication: false macos_setup_assistant: null + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null @@ -49,6 +50,7 @@ spec: macos_setup: bootstrap_package: null macos_setup_assistant: null + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 1cce56630c..ca5914606f 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -19,6 +19,7 @@ spec: bootstrap_package: %s enable_end_user_authentication: false macos_setup_assistant: %s + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null @@ -49,6 +50,7 @@ spec: macos_setup: bootstrap_package: %s macos_setup_assistant: %s + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index c6e8b1653b..41b21230d9 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -17,6 +17,7 @@ spec: bootstrap_package: null enable_end_user_authentication: false macos_setup_assistant: null + enable_release_device_manually: false macos_updates: deadline: null minimum_version: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml new file mode 100644 index 0000000000..78e6052da4 --- /dev/null +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: team +spec: + team: + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: false + host_expiry_window: 0 + mdm: + enable_disk_encryption: false + macos_settings: + custom_settings: null + windows_settings: + custom_settings: null + macos_setup: + bootstrap_package: %s + enable_end_user_authentication: false + macos_setup_assistant: %s + enable_release_device_manually: false + macos_updates: + deadline: null + minimum_version: null + windows_updates: + deadline_days: null + grace_period_days: null + scripts: null + webhook_settings: + host_status_webhook: null + name: tm1 diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 2d80af3dbc..3835edc475 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -237,6 +237,13 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl } } + if payload.EnableReleaseDeviceManually != nil { + if ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually { + ac.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually) + didUpdate = true + } + } + if didUpdate { if err := svc.ds.SaveAppConfig(ctx, ac); err != nil { return err @@ -550,7 +557,10 @@ func (svc *Service) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst } if _, ok := m["url"]; ok { - return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", `Couldn’t edit macos_setup_assistant. The automatic enrollment profile can’t include url.`)) + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", `Couldn't edit macos_setup_assistant. The automatic enrollment profile can't include url.`)) + } + if _, ok := m["await_device_configured"]; ok { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", `Couldn't edit macos_setup_assistant. The profile can't include "await_device_configured" option.`)) } // must read the existing setup assistant first to detect if it did change @@ -933,6 +943,7 @@ func (svc *Service) getOrCreatePreassignTeam(ctx context.Context, groups []strin // instead by CopyDefaultMDMAppleBootstrapPackage below // BootstrapPackage: ac.MDM.MacOSSetup.BootstrapPackage, EnableEndUserAuthentication: ac.MDM.MacOSSetup.EnableEndUserAuthentication, + // TODO(mna): should we copy the EnableReleaseDeviceManually setting from the global config? }, } diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 95588cd466..4f9ab22e91 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" @@ -856,12 +857,16 @@ func (svc *Service) createTeamFromSpec( return nil, err } macOSSetup := spec.MDM.MacOSSetup - if macOSSetup.MacOSSetupAssistant.Value != "" || macOSSetup.BootstrapPackage.Value != "" { + if !macOSSetup.EnableReleaseDeviceManually.Valid { + macOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) + } + if macOSSetup.MacOSSetupAssistant.Value != "" || macOSSetup.BootstrapPackage.Value != "" || macOSSetup.EnableReleaseDeviceManually.Value { if !appCfg.MDM.EnabledAndConfigured { return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("macos_setup", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)) } } + enableDiskEncryption := spec.MDM.EnableDiskEncryption.Value if !spec.MDM.EnableDiskEncryption.Valid { if de := macOSSettings.DeprecatedEnableDiskEncryption; de != nil { @@ -993,8 +998,11 @@ func (svc *Service) editTeamFromSpec( `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) } + if !team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) + } oldMacOSSetup := team.Config.MDM.MacOSSetup - var didUpdateSetupAssistant, didUpdateBootstrapPackage bool + var didUpdateSetupAssistant, didUpdateBootstrapPackage, didUpdateEnableReleaseManually bool if spec.MDM.MacOSSetup.MacOSSetupAssistant.Set { didUpdateSetupAssistant = oldMacOSSetup.MacOSSetupAssistant.Value != spec.MDM.MacOSSetup.MacOSSetupAssistant.Value team.Config.MDM.MacOSSetup.MacOSSetupAssistant = spec.MDM.MacOSSetup.MacOSSetupAssistant @@ -1003,12 +1011,17 @@ func (svc *Service) editTeamFromSpec( didUpdateBootstrapPackage = oldMacOSSetup.BootstrapPackage.Value != spec.MDM.MacOSSetup.BootstrapPackage.Value team.Config.MDM.MacOSSetup.BootstrapPackage = spec.MDM.MacOSSetup.BootstrapPackage } + if spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + didUpdateEnableReleaseManually = oldMacOSSetup.EnableReleaseDeviceManually.Value != spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Value + team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = spec.MDM.MacOSSetup.EnableReleaseDeviceManually + } // TODO(mna): doesn't look like we create an activity for macos updates when // modified via spec? Doing the same for Windows, but should we? if !appCfg.MDM.EnabledAndConfigured && ((didUpdateSetupAssistant && team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value != "") || - (didUpdateBootstrapPackage && team.Config.MDM.MacOSSetup.BootstrapPackage.Value != "")) { + (didUpdateBootstrapPackage && team.Config.MDM.MacOSSetup.BootstrapPackage.Value != "") || + (didUpdateEnableReleaseManually && team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)) { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("macos_setup", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)) } @@ -1237,6 +1250,13 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, } } + if payload.EnableReleaseDeviceManually != nil { + if tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually { + tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually) + didUpdate = true + } + } + if didUpdate { if _, err := svc.ds.SaveTeam(ctx, tm); err != nil { return err diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index a35608e669..5b23f10ca3 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_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, \"scripts_disabled\": 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, \"enable_release_device_manually\": 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, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 254f509095..bbaa8c7c5e 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -613,8 +613,9 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { GracePeriodDays: optjson.SetInt(3), }, MacOSSetup: fleet.MacOSSetup{ - BootstrapPackage: optjson.SetString("bootstrap"), - MacOSSetupAssistant: optjson.SetString("assistant"), + BootstrapPackage: optjson.SetString("bootstrap"), + MacOSSetupAssistant: optjson.SetString("assistant"), + EnableReleaseDeviceManually: optjson.SetBool(false), }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}), diff --git a/server/fleet/app.go b/server/fleet/app.go index 778f6fe7eb..548008c1d5 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -380,6 +380,7 @@ type MacOSSetup struct { BootstrapPackage optjson.String `json:"bootstrap_package"` EnableEndUserAuthentication bool `json:"enable_end_user_authentication"` MacOSSetupAssistant optjson.String `json:"macos_setup_assistant"` + EnableReleaseDeviceManually optjson.Bool `json:"enable_release_device_manually"` } // MacOSMigration contains settings related to the MDM migration work flow. @@ -809,6 +810,9 @@ func (c AppConfig) MarshalJSON() ([]byte, error) { if !c.MDM.EnableDiskEncryption.Valid { c.MDM.EnableDiskEncryption = optjson.SetBool(false) } + if !c.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + c.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) + } type aliasConfig AppConfig aa := aliasConfig(c) diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 02a0b65f47..0b074b87dc 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -416,6 +416,7 @@ func (p MDMAppleSettingsPayload) AuthzType() string { type MDMAppleSetupPayload struct { TeamID *uint `json:"team_id"` EnableEndUserAuthentication *bool `json:"enable_end_user_authentication"` + EnableReleaseDeviceManually *bool `json:"enable_release_device_manually"` } // AuthzType implements authz.AuthzTyper. diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d1e3a86e98..bc4522febb 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -120,6 +120,9 @@ func (t *Team) UnmarshalJSON(b []byte) error { return err } + if !x.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + x.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) + } *t = Team{ ID: x.ID, CreatedAt: x.CreatedAt, @@ -241,6 +244,10 @@ func (t *TeamConfig) Scan(val interface{}) error { // Value implements the sql.Valuer interface func (t TeamConfig) Value() (driver.Value, error) { + // force-save as the default `false` value if not set + if !t.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + t.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) + } return json.Marshal(t) } diff --git a/server/service/appconfig.go b/server/service/appconfig.go index c92cd344cd..d0454b027a 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/server/authz" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" @@ -343,6 +344,19 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid { appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption } + // this is to handle the case where `enable_release_device_manually: null` is + // passed in the request payload, which should be treated as "not present/not + // changed" by the PATCH. We should really try to find a more general way to + // handle this. + if !oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + // this makes a DB migration unnecessary, will update the field to its default false value as necessary + oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) + } + if newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Valid { + appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = newAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually + } else { + appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually = oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually + } var legacyUsedWarning error if legacyKeys := appConfig.DidUnmarshalLegacySettings(); len(legacyKeys) > 0 { @@ -674,6 +688,9 @@ func (svc *Service) validateMDM( if mdm.MacOSSetup.MacOSSetupAssistant.Value != "" && oldMdm.MacOSSetup.MacOSSetupAssistant.Value != mdm.MacOSSetup.MacOSSetupAssistant.Value && !license.IsPremium() { invalid.Append("macos_setup.macos_setup_assistant", ErrMissingLicense.Error()) } + if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value && !license.IsPremium() { + invalid.Append("macos_setup.enable_release_device_manually", ErrMissingLicense.Error()) + } if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value && !license.IsPremium() { invalid.Append("macos_setup.bootstrap_package", ErrMissingLicense.Error()) } @@ -694,6 +711,11 @@ func (svc *Service) validateMDM( `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) } + if mdm.MacOSSetup.EnableReleaseDeviceManually.Value && oldMdm.MacOSSetup.EnableReleaseDeviceManually.Value != mdm.MacOSSetup.EnableReleaseDeviceManually.Value { + invalid.Append("macos_setup.enable_release_device_manually", + `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) + } + if mdm.MacOSSetup.BootstrapPackage.Value != "" && oldMdm.MacOSSetup.BootstrapPackage.Value != mdm.MacOSSetup.BootstrapPackage.Value { invalid.Append("macos_setup.bootstrap_package", `Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 884700bd7e..da0e71e7d9 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -810,7 +810,7 @@ func TestMDMAppleConfig(t *testing.T) { name: "nochange", licenseTier: "free", 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}, EnableReleaseDeviceManually: optjson.SetBool(false)}, 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}}, WindowsSettings: fleet.WindowsSettings{ @@ -840,7 +840,7 @@ func TestMDMAppleConfig(t *testing.T) { newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ 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}, EnableReleaseDeviceManually: optjson.SetBool(false)}, 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}}, WindowsSettings: fleet.WindowsSettings{ @@ -855,7 +855,7 @@ func TestMDMAppleConfig(t *testing.T) { newMDM: fleet.MDM{AppleBMDefaultTeam: "foobar"}, expectedMDM: fleet.MDM{ 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}, EnableReleaseDeviceManually: optjson.SetBool(false)}, 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}}, WindowsSettings: fleet.WindowsSettings{ @@ -876,7 +876,7 @@ func TestMDMAppleConfig(t *testing.T) { oldMDM: fleet.MDM{EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}}, expectedMDM: fleet.MDM{ 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}, EnableReleaseDeviceManually: optjson.SetBool(false)}, 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}}, WindowsSettings: fleet.WindowsSettings{ @@ -900,7 +900,7 @@ func TestMDMAppleConfig(t *testing.T) { MetadataURL: "http://isser.metadata.com", IDPName: "onelogin", }}, - 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}, EnableReleaseDeviceManually: optjson.SetBool(false)}, 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}}, WindowsSettings: fleet.WindowsSettings{ @@ -958,7 +958,7 @@ func TestMDMAppleConfig(t *testing.T) { }, expectedMDM: fleet.MDM{ EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, - 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}, EnableReleaseDeviceManually: optjson.SetBool(false)}, 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}}, WindowsSettings: fleet.WindowsSettings{ diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 2367b7529e..5d09fc2000 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5402,6 +5402,19 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { s.Do("POST", "/api/v1/fleet/hosts/123/lock", nil, http.StatusPaymentRequired) s.Do("POST", "/api/v1/fleet/hosts/123/unlock", nil, http.StatusPaymentRequired) s.Do("POST", "/api/v1/fleet/hosts/123/wipe", nil, http.StatusPaymentRequired) + + // try to update the enable_release_device_manually setting, requires premium + // (but /setup_experience catches the error of the MDM middleware check, so not + // StatusPaymentRequired) + res = s.Do("PATCH", "/api/v1/fleet/setup_experience", fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error()) + + res = s.Do("PATCH", "/api/v1/fleet/config", json.RawMessage(`{ + "mdm": { "macos_setup": { "enable_release_device_manually": true } } + }`), http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "missing or invalid license") } func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 787207fb23..bf46a6510e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/fleetdm/fleet/v4/server/pubsub" "io" "net/http" "net/http/httptest" @@ -20,6 +19,8 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/server/pubsub" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" @@ -149,8 +150,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { // 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 // null). - MacOSSetupAssistant: optjson.String{Set: true}, - BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), }, // because the WindowsSettings was marshalled to JSON to be saved in the DB, // it did get marshalled, and then when unmarshalled it was set (but @@ -210,8 +212,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { GracePeriodDays: optjson.SetInt(1), }, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.String{Set: true}, - BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -231,8 +234,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { GracePeriodDays: optjson.SetInt(1), }, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.String{Set: true}, - BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -254,8 +258,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { GracePeriodDays: optjson.SetInt(1), }, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.String{Set: true}, - BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -344,6 +349,40 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn't update macos_settings because MDM features aren't turned on in Fleet.") + // dry-run with macos enable release device set to false, no error + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "macos_setup": map[string]any{ + "enable_release_device_manually": false, + }, + }, + }, + }, + } + applyResp = applyTeamSpecsResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp, "dry_run", "true") + assert.Equal(t, map[string]uint{teamName: team.ID}, applyResp.TeamIDsByName) + + // dry-run with macos enable release device manually set to true + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "mdm": map[string]any{ + "macos_setup": map[string]any{ + "enable_release_device_manually": true, + }, + }, + }, + }, + } + res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, "dry_run", "true") + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't update macos_setup because MDM features aren't turned on in Fleet.") + // dry-run with invalid host_expiry_settings.host_expiry_window teamSpecs = map[string]any{ "specs": []map[string]any{ @@ -1920,8 +1959,9 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { GracePeriodDays: optjson.SetInt(2), }, MacOSSetup: fleet.MacOSSetup{ - MacOSSetupAssistant: optjson.String{Set: true}, - BootstrapPackage: optjson.String{Set: true}, + MacOSSetupAssistant: optjson.String{Set: true}, + BootstrapPackage: optjson.String{Set: true}, + EnableReleaseDeviceManually: optjson.SetBool(false), }, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -3531,6 +3571,17 @@ func (s *integrationEnterpriseTestSuite) TestMDMNotConfiguredEndpoints() { var reqCSRResp requestMDMAppleCSRResponse s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusOK, &reqCSRResp) s.Do("POST", "/api/latest/fleet/mdm/apple/dep/key_pair", nil, http.StatusOK) + + // setting enable release device manually requires MDM + res := s.Do("PATCH", "/api/v1/fleet/setup_experience", fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}, http.StatusBadRequest) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error()) + + res = s.Do("PATCH", "/api/v1/fleet/config", json.RawMessage(`{ + "mdm": { "macos_setup": { "enable_release_device_manually": true } } + }`), http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, `Couldn't update macos_setup because MDM features aren't turned on in Fleet.`) } func (s *integrationEnterpriseTestSuite) TestGlobalPolicyCreateReadPatch() { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index aaa2b73dc0..7d0e43310f 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -6323,7 +6323,7 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{Name: "team1"}) require.NoError(t, err) - cases := []struct { + endUserAuthCases := []struct { raw string expected bool }{ @@ -6355,6 +6355,73 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { }, } + writeTmpJSON := func(t *testing.T, v any) string { + tmpFile, err := os.CreateTemp(t.TempDir(), "*.json") + require.NoError(t, err) + err = json.NewEncoder(tmpFile).Encode(v) + require.NoError(t, err) + return tmpFile.Name() + } + + mustReadFile := func(t *testing.T, path string) string { + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) + } + + asstOk := writeTmpJSON(t, map[string]any{"ok": true}) + asstURL := writeTmpJSON(t, map[string]any{"url": "https://example.com"}) + asstAwait := writeTmpJSON(t, map[string]any{"await_device_configured": true}) + asstsByName := map[string]string{ + asstOk: mustReadFile(t, asstOk), + asstURL: mustReadFile(t, asstURL), + asstAwait: mustReadFile(t, asstAwait), + } + + enableReleaseDeviceCases := []struct { + enableRelease *bool + setupAssistant string + expectedRelease bool + expectedAssistant string + expectedStatus int + }{ + { + enableRelease: nil, + setupAssistant: "", + expectedRelease: false, + expectedAssistant: "", + expectedStatus: http.StatusOK, + }, + { + enableRelease: ptr.Bool(true), + setupAssistant: "", + expectedRelease: true, + expectedAssistant: "", + expectedStatus: http.StatusOK, + }, + { + enableRelease: ptr.Bool(false), + setupAssistant: "", + expectedRelease: false, + expectedAssistant: "", + expectedStatus: http.StatusOK, + }, + { + enableRelease: ptr.Bool(false), + setupAssistant: asstURL, + expectedRelease: false, + expectedAssistant: "", + expectedStatus: http.StatusUnprocessableEntity, + }, + { + enableRelease: ptr.Bool(true), + setupAssistant: asstAwait, + expectedRelease: false, + expectedAssistant: "", + expectedStatus: http.StatusUnprocessableEntity, + }, + } + t.Run("UpdateAppConfig", func(t *testing.T) { acResp := appConfigResponse{} path := "/api/latest/fleet/config" @@ -6364,11 +6431,13 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { }`, s)) } - // get the initial appconfig; enable end user authentication default is false + // get the initial appconfig; enable end user authentication and release + // device default is false s.DoJSON("GET", path, nil, http.StatusOK, &acResp) require.False(t, acResp.MDM.MacOSSetup.EnableEndUserAuthentication) + require.False(t, acResp.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) - for i, c := range cases { + for i, c := range endUserAuthCases { t.Run(strconv.Itoa(i), func(t *testing.T) { acResp = appConfigResponse{} s.DoJSON("PATCH", path, fmtJSON(c.raw), http.StatusOK, &acResp) @@ -6379,6 +6448,43 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { require.Equal(t, c.expected, acResp.MDM.MacOSSetup.EnableEndUserAuthentication) }) } + + for i, c := range enableReleaseDeviceCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + macSetup := map[string]any{} + if c.enableRelease != nil { + macSetup["enable_release_device_manually"] = *c.enableRelease + } + if c.setupAssistant != "" { + macSetup["macos_setup_assistant"] = c.setupAssistant + } + + uploadSucceeded := true + if c.setupAssistant != "" { + s.Do("POST", "/api/v1/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + Name: c.setupAssistant, + EnrollmentProfile: json.RawMessage(asstsByName[c.setupAssistant]), + }, c.expectedStatus) + if c.expectedStatus >= 300 { + uploadSucceeded = false + } + } + + if uploadSucceeded { + acResp = appConfigResponse{} + s.DoJSON("PATCH", path, + json.RawMessage(jsonMustMarshal(t, map[string]any{"mdm": map[string]any{"macos_setup": macSetup}})), + c.expectedStatus, &acResp) + require.Equal(t, c.expectedRelease, acResp.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.Equal(t, c.expectedAssistant, acResp.MDM.MacOSSetup.MacOSSetupAssistant.Value) + } + + acResp = appConfigResponse{} + s.DoJSON("GET", path, nil, http.StatusOK, &acResp) + require.Equal(t, c.expectedRelease, acResp.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.Equal(t, c.expectedAssistant, acResp.MDM.MacOSSetup.MacOSSetupAssistant.Value) + }) + } }) t.Run("UpdateTeamConfig", func(t *testing.T) { @@ -6388,12 +6494,14 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { %s }` - // get the initial team config; enable end user authentication default is false + // get the initial team config; enable end user authentication and release + // device default is false teamResp := teamResponse{} s.DoJSON("GET", path, nil, http.StatusOK, &teamResp) require.False(t, teamResp.Team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.False(t, teamResp.Team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) - for i, c := range cases { + for i, c := range endUserAuthCases { t.Run(strconv.Itoa(i), func(t *testing.T) { teamResp = teamResponse{} s.DoJSON("PATCH", path, json.RawMessage(fmt.Sprintf(fmtJSON, tm.Name, c.raw)), http.StatusOK, &teamResp) @@ -6404,6 +6512,54 @@ func (s *integrationMDMTestSuite) TestMDMMacOSSetup() { require.Equal(t, c.expected, teamResp.Team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) }) } + + for i, c := range enableReleaseDeviceCases { + expectedPatchStatus := c.expectedStatus + if expectedPatchStatus == http.StatusOK { + expectedPatchStatus = http.StatusNoContent + } + + t.Run(strconv.Itoa(i), func(t *testing.T) { + if c.setupAssistant != "" { + s.Do("POST", "/api/v1/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: &tm.ID, + Name: c.setupAssistant, + EnrollmentProfile: json.RawMessage(asstsByName[c.setupAssistant]), + }, c.expectedStatus) + uploadSucceeded := c.expectedStatus < 300 + + if uploadSucceeded { + // use the apply team specs to set both the setup assistant and the + // enable release at once + macSetup := fleet.MacOSSetup{ + MacOSSetupAssistant: optjson.SetString(c.setupAssistant), + } + if c.enableRelease != nil { + macSetup.EnableReleaseDeviceManually = optjson.SetBool(*c.enableRelease) + } + teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ + Name: tm.Name, + MDM: fleet.TeamSpecMDM{MacOSSetup: macSetup}, + }}} + s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) + } + } else { + // no setup assistant, use the PATCH /setup_experience endpoint + payload := map[string]any{ + "team_id": tm.ID, + } + if c.enableRelease != nil { + payload["enable_release_device_manually"] = *c.enableRelease + } + s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), expectedPatchStatus) + } + + teamResp = teamResponse{} + s.DoJSON("GET", path, nil, http.StatusOK, &teamResp) + require.Equal(t, c.expectedRelease, teamResp.Team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.Equal(t, c.expectedAssistant, teamResp.Team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value) + }) + } }) t.Run("TestMDMAppleSetupEndpoint", func(t *testing.T) { @@ -6675,7 +6831,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() { EnrollmentProfile: json.RawMessage(tmProf), }, http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) - require.Contains(t, errMsg, `The automatic enrollment profile can’t include url.`) + require.Contains(t, errMsg, `The automatic enrollment profile can't include url.`) s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(), fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), latestChangedActID) diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 5480e2432f..b57104f0fd 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -110,6 +110,10 @@ github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSetup fleet.MacOSSetup github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually optjson.Bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSMigration fleet.MacOSMigration github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Enable bool github.com/fleetdm/fleet/v4/server/fleet/MacOSMigration Mode fleet.MacOSMigrationMode string @@ -118,9 +122,6 @@ github.com/fleetdm/fleet/v4/server/fleet/MDM EndUserAuthentication fleet.MDMEndU github.com/fleetdm/fleet/v4/server/fleet/MDMEndUserAuthentication SSOProviderSettings fleet.SSOProviderSettings github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsEnabledAndConfigured bool github.com/fleetdm/fleet/v4/server/fleet/MDM EnableDiskEncryption optjson.Bool -github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool -github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool -github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt index b140ae9841..fcf77c0fca 100644 --- a/tools/cloner-check/generated_files/teammdm.txt +++ b/tools/cloner-check/generated_files/teammdm.txt @@ -20,6 +20,10 @@ github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSetup fleet.MacOSSetup github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String +github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableReleaseDeviceManually optjson.Bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool From 994040b1c958c0cb9cfa63249203460c45504d3d Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 25 Mar 2024 13:25:29 -0400 Subject: [PATCH 2/6] Send `DeviceConfigured` MDM command after DEP enrollment (#17737) --- .../17401-add-enable-release-device-manually | 1 + ee/server/service/mdm.go | 15 +- ee/server/service/teams.go | 13 +- server/datastore/mysql/jobs.go | 10 +- server/datastore/mysql/jobs_test.go | 11 +- ...pdateDEPProfilesToAwaitDeviceConfigured.go | 64 + ...DEPProfilesToAwaitDeviceConfigured_test.go | 52 + server/datastore/mysql/schema.sql | 7 +- server/fleet/datastore.go | 5 +- server/mdm/apple/apple_mdm.go | 25 +- server/mdm/apple/apple_mdm_test.go | 1 + server/mdm/apple/commander.go | 18 + server/mdm/nanodep/godep/profile.go | 14 +- server/mock/datastore_mock.go | 6 +- server/service/appconfig.go | 4 +- server/service/integration_mdm_dep_test.go | 1083 +++++++++++++++++ server/service/integration_mdm_test.go | 792 +----------- server/worker/apple_mdm.go | 190 ++- server/worker/apple_mdm_test.go | 166 ++- server/worker/macos_setup_assistant_test.go | 4 +- server/worker/worker.go | 27 +- server/worker/worker_test.go | 14 +- 22 files changed, 1689 insertions(+), 833 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go create mode 100644 server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go create mode 100644 server/service/integration_mdm_dep_test.go diff --git a/changes/17401-add-enable-release-device-manually b/changes/17401-add-enable-release-device-manually index 8ad195e74d..4fcda2283c 100644 --- a/changes/17401-add-enable-release-device-manually +++ b/changes/17401-add-enable-release-device-manually @@ -1 +1,2 @@ * Added the `enable_release_device_manually` configuration setting for a team and no team. **Note** that the macOS automatic enrollment profile cannot set the `await_device_configured` option anymore, this setting is controlled by Fleet via the new `enable_release_device_manually` option. +* Automatically release a macOS DEP-enrolled device after enrollment commands and profiles have been delivered, unless `enable_release_device_manually` is set to `true`. diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 3835edc475..aaa1507412 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -228,7 +228,7 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl return err } - var didUpdate, didUpdateMacOSEndUserAuth bool + var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSReleaseDevice bool if payload.EnableEndUserAuthentication != nil { if ac.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication { ac.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication @@ -241,6 +241,7 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl if ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually { ac.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually) didUpdate = true + didUpdateMacOSReleaseDevice = true } } @@ -248,6 +249,11 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl if err := svc.ds.SaveAppConfig(ctx, ac); err != nil { return err } + if didUpdateMacOSReleaseDevice { + if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, nil, nil); err != nil { + return err + } + } if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, ac.MDM.MacOSSetup.EnableEndUserAuthentication, nil, nil); err != nil { return err @@ -257,6 +263,13 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl return nil } +func (svc *Service) updateMacOSSetupEnableReleaseDevice(ctx context.Context, enable bool, teamID *uint, teamName *string) error { + if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil { + return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") + } + return nil +} + func (svc *Service) updateMacOSSetupEnableEndUserAuth(ctx context.Context, enable bool, teamID *uint, teamName *string) error { if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 4f9ab22e91..bf5b29f029 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1136,6 +1136,11 @@ func (svc *Service) editTeamFromSpec( } } + if didUpdateEnableReleaseManually { + if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, &team.ID, &team.Name); err != nil { + return err + } + } if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil { return err @@ -1241,7 +1246,7 @@ func (svc *Service) updateTeamMDMDiskEncryption(ctx context.Context, tm *fleet.T } func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, payload fleet.MDMAppleSetupPayload) error { - var didUpdate, didUpdateMacOSEndUserAuth bool + var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSReleaseDevice bool if payload.EnableEndUserAuthentication != nil { if tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication { tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication @@ -1254,6 +1259,7 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, if tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually { tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually) didUpdate = true + didUpdateMacOSReleaseDevice = true } } @@ -1261,6 +1267,11 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, if _, err := svc.ds.SaveTeam(ctx, tm); err != nil { return err } + if didUpdateMacOSReleaseDevice { + if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, &tm.ID, &tm.Name); err != nil { + return err + } + } if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication, &tm.ID, &tm.Name); err != nil { return err diff --git a/server/datastore/mysql/jobs.go b/server/datastore/mysql/jobs.go index 3c68ec45b9..d674ad26c2 100644 --- a/server/datastore/mysql/jobs.go +++ b/server/datastore/mysql/jobs.go @@ -35,7 +35,7 @@ VALUES (?, ?, ?, ?, ?, COALESCE(?, NOW())) return job, nil } -func (ds *Datastore) GetQueuedJobs(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) { +func (ds *Datastore) GetQueuedJobs(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) { query := ` SELECT id, created_at, updated_at, name, args, state, retries, error, not_before @@ -43,14 +43,18 @@ FROM jobs WHERE state = ? AND - not_before <= NOW() + not_before <= ? ORDER BY updated_at ASC LIMIT ? ` + if now.IsZero() { + now = time.Now().UTC() + } + var jobs []*fleet.Job - err := sqlx.SelectContext(ctx, ds.reader(ctx), &jobs, query, fleet.JobStateQueued, maxNumJobs) + err := sqlx.SelectContext(ctx, ds.reader(ctx), &jobs, query, fleet.JobStateQueued, now, maxNumJobs) if err != nil { return nil, err } diff --git a/server/datastore/mysql/jobs_test.go b/server/datastore/mysql/jobs_test.go index 177d54f22e..400003f57c 100644 --- a/server/datastore/mysql/jobs_test.go +++ b/server/datastore/mysql/jobs_test.go @@ -11,6 +11,9 @@ import ( func TestJobs(t *testing.T) { ds := CreateMySQLDS(t) + // call TruncateTables before the first test, because a DB migation may have + // created job entries. + TruncateTables(t, ds) cases := []struct { name string @@ -30,7 +33,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) { ctx := context.Background() // no jobs yet - jobs, err := ds.GetQueuedJobs(ctx, 10) + jobs, err := ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Empty(t, jobs) @@ -45,7 +48,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) { require.NotZero(t, j2.ID) // only j1 is returned - jobs, err = ds.GetQueuedJobs(ctx, 10) + jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Len(t, jobs, 1) require.Equal(t, j1.ID, jobs[0].ID) @@ -58,7 +61,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) { require.NoError(t, err) // no jobs queued for now - jobs, err = ds.GetQueuedJobs(ctx, 10) + jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Empty(t, jobs) @@ -68,7 +71,7 @@ func testQueueAndProcessJobs(t *testing.T, ds *Datastore) { require.NoError(t, err) // j2 is returned - jobs, err = ds.GetQueuedJobs(ctx, 10) + jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Len(t, jobs, 1) require.Equal(t, j2.ID, jobs[0].ID) diff --git a/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go new file mode 100644 index 0000000000..3d7db95b63 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured.go @@ -0,0 +1,64 @@ +package tables + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +func init() { + MigrationClient.AddMigration(Up_20240320145650, Down_20240320145650) +} + +func Up_20240320145650(tx *sql.Tx) error { + // This migration is to re-generate and re-register with Apple the DEP + // enrollment profile(s) so that await_device_configured is set to true. + // We do this by doing the equivalent of: + // + // worker.QueueMacosSetupAssistantJob(ctx, ds, logger, + // worker.MacosSetupAssistantUpdateAllProfiles, nil) + // + // but without calling that function, in case the code changes in the future, + // breaking this migration. Instead we insert directly the job in the + // database, and the worker will process it shortly after Fleet restarts. + + const ( + jobName = "macos_setup_assistant" + taskName = "update_all_profiles" + jobStateQueued = "queued" + ) + + type macosSetupAssistantArgs struct { + Task string `json:"task"` + TeamID *uint `json:"team_id,omitempty"` + HostSerialNumbers []string `json:"host_serial_numbers,omitempty"` + } + argsJSON, err := json.Marshal(macosSetupAssistantArgs{Task: taskName}) + if err != nil { + return fmt.Errorf("failed to JSON marshal the job arguments: %w", err) + } + + // hard-coded timestamps are used so that schema.sql is stable + const query = ` +INSERT INTO jobs ( + name, + args, + state, + error, + not_before, + created_at, + updated_at +) +VALUES (?, ?, ?, '', ?, ?, ?) +` + ts := time.Date(2024, 3, 20, 0, 0, 0, 0, time.UTC) + if _, err := tx.Exec(query, jobName, argsJSON, jobStateQueued, ts, ts, ts); err != nil { + return fmt.Errorf("failed to insert worker job: %w", err) + } + return nil +} + +func Down_20240320145650(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go new file mode 100644 index 0000000000..5ec92d352a --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240320145650_UpdateDEPProfilesToAwaitDeviceConfigured_test.go @@ -0,0 +1,52 @@ +package tables + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240320145650(t *testing.T) { + db := applyUpToPrev(t) + + type macosSetupAssistantArgs struct { + Task string `json:"task"` + TeamID *uint `json:"team_id,omitempty"` + HostSerialNumbers []string `json:"host_serial_numbers,omitempty"` + } + + type job struct { + ID uint `json:"id" db:"id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt *time.Time `json:"updated_at" db:"updated_at"` + Name string `json:"name" db:"name"` + Args *json.RawMessage `json:"args" db:"args"` + State string `json:"state" db:"state"` + Retries int `json:"retries" db:"retries"` + Error string `json:"error" db:"error"` + NotBefore time.Time `json:"not_before" db:"not_before"` + } + + var jobs []*job + err := db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs`) + require.NoError(t, err) + require.Empty(t, jobs) + + applyNext(t, db) + + err = db.Select(&jobs, `SELECT id, name, args, state, retries, error, not_before FROM jobs`) + require.NoError(t, err) + require.Len(t, jobs, 1) + + require.Equal(t, "macos_setup_assistant", jobs[0].Name) + require.Equal(t, 0, jobs[0].Retries) + require.LessOrEqual(t, jobs[0].NotBefore, time.Now().UTC()) + require.NotNil(t, jobs[0].Args) + + var args macosSetupAssistantArgs + err = json.Unmarshal(*jobs[0].Args, &args) + require.NoError(t, err) + require.Equal(t, "update_all_profiles", args.Task) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 5b23f10ca3..7db678014c 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -556,8 +556,9 @@ CREATE TABLE `jobs` ( `error` text COLLATE utf8mb4_unicode_ci, `not_before` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +INSERT INTO `jobs` VALUES (1,'2024-03-20 00:00:00','2024-03-20 00:00:00','macos_setup_assistant','{\"task\": \"update_all_profiles\"}','queued',0,'','2024-03-20 00:00:00'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `label_membership` ( @@ -779,9 +780,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=257 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=258 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240320145650,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 4081db8af0..29be3baee7 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -844,8 +844,9 @@ type Datastore interface { // NewJob inserts a new job into the jobs table (queue). NewJob(ctx context.Context, job *Job) (*Job, error) - // GetQueuedJobs gets queued jobs from the jobs table (queue). - GetQueuedJobs(ctx context.Context, maxNumJobs int) ([]*Job, error) + // GetQueuedJobs gets queued jobs from the jobs table (queue) ready to be + // processed. If now is the zero time, the current time will be used. + GetQueuedJobs(ctx context.Context, maxNumJobs int, now time.Time) ([]*Job, error) // UpdateJobs updates an existing job. Call this after processing a job. UpdateJob(ctx context.Context, id uint, job *Job) (*Job, error) diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index b358238dce..0e9f79982d 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -91,17 +91,16 @@ type DEPService struct { // getDefaultProfile returns a godep.Profile with default values set. func (d *DEPService) getDefaultProfile() *godep.Profile { return &godep.Profile{ - ProfileName: "FleetDM default enrollment profile", - AllowPairing: true, - AutoAdvanceSetup: false, - AwaitDeviceConfigured: false, - IsSupervised: false, - IsMultiUser: false, - IsMandatory: false, - IsMDMRemovable: true, - Language: "en", - OrgMagic: "1", - Region: "US", + ProfileName: "FleetDM default enrollment profile", + AllowPairing: true, + AutoAdvanceSetup: false, + IsSupervised: false, + IsMultiUser: false, + IsMandatory: false, + IsMDMRemovable: true, + Language: "en", + OrgMagic: "1", + Region: "US", SkipSetupItems: []string{ "Accessibility", "Appearance", @@ -207,6 +206,10 @@ func (d *DEPService) RegisterProfileWithAppleDEPServer(ctx context.Context, team // ensure `url` is the same as `configuration_web_url`, to not leak the URL // to get a token without SSO enabled jsonProf.URL = jsonProf.ConfigurationWebURL + // always set await_device_configured to true - it will be released either + // automatically by Fleet or manually by the user if + // enable_release_device_manually is true. + jsonProf.AwaitDeviceConfigured = true depClient := NewDEPClient(d.depStorage, d.ds, d.logger) res, err := depClient.DefineProfile(ctx, DEPName, &jsonProf) diff --git a/server/mdm/apple/apple_mdm_test.go b/server/mdm/apple/apple_mdm_test.go index 4485074a47..a03b5030d7 100644 --- a/server/mdm/apple/apple_mdm_test.go +++ b/server/mdm/apple/apple_mdm_test.go @@ -45,6 +45,7 @@ func TestDEPService(t *testing.T) { require.Contains(t, got.ConfigurationWebURL, serverURL+"api/mdm/apple/enroll?token=") got.URL = "" got.ConfigurationWebURL = "" + defaultProfile.AwaitDeviceConfigured = true // this is now always set to true require.Equal(t, defaultProfile, &got) default: require.Fail(t, "unexpected path: %s", r.URL.Path) diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 280596a0cb..24f8def567 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -226,6 +226,24 @@ func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUID return svc.EnqueueCommand(ctx, hostUUIDs, raw) } +func (svc *MDMAppleCommander) DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error { + raw := fmt.Sprintf(` + + + + Command + + RequestType + DeviceConfigured + + CommandUUID + %s + +`, cmdUUID) + + return svc.EnqueueCommand(ctx, []string{hostUUID}, raw) +} + // EnqueueCommand takes care of enqueuing the commands and sending push // notifications to the devices. // diff --git a/server/mdm/nanodep/godep/profile.go b/server/mdm/nanodep/godep/profile.go index d8f7149ad9..267b1448c2 100644 --- a/server/mdm/nanodep/godep/profile.go +++ b/server/mdm/nanodep/godep/profile.go @@ -8,12 +8,14 @@ import ( // Profile corresponds to the Apple DEP API "Profile" structure. // See https://developer.apple.com/documentation/devicemanagement/profile type Profile struct { - ProfileName string `json:"profile_name"` - URL string `json:"url"` - AllowPairing bool `json:"allow_pairing,omitempty"` - IsSupervised bool `json:"is_supervised,omitempty"` - IsMultiUser bool `json:"is_multi_user,omitempty"` - IsMandatory bool `json:"is_mandatory,omitempty"` + ProfileName string `json:"profile_name"` + URL string `json:"url"` + AllowPairing bool `json:"allow_pairing,omitempty"` + IsSupervised bool `json:"is_supervised,omitempty"` + IsMultiUser bool `json:"is_multi_user,omitempty"` + IsMandatory bool `json:"is_mandatory,omitempty"` + // AwaitDeviceConfigured should never be set in the profiles we store in the + // database - it is now always forced to true when registering with Apple. AwaitDeviceConfigured bool `json:"await_device_configured,omitempty"` IsMDMRemovable bool `json:"is_mdm_removable"` // default true SupportPhoneNumber string `json:"support_phone_number,omitempty"` diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1469826979..bab3ff1949 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -580,7 +580,7 @@ type SerialUpdateHostFunc func(ctx context.Context, host *fleet.Host) error type NewJobFunc func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) -type GetQueuedJobsFunc func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) +type GetQueuedJobsFunc func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) type UpdateJobFunc func(ctx context.Context, id uint, job *fleet.Job) (*fleet.Job, error) @@ -4089,11 +4089,11 @@ func (s *DataStore) NewJob(ctx context.Context, job *fleet.Job) (*fleet.Job, err return s.NewJobFunc(ctx, job) } -func (s *DataStore) GetQueuedJobs(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) { +func (s *DataStore) GetQueuedJobs(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) { s.mu.Lock() s.GetQueuedJobsFuncInvoked = true s.mu.Unlock() - return s.GetQueuedJobsFunc(ctx, maxNumJobs) + return s.GetQueuedJobsFunc(ctx, maxNumJobs, now) } func (s *DataStore) UpdateJob(ctx context.Context, id uint, job *fleet.Job) (*fleet.Job, error) { diff --git a/server/service/appconfig.go b/server/service/appconfig.go index d0454b027a..701dbb0878 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -630,7 +630,9 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle mdmSSOSettingsChanged := oldAppConfig.MDM.EndUserAuthentication.SSOProviderSettings != appConfig.MDM.EndUserAuthentication.SSOProviderSettings serverURLChanged := oldAppConfig.ServerSettings.ServerURL != appConfig.ServerSettings.ServerURL - if (mdmEnableEndUserAuthChanged || mdmSSOSettingsChanged || serverURLChanged) && license.IsPremium() { + mdmEnableReleaseDeviceChanged := oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != + appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value + if (mdmEnableEndUserAuthChanged || mdmEnableReleaseDeviceChanged || mdmSSOSettingsChanged || serverURLChanged) && license.IsPremium() { if err := svc.EnterpriseOverrides.MDMAppleSyncDEPProfiles(ctx); err != nil { return nil, ctxerr.Wrap(ctx, err, "sync DEP profiles") } diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go new file mode 100644 index 0000000000..4e83e3c0fe --- /dev/null +++ b/server/service/integration_mdm_dep_test.go @@ -0,0 +1,1083 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" + "github.com/fleetdm/fleet/v4/server/datastore/mysql" + "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/worker" + kitlog "github.com/go-kit/log" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/stretchr/testify/require" +) + +type profileAssignmentReq struct { + ProfileUUID string `json:"profile_uuid"` + Devices []string `json:"devices"` +} + +func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceGlobal() { + t := s.T() + ctx := context.Background() + + globalDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} + + // set an enroll secret, the Fleetd configuration profile will be installed + // on the host + enrollSecret := "test-release-dep-device" + err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}}) + require.NoError(t, err) + + // add a valid bootstrap package + b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg")) + require.NoError(t, err) + signedPkg := b + s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: 0}, http.StatusOK, "") + + // add a custom setup assistant and ensure enable_release_device_manually is + // false (the default) + noTeamProf := `{"x": 1}` + s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: nil, + Name: "no-team", + EnrollmentProfile: json.RawMessage(noTeamProf), + }, http.StatusOK) + payload := map[string]any{ + "enable_release_device_manually": false, + } + s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent) + + // setup IdP so that AccountConfiguration profile is sent after DEP enrollment + var acResp appConfigResponse + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { + "end_user_authentication": { + "entity_id": "https://localhost:8080", + "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", + "idp_name": "SimpleSAML", + "metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php" + }, + "macos_setup": { + "enable_end_user_authentication": true + } + } + }`), http.StatusOK, &acResp) + require.NotEmpty(t, acResp.MDM.EndUserAuthentication) + + // TODO(mna): how/where to pass an enroll_reference so that + // runPostDEPEnrollment sends an AccountConfiguration command? + + // add a global profile + globalProfile := mobileconfigForTest("N1", "I1") + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent) + + for _, enableReleaseManually := range []bool{false, true} { + t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) { + s.runDEPEnrollReleaseDeviceTest(t, globalDevice, enableReleaseManually, nil, "I1") + }) + } +} + +func (s *integrationMDMTestSuite) TestDEPEnrollReleaseDeviceTeam() { + t := s.T() + ctx := context.Background() + + teamDevice := godep.Device{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"} + + tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test-team-device-release"}) + require.NoError(t, err) + + // set an enroll secret, the Fleetd configuration profile will be installed + // on the host + enrollSecret := "test-release-dep-device-team" + err = s.ds.ApplyEnrollSecrets(ctx, &tm.ID, []*fleet.EnrollSecret{{Secret: enrollSecret}}) + require.NoError(t, err) + + // add a valid bootstrap package + b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg")) + require.NoError(t, err) + signedPkg := b + s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: tm.ID}, http.StatusOK, "") + + // add a custom setup assistant and ensure enable_release_device_manually is + // false (the default) + teamProf := `{"y": 2}` + s.Do("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ + TeamID: &tm.ID, + Name: "team", + EnrollmentProfile: json.RawMessage(teamProf), + }, http.StatusOK) + payload := map[string]any{ + "enable_release_device_manually": false, + } + s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent) + + // setup IdP so that AccountConfiguration profile is sent after DEP enrollment + var acResp appConfigResponse + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ + "mdm": { + "apple_bm_default_team": %q, + "end_user_authentication": { + "entity_id": "https://localhost:8080", + "issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php", + "idp_name": "SimpleSAML", + "metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php" + }, + "macos_setup": { + "enable_end_user_authentication": true + } + } + }`, tm.Name)), http.StatusOK, &acResp) + require.NotEmpty(t, acResp.MDM.EndUserAuthentication) + + // TODO(mna): how/where to pass an enroll_reference so that + // runPostDEPEnrollment sends an AccountConfiguration command? + + // add a team profile + teamProfile := mobileconfigForTest("N2", "I2") + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{teamProfile}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) + + for _, enableReleaseManually := range []bool{false, true} { + t.Run(fmt.Sprintf("enableReleaseManually=%t", enableReleaseManually), func(t *testing.T) { + s.runDEPEnrollReleaseDeviceTest(t, teamDevice, enableReleaseManually, &tm.ID, "I2") + }) + } +} + +func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, device godep.Device, enableReleaseManually bool, teamID *uint, customProfileIdent string) { + ctx := context.Background() + + // set the enable release device manually option + payload := map[string]any{ + "enable_release_device_manually": enableReleaseManually, + } + if teamID != nil { + payload["team_id"] = *teamID + } + s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, payload)), http.StatusNoContent) + + // query all hosts - none yet + listHostsRes := listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Empty(t, listHostsRes.Hosts) + + s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + return map[string]*push.Response{}, nil + } + + s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + err := encoder.Encode(map[string]string{"auth_session_token": "xyz"}) + require.NoError(t, err) + case "/profile": + err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}) + require.NoError(t, err) + case "/server/devices": + err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}}) + require.NoError(t, err) + case "/devices/sync": + // This endpoint is polled over time to sync devices from + // ABM, send a repeated serial and a new one + err := encoder.Encode(godep.DeviceResponse{Devices: []godep.Device{device}, Cursor: "foo"}) + require.NoError(t, err) + case "/profile/devices": + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var prof profileAssignmentReq + require.NoError(t, json.Unmarshal(b, &prof)) + + var resp godep.ProfileResponse + resp.ProfileUUID = prof.ProfileUUID + resp.Devices = make(map[string]string, len(prof.Devices)) + for _, device := range prof.Devices { + resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess) + } + err = encoder.Encode(resp) + require.NoError(t, err) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + + // trigger a profile sync + s.runDEPSchedule() + + listHostsRes = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 1) + require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, device.SerialNumber) + + t.Cleanup(func() { + // delete the enrolled host + err := s.ds.DeleteHost(ctx, listHostsRes.Hosts[0].ID) + require.NoError(t, err) + }) + + // enroll the host + depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) + mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) + mdmDevice.SerialNumber = device.SerialNumber + err := mdmDevice.Enroll() + require.NoError(t, err) + + // run the worker to process the DEP enroll request + s.runWorker() + // run the worker to assign configuration profiles + s.awaitTriggerProfileSchedule(t) + + var cmds []*micromdm.CommandPayload + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + // Can be useful for debugging + //switch cmd.Command.RequestType { + //case "InstallProfile": + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(cmd.Command.InstallProfile.Payload)) + //case "InstallEnterpriseApplication": + // if cmd.Command.InstallEnterpriseApplication.ManifestURL != nil { + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *cmd.Command.InstallEnterpriseApplication.ManifestURL) + // } else { + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + // } + //default: + // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + //} + cmds = append(cmds, cmd) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + // expected commands: install fleetd, install bootstrap, install profiles + // (custom one and fleetd configuration) (not expected: account + // configuration, since enrollment_reference not set) + require.Len(t, cmds, 4) + var installProfileCount, installEnterpriseCount, otherCount int + var profileCustomSeen, profileFleetdSeen bool + for _, cmd := range cmds { + switch cmd.Command.RequestType { + case "InstallProfile": + installProfileCount++ + if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", customProfileIdent)) { + profileCustomSeen = true + } else if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetdConfigPayloadIdentifier)) { + profileFleetdSeen = true + } + + case "InstallEnterpriseApplication": + installEnterpriseCount++ + default: + otherCount++ + } + } + require.Equal(t, 2, installProfileCount) + require.Equal(t, 2, installEnterpriseCount) + require.Equal(t, 0, otherCount) + require.True(t, profileCustomSeen) + require.True(t, profileFleetdSeen) + + if enableReleaseManually { + // get the worker's pending job from the future, there should not be any + // because it needs to be released manually + pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) + require.NoError(t, err) + require.Empty(t, pending) + } else { + // get the worker's pending job from the future, there should be a DEP + // release device task + pending, err := s.ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) + require.NoError(t, err) + require.Len(t, pending, 1) + releaseJob := pending[0] + require.Equal(t, 0, releaseJob.Retries) + require.Contains(t, string(*releaseJob.Args), worker.AppleMDMPostDEPReleaseDeviceTask) + + // update the job so that it can run immediately + releaseJob.NotBefore = time.Now().UTC().Add(-time.Minute) + _, err = s.ds.UpdateJob(ctx, releaseJob.ID, releaseJob) + require.NoError(t, err) + + // run the worker to process the DEP release + s.runWorker() + + // make the device process the commands, it should receive the + // DeviceConfigured one. + cmds = cmds[:0] + cmd, err = mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + cmds = append(cmds, cmd) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + require.Len(t, cmds, 1) + var deviceConfiguredCount int + for _, cmd := range cmds { + switch cmd.Command.RequestType { + case "DeviceConfigured": + deviceConfiguredCount++ + default: + otherCount++ + } + } + require.Equal(t, 1, deviceConfiguredCount) + require.Equal(t, 0, otherCount) + } +} + +func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { + t := s.T() + + ctx := context.Background() + devices := []godep.Device{ + {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}, + {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"}, + {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: ""}, + {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "modified"}, + } + + profileAssignmentReqs := []profileAssignmentReq{} + + // add global profiles + globalProfile := mobileconfigForTest("N1", "I1") + s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent) + + checkPostEnrollmentCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, shouldReceive bool) { + // run the worker to process the DEP enroll request + s.runWorker() + // run the worker to assign configuration profiles + s.awaitTriggerProfileSchedule(t) + + var fleetdCmd, installProfileCmd *micromdm.CommandPayload + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + if cmd.Command.RequestType == "InstallEnterpriseApplication" && + cmd.Command.InstallEnterpriseApplication.ManifestURL != nil && + strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) { + fleetdCmd = cmd + } else if cmd.Command.RequestType == "InstallProfile" { + installProfileCmd = cmd + } + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + if shouldReceive { + // received request to install fleetd + require.NotNil(t, fleetdCmd, "host didn't get a command to install fleetd") + require.NotNil(t, fleetdCmd.Command, "host didn't get a command to install fleetd") + + // received request to install the global configuration profile + require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles") + require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles") + } else { + require.Nil(t, fleetdCmd, "host got a command to install fleetd") + require.Nil(t, installProfileCmd, "host got a command to install profiles") + } + } + + checkAssignProfileRequests := func(serial string, profUUID *string) { + require.NotEmpty(t, profileAssignmentReqs) + require.Len(t, profileAssignmentReqs, 1) + require.Len(t, profileAssignmentReqs[0].Devices, 1) + require.Equal(t, serial, profileAssignmentReqs[0].Devices[0]) + if profUUID != nil { + require.Equal(t, *profUUID, profileAssignmentReqs[0].ProfileUUID) + } + } + + type hostDEPRow struct { + HostID uint `db:"host_id"` + ProfileUUID string `db:"profile_uuid"` + AssignProfileResponse string `db:"assign_profile_response"` + ResponseUpdatedAt time.Time `db:"response_updated_at"` + RetryJobID uint `db:"retry_job_id"` + } + checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow { + bySerial := make(map[string]hostDEPRow, len(deviceSerials)) + for _, deviceSerial := range deviceSerials { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var dest hostDEPRow + err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial) + require.NoError(t, err) + require.Equal(t, string(expectedStatus), dest.AssignProfileResponse) + bySerial[deviceSerial] = dest + return nil + }) + } + return bySerial + } + + checkPendingMacOSSetupAssistantJob := func(expectedTask string, expectedTeamID *uint, expectedSerials []string, expectedJobID uint) { + pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{}) + require.NoError(t, err) + require.Len(t, pending, 1) + require.Equal(t, "macos_setup_assistant", pending[0].Name) + require.NotNil(t, pending[0].Args) + var gotArgs struct { + Task string `json:"task"` + TeamID *uint `json:"team_id,omitempty"` + HostSerialNumbers []string `json:"host_serial_numbers,omitempty"` + } + require.NoError(t, json.Unmarshal(*pending[0].Args, &gotArgs)) + require.Equal(t, expectedTask, gotArgs.Task) + if expectedTeamID != nil { + require.NotNil(t, gotArgs.TeamID) + require.Equal(t, *expectedTeamID, *gotArgs.TeamID) + } else { + require.Nil(t, gotArgs.TeamID) + } + require.Equal(t, expectedSerials, gotArgs.HostSerialNumbers) + + if expectedJobID != 0 { + require.Equal(t, expectedJobID, pending[0].ID) + } + } + + checkNoJobsPending := func() { + pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{}) + require.NoError(t, err) + require.Empty(t, pending) + } + + expectNoJobID := ptr.Uint(0) // used when expect no retry job + checkHostCooldown := func(serial, profUUID string, status fleet.DEPAssignProfileResponseStatus, expectUpdatedAt *time.Time, expectRetryJobID *uint) hostDEPRow { + bySerial := checkHostDEPAssignProfileResponses([]string{serial}, profUUID, status) + d, ok := bySerial[serial] + require.True(t, ok) + if expectUpdatedAt != nil { + require.Equal(t, *expectUpdatedAt, d.ResponseUpdatedAt) + } + if expectRetryJobID != nil { + require.Equal(t, *expectRetryJobID, d.RetryJobID) + } + return d + } + + checkListHostDEPError := func(serial string, expectStatus string, expectError bool) *fleet.HostResponse { + listHostsRes := listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", serial), nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 1) + require.Equal(t, serial, listHostsRes.Hosts[0].HardwareSerial) + require.Equal(t, expectStatus, *listHostsRes.Hosts[0].MDM.EnrollmentStatus) + require.Equal(t, expectError, listHostsRes.Hosts[0].MDM.DEPProfileError) + + return &listHostsRes.Hosts[0] + } + + setAssignProfileResponseUpdatedAt := func(serial string, updatedAt time.Time) { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE host_dep_assignments SET response_updated_at = ? WHERE host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)`, updatedAt, serial) + return err + }) + } + + expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow + expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow + s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + encoder := json.NewEncoder(w) + switch r.URL.Path { + case "/session": + err := encoder.Encode(map[string]string{"auth_session_token": "xyz"}) + require.NoError(t, err) + case "/profile": + err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}) + require.NoError(t, err) + case "/server/devices": + // This endpoint is used to get an initial list of + // devices, return a single device + err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]}) + require.NoError(t, err) + case "/devices/sync": + // This endpoint is polled over time to sync devices from + // ABM, send a repeated serial and a new one + err := encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"}) + require.NoError(t, err) + case "/profile/devices": + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + var prof profileAssignmentReq + require.NoError(t, json.Unmarshal(b, &prof)) + profileAssignmentReqs = append(profileAssignmentReqs, prof) + var resp godep.ProfileResponse + resp.ProfileUUID = prof.ProfileUUID + resp.Devices = make(map[string]string, len(prof.Devices)) + for _, device := range prof.Devices { + switch device { + case expectAssignProfileResponseNotAccessible: + resp.Devices[device] = string(fleet.DEPAssignProfileResponseNotAccessible) + case expectAssignProfileResponseFailed: + resp.Devices[device] = string(fleet.DEPAssignProfileResponseFailed) + default: + resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess) + } + } + err = encoder.Encode(resp) + require.NoError(t, err) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + + // query all hosts + listHostsRes := listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Empty(t, listHostsRes.Hosts) + + // trigger a profile sync + s.runDEPSchedule() + + // all hosts should be returned from the hosts endpoint + listHostsRes = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, len(devices)) + var wantSerials []string + var gotSerials []string + for i, device := range devices { + wantSerials = append(wantSerials, device.SerialNumber) + gotSerials = append(gotSerials, listHostsRes.Hosts[i].HardwareSerial) + // entries for all hosts should be created in the host_dep_assignments table + _, err := s.ds.GetHostDEPAssignment(ctx, listHostsRes.Hosts[i].ID) + require.NoError(t, err) + } + require.ElementsMatch(t, wantSerials, gotSerials) + // called two times: + // - one when we get the initial list of devices (/server/devices) + // - one when we do the device sync (/device/sync) + require.Len(t, profileAssignmentReqs, 2) + require.Len(t, profileAssignmentReqs[0].Devices, 1) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + require.Len(t, profileAssignmentReqs[1].Devices, len(devices)) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + // record the default profile to be used in other tests + defaultProfileUUID := profileAssignmentReqs[1].ProfileUUID + + // create a new host + nonDEPHost := createHostAndDeviceToken(t, s.ds, "not-dep") + listHostsRes = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, len(devices)+1) + + // filtering by MDM status works + listHostsRes = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, len(devices)) + + // searching by display name works + listHostsRes = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", url.QueryEscape("MacBook Mini")), nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 3) + for _, host := range listHostsRes.Hosts { + require.Equal(t, "MacBook Mini", host.HardwareModel) + require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial)) + } + + s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { + return map[string]*push.Response{}, nil + } + + // Enroll one of the hosts + depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) + mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) + mdmDevice.SerialNumber = devices[0].SerialNumber + err := mdmDevice.Enroll() + require.NoError(t, err) + + // make sure the host gets post enrollment requests + checkPostEnrollmentCommands(mdmDevice, true) + + // only one shows up as pending + listHostsRes = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, len(devices)-1) + + activities := listActivitiesResponse{} + s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at") + found := false + for _, activity := range activities.Activities { + if activity.Type == "mdm_enrolled" && + strings.Contains(string(*activity.Details), devices[0].SerialNumber) { + found = true + require.Nil(t, activity.ActorID) + require.Nil(t, activity.ActorFullName) + require.JSONEq( + t, + fmt.Sprintf( + `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`, + devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber, + ), + string(*activity.Details), + ) + } + } + require.True(t, found) + + // add devices[1].SerialNumber to a team + teamName := t.Name() + "team1" + team := &fleet.Team{ + Name: teamName, + Description: "desc team1", + } + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + team = createTeamResp.Team + for _, h := range listHostsRes.Hosts { + if h.HardwareSerial == devices[1].SerialNumber { + err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID}) + require.NoError(t, err) + } + } + + // modify the response and trigger another sync to include: + // + // 1. A repeated device with "added" + // 2. A repeated device with "modified" + // 3. A device with "deleted" + // 4. A new device + deletedSerial := devices[2].SerialNumber + addedSerial := uuid.New().String() + devices = []godep.Device{ + {SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added"}, + {SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini", OS: "osx", OpType: "modified"}, + {SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "deleted"}, + {SerialNumber: addedSerial, Model: "MacBook Mini", OS: "osx", OpType: "added"}, + } + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + + // all hosts should be returned from the hosts endpoint + listHostsRes = listHostsResponse{} + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) + // all previous devices + the manually added host + the new `addedSerial` + wantSerials = append(wantSerials, devices[3].SerialNumber, nonDEPHost.HardwareSerial) + require.Len(t, listHostsRes.Hosts, len(wantSerials)) + gotSerials = []string{} + var deletedHostID uint + var addedHostID uint + var mdmDeviceID uint + for _, device := range listHostsRes.Hosts { + gotSerials = append(gotSerials, device.HardwareSerial) + switch device.HardwareSerial { + case deletedSerial: + deletedHostID = device.ID + case addedSerial: + addedHostID = device.ID + case mdmDevice.SerialNumber: + mdmDeviceID = device.ID + } + } + require.ElementsMatch(t, wantSerials, gotSerials) + require.Len(t, profileAssignmentReqs, 3) + + // first request to get a list of profiles + // TODO: seems like we're doing this request on each loop? + require.Len(t, profileAssignmentReqs[0].Devices, 1) + require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // profileAssignmentReqs[1] and [2] can be in any order + ix2Devices, ix1Device := 1, 2 + if len(profileAssignmentReqs[1].Devices) == 1 { + ix2Devices, ix1Device = ix1Device, ix2Devices + } + + // - existing device with "added" + // - new device with "added" + require.Len(t, profileAssignmentReqs[ix2Devices].Devices, 2, "%#+v", profileAssignmentReqs) + require.ElementsMatch(t, []string{devices[0].SerialNumber, addedSerial}, profileAssignmentReqs[ix2Devices].Devices) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix2Devices].Devices, profileAssignmentReqs[ix2Devices].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // - existing device with "modified" and a different team (thus different profile request) + require.Len(t, profileAssignmentReqs[ix1Device].Devices, 1) + require.Equal(t, devices[1].SerialNumber, profileAssignmentReqs[ix1Device].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix1Device].Devices, profileAssignmentReqs[ix1Device].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // entries for all hosts except for the one with OpType = "deleted" + assignment, err := s.ds.GetHostDEPAssignment(ctx, deletedHostID) + require.NoError(t, err) + require.NotZero(t, assignment.DeletedAt) + + _, err = s.ds.GetHostDEPAssignment(ctx, addedHostID) + require.NoError(t, err) + + // send a TokenUpdate command, it shouldn't re-send the post-enrollment commands + err = mdmDevice.TokenUpdate() + require.NoError(t, err) + checkPostEnrollmentCommands(mdmDevice, false) + + // enroll the device again, it should get the post-enrollment commands + err = mdmDevice.Enroll() + require.NoError(t, err) + checkPostEnrollmentCommands(mdmDevice, true) + + // delete the device from Fleet + var delResp deleteHostResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmDeviceID), nil, http.StatusOK, &delResp) + + // the device comes back as pending + listHostsRes = listHostsResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", mdmDevice.UUID), nil, http.StatusOK, &listHostsRes) + require.Len(t, listHostsRes.Hosts, 1) + require.Equal(t, mdmDevice.SerialNumber, listHostsRes.Hosts[0].HardwareSerial) + + // we assign a DEP profile to the device + profileAssignmentReqs = []profileAssignmentReq{} + s.runWorker() + require.Equal(t, mdmDevice.SerialNumber, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // it should get the post-enrollment commands + require.NoError(t, mdmDevice.Enroll()) + checkPostEnrollmentCommands(mdmDevice, true) + + // delete all MDM info + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, listHostsRes.Hosts[0].ID) + return err + }) + + // it should still get the post-enrollment commands + require.NoError(t, mdmDevice.Enroll()) + checkPostEnrollmentCommands(mdmDevice, true) + + // The user unenrolls from Fleet (e.g. was DEP enrolled but with `is_mdm_removable: true` + // so the user removes the enrollment profile). + err = mdmDevice.Checkout() + require.NoError(t, err) + + // Simulate a refetch where we clean up the MDM data since the host is not enrolled anymore + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, mdmDeviceID) + return err + }) + + // Simulate fleetd re-enrolling automatically. + err = mdmDevice.Enroll() + require.NoError(t, err) + + // The last activity should have `installed_from_dep=true`. + s.lastActivityMatches( + "mdm_enrolled", + fmt.Sprintf( + `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`, + mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber, + ), + 0, + ) + + // enroll a host into Fleet + eHost, err := s.ds.NewHost(context.Background(), &fleet.Host{ + ID: 1, + OsqueryHostID: ptr.String("Desktop-ABCQWE"), + NodeKey: ptr.String("Desktop-ABCQWE"), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%sfoo.local", s.T().Name()), + Platform: "darwin", + HardwareSerial: uuid.New().String(), + }) + require.NoError(t, err) + + // on team transfer, we don't assign a DEP profile to the device + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) + profileAssignmentReqs = []profileAssignmentReq{} + s.runWorker() + require.Empty(t, profileAssignmentReqs) + + // assign the host in ABM + devices = []godep.Device{ + {SerialNumber: eHost.HardwareSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified"}, + } + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.NotEmpty(t, profileAssignmentReqs) + require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // report MDM info via osquery + require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, eHost.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "")) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) + + // transfer to "no team", we assign a DEP profile to the device + profileAssignmentReqs = []profileAssignmentReq{} + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) + s.runWorker() + require.NotEmpty(t, profileAssignmentReqs) + require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) + + // transfer to the team back again, we assign a DEP profile to the device again + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) + profileAssignmentReqs = []profileAssignmentReq{} + s.runWorker() + require.NotEmpty(t, profileAssignmentReqs) + require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) + + // transfer to "no team", but simulate a failed profile assignment + expectAssignProfileResponseFailed = eHost.HardwareSerial + profileAssignmentReqs = []profileAssignmentReq{} + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0) + + s.runIntegrationsSchedule() + checkAssignProfileRequests(eHost.HardwareSerial, nil) + profUUID := profileAssignmentReqs[0].ProfileUUID + d := checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID) + require.NotZero(t, d.ResponseUpdatedAt) + failedAt := d.ResponseUpdatedAt + checkNoJobsPending() + // list hosts shows dep profile error + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) + + // run the integrations schedule during the cooldown period + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // no new request during cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // create a new team + var tmResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name() + "dummy", + Description: "desc dummy", + }, http.StatusOK, &tmResp) + require.NotZero(t, createTeamResp.Team.ID) + dummyTeam := tmResp.Team + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + + // expect no assign profile request during cooldown + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // cooldown hosts are screened from update profile jobs that would assign profiles + _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantUpdateProfile, &dummyTeam.ID, eHost.HardwareSerial) + require.NoError(t, err) + checkPendingMacOSSetupAssistantJob("update_profile", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // cooldown hosts are screened from delete profile jobs that would assign profiles + _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantProfileDeleted, &dummyTeam.ID, eHost.HardwareSerial) + require.NoError(t, err) + checkPendingMacOSSetupAssistantJob("profile_deleted", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // // TODO: Restore this test when FIXME on DeleteTeam is addressed + // s.Do("DELETE", fmt.Sprintf("/api/v1/fleet/teams/%d", dummyTeam.ID), nil, http.StatusOK) + // checkPendingMacOSSetupAssistantJob("team_deleted", nil, []string{eHost.HardwareSerial}, 0) + // s.runIntegrationsSchedule() + // require.Empty(t, profileAssignmentReqs) // screened for cooldown + // bySerial = checkHostDEPAssignProfileResponses([]string{eHost.HardwareSerial}, profUUID, fleet.DEPAssignProfileResponseFailed) + // d, ok = bySerial[eHost.HardwareSerial] + // require.True(t, ok) + // require.Equal(t, failedAt, d.ResponseUpdatedAt) + // require.Zero(t, d.RetryJobID) // cooling down so no retry job + // checkNoJobsPending() + + // transfer back to no team, expect no assign profile request during cooldown + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened for cooldown + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // simulate expired cooldown + failedAt = failedAt.Add(-2 * time.Hour) + setAssignProfileResponseUpdatedAt(eHost.HardwareSerial, failedAt) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run + d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil) + require.NotZero(t, d.RetryJobID) // retry job created + jobID := d.RetryJobID + checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) + + // running the DEP schedule should not trigger a profile assignment request when the retry job is pending + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, &jobID) // no change + checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) + + // run the inregration schedule and expect success + expectAssignProfileResponseFailed = "" + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + checkAssignProfileRequests(eHost.HardwareSerial, &profUUID) + d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared + require.True(t, d.ResponseUpdatedAt.After(failedAt)) + succeededAt := d.ResponseUpdatedAt + checkNoJobsPending() + checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) + + // run the integrations schedule and expect no changes + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) + checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, &succeededAt, expectNoJobID) // no change + checkNoJobsPending() + + // ingest new device via DEP but the profile assignment fails + serial := uuid.NewString() + devices = []godep.Device{ + {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"}, + } + expectAssignProfileResponseFailed = serial + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + checkAssignProfileRequests(serial, nil) + profUUID = profileAssignmentReqs[0].ProfileUUID + d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID) + require.NotZero(t, d.ResponseUpdatedAt) + failedAt = d.ResponseUpdatedAt + checkNoJobsPending() + h := checkListHostDEPError(serial, "Pending", true) // list hosts shows device pending and dep profile error + + // transfer to team, no profile assignment request is made during the cooldown period + profileAssignmentReqs = []profileAssignmentReq{} + s.Do("POST", "/api/v1/fleet/hosts/transfer", + addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{h.ID}}, http.StatusOK) + checkPendingMacOSSetupAssistantJob("hosts_transferred", &team.ID, []string{serial}, 0) + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // screened by cooldown + checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // run the integrations schedule and expect no changes + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) + checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // simulate expired cooldown + failedAt = failedAt.Add(-2 * time.Hour) + setAssignProfileResponseUpdatedAt(serial, failedAt) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run + d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil) + require.NotZero(t, d.RetryJobID) // retry job created + jobID = d.RetryJobID + checkPendingMacOSSetupAssistantJob("hosts_cooldown", &team.ID, []string{serial}, jobID) + + // run the inregration schedule and expect success + expectAssignProfileResponseFailed = "" + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + checkAssignProfileRequests(serial, nil) + require.NotEqual(t, profUUID, profileAssignmentReqs[0].ProfileUUID) // retry job will use the current team profile instead + profUUID = profileAssignmentReqs[0].ProfileUUID + d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared + require.True(t, d.ResponseUpdatedAt.After(failedAt)) + checkNoJobsPending() + // list hosts shows pending (because MDM detail query hasn't been reported) but dep profile + // error has been cleared + checkListHostDEPError(serial, "Pending", false) + + // ingest another device via DEP but the profile assignment is not accessible + serial = uuid.NewString() + devices = []godep.Device{ + {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"}, + } + expectAssignProfileResponseNotAccessible = serial + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.Len(t, profileAssignmentReqs, 2) // FIXME: When new device is added in ABM, we see two profile assign requests when device is not accessible: first during the "fetch" phase, then during the "sync" phase + expectProfileUUID := "" + for _, req := range profileAssignmentReqs { + require.Len(t, req.Devices, 1) + require.Equal(t, serial, req.Devices[0]) + if expectProfileUUID == "" { + expectProfileUUID = req.ProfileUUID + } else { + require.Equal(t, expectProfileUUID, req.ProfileUUID) + } + d := checkHostCooldown(serial, req.ProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, nil, expectNoJobID) // not accessible responses aren't retried + require.NotZero(t, d.ResponseUpdatedAt) + failedAt = d.ResponseUpdatedAt + } + // list hosts shows device pending and no dep profile error for not accessible responses + checkListHostDEPError(serial, "Pending", false) + + // no retry job for not accessible responses even if cooldown expires + failedAt = failedAt.Add(-2 * time.Hour) + setAssignProfileResponseUpdatedAt(serial, failedAt) + profileAssignmentReqs = []profileAssignmentReq{} + s.runIntegrationsSchedule() + require.Empty(t, profileAssignmentReqs) + checkHostCooldown(serial, expectProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, &failedAt, expectNoJobID) // no change + checkNoJobsPending() + + // run with devices that already have valid and invalid profiles + // assigned, we shouldn't re-assign the valid ones. + devices = []godep.Device{ + {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile + {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile + {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: "bar"}, // doesn't match an existing profile + {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: "foo"}, // doesn't match an existing profile + {SerialNumber: addedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile + {SerialNumber: serial, Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile + } + expectAssignProfileResponseNotAccessible = "" + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.NotEmpty(t, profileAssignmentReqs) + require.Len(t, profileAssignmentReqs[0].Devices, 2) + require.ElementsMatch(t, []string{devices[2].SerialNumber, devices[3].SerialNumber}, profileAssignmentReqs[0].Devices) + checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) + + // run with only a device that already has the right profile, no errors and no assignments + devices = []godep.Device{ + {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile + } + profileAssignmentReqs = []profileAssignmentReq{} + s.runDEPSchedule() + require.Empty(t, profileAssignmentReqs) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 7d0e43310f..39d322cba5 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -116,29 +116,37 @@ func (s *integrationMDMTestSuite) SetupSuite() { scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM) require.NoError(s.T(), err) + pushLog := kitlog.NewJSONLogger(os.Stdout) + if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { + pushLog = kitlog.NewNopLogger() + } pushFactory, pushProvider := newMockAPNSPushProviderFactory() mdmPushService := nanomdm_pushsvc.New( mdmStorage, mdmStorage, pushFactory, - NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)), + NewNanoMDMLogger(pushLog), ) mdmCommander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false) s.withServer.lq = live_query_mock.New(s.T()) + wlog := kitlog.NewJSONLogger(os.Stdout) + if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { + wlog = kitlog.NewNopLogger() + } macosJob := &worker.MacosSetupAssistant{ Datastore: s.ds, - Log: kitlog.NewJSONLogger(os.Stdout), - DEPService: apple_mdm.NewDEPService(s.ds, depStorage, kitlog.NewJSONLogger(os.Stdout)), - DEPClient: apple_mdm.NewDEPClient(depStorage, s.ds, kitlog.NewJSONLogger(os.Stdout)), + Log: wlog, + DEPService: apple_mdm.NewDEPService(s.ds, depStorage, wlog), + DEPClient: apple_mdm.NewDEPClient(depStorage, s.ds, wlog), } appleMDMJob := &worker.AppleMDM{ Datastore: s.ds, - Log: kitlog.NewJSONLogger(os.Stdout), + Log: wlog, Commander: mdmCommander, } - workr := worker.NewWorker(s.ds, kitlog.NewJSONLogger(os.Stdout)) + workr := worker.NewWorker(s.ds, wlog) workr.TestIgnoreUnknownJobs = true workr.Register(macosJob, appleMDMJob) s.worker = workr @@ -146,6 +154,10 @@ func (s *integrationMDMTestSuite) SetupSuite() { var depSchedule *schedule.Schedule var integrationsSchedule *schedule.Schedule var profileSchedule *schedule.Schedule + cronLog := kitlog.NewJSONLogger(os.Stdout) + if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { + cronLog = kitlog.NewNopLogger() + } config := TestServerOpts{ License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, @@ -161,7 +173,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { return func() (fleet.CronSchedule, error) { const name = string(fleet.CronAppleMDMDEPProfileAssigner) - logger := kitlog.NewJSONLogger(os.Stdout) + logger := cronLog fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger) depSchedule = schedule.New( ctx, name, s.T().Name(), 1*time.Hour, ds, ds, @@ -181,7 +193,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { return func() (fleet.CronSchedule, error) { const name = string(fleet.CronMDMAppleProfileManager) - logger := kitlog.NewJSONLogger(os.Stdout) + logger := cronLog profileSchedule = schedule.New( ctx, name, s.T().Name(), 1*time.Hour, ds, ds, schedule.WithLogger(logger), @@ -208,7 +220,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc { return func() (fleet.CronSchedule, error) { const name = string(fleet.CronWorkerIntegrations) - logger := kitlog.NewJSONLogger(os.Stdout) + logger := cronLog integrationsSchedule = schedule.New( ctx, name, s.T().Name(), 1*time.Minute, ds, ds, schedule.WithLogger(logger), @@ -288,6 +300,8 @@ func (s *integrationMDMTestSuite) TearDownTest() { appCfg.MDM.WindowsEnabledAndConfigured = true // ensure global disk encryption is disabled on exit appCfg.MDM.EnableDiskEncryption = optjson.SetBool(false) + // ensure enable release manually is false + appCfg.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(false) // ensure global Windows OS updates are always disabled for the next test appCfg.MDM.WindowsUpdates = fleet.WindowsUpdates{} err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig) @@ -305,6 +319,10 @@ func (s *integrationMDMTestSuite) TearDownTest() { _, err := q.ExecContext(ctx, "DELETE FROM mdm_windows_configuration_profiles") return err }) + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, "DELETE FROM mdm_apple_bootstrap_packages") + return err + }) // clear any pending worker job mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -1981,745 +1999,6 @@ func createWindowsHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t return host, mdmDevice } -func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { - t := s.T() - - ctx := context.Background() - devices := []godep.Device{ - {SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"}, - {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"}, - {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: ""}, - {SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "modified"}, - } - - type profileAssignmentReq struct { - ProfileUUID string `json:"profile_uuid"` - Devices []string `json:"devices"` - } - profileAssignmentReqs := []profileAssignmentReq{} - - // add global profiles - globalProfile := mobileconfigForTest("N1", "I1") - s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{globalProfile}}, http.StatusNoContent) - - checkPostEnrollmentCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, shouldReceive bool) { - // run the worker to process the DEP enroll request - s.runWorker() - // run the worker to assign configuration profiles - s.awaitTriggerProfileSchedule(t) - - var fleetdCmd, installProfileCmd *micromdm.CommandPayload - cmd, err := mdmDevice.Idle() - require.NoError(t, err) - for cmd != nil { - if cmd.Command.RequestType == "InstallEnterpriseApplication" && - cmd.Command.InstallEnterpriseApplication.ManifestURL != nil && - strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) { - fleetdCmd = cmd - } else if cmd.Command.RequestType == "InstallProfile" { - installProfileCmd = cmd - } - cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) - require.NoError(t, err) - } - - if shouldReceive { - // received request to install fleetd - require.NotNil(t, fleetdCmd, "host didn't get a command to install fleetd") - require.NotNil(t, fleetdCmd.Command, "host didn't get a command to install fleetd") - - // received request to install the global configuration profile - require.NotNil(t, installProfileCmd, "host didn't get a command to install profiles") - require.NotNil(t, installProfileCmd.Command, "host didn't get a command to install profiles") - } else { - require.Nil(t, fleetdCmd, "host got a command to install fleetd") - require.Nil(t, installProfileCmd, "host got a command to install profiles") - } - } - - checkAssignProfileRequests := func(serial string, profUUID *string) { - require.NotEmpty(t, profileAssignmentReqs) - require.Len(t, profileAssignmentReqs, 1) - require.Len(t, profileAssignmentReqs[0].Devices, 1) - require.Equal(t, serial, profileAssignmentReqs[0].Devices[0]) - if profUUID != nil { - require.Equal(t, *profUUID, profileAssignmentReqs[0].ProfileUUID) - } - } - - type hostDEPRow struct { - HostID uint `db:"host_id"` - ProfileUUID string `db:"profile_uuid"` - AssignProfileResponse string `db:"assign_profile_response"` - ResponseUpdatedAt time.Time `db:"response_updated_at"` - RetryJobID uint `db:"retry_job_id"` - } - checkHostDEPAssignProfileResponses := func(deviceSerials []string, expectedProfileUUID string, expectedStatus fleet.DEPAssignProfileResponseStatus) map[string]hostDEPRow { - bySerial := make(map[string]hostDEPRow, len(deviceSerials)) - for _, deviceSerial := range deviceSerials { - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - var dest hostDEPRow - err := sqlx.GetContext(ctx, q, &dest, "SELECT host_id, assign_profile_response, profile_uuid, response_updated_at, retry_job_id FROM host_dep_assignments WHERE profile_uuid = ? AND host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)", expectedProfileUUID, deviceSerial) - require.NoError(t, err) - require.Equal(t, string(expectedStatus), dest.AssignProfileResponse) - bySerial[deviceSerial] = dest - return nil - }) - } - return bySerial - } - - checkPendingMacOSSetupAssistantJob := func(expectedTask string, expectedTeamID *uint, expectedSerials []string, expectedJobID uint) { - pending, err := s.ds.GetQueuedJobs(context.Background(), 1) - require.NoError(t, err) - require.Len(t, pending, 1) - require.Equal(t, "macos_setup_assistant", pending[0].Name) - require.NotNil(t, pending[0].Args) - var gotArgs struct { - Task string `json:"task"` - TeamID *uint `json:"team_id,omitempty"` - HostSerialNumbers []string `json:"host_serial_numbers,omitempty"` - } - require.NoError(t, json.Unmarshal(*pending[0].Args, &gotArgs)) - require.Equal(t, expectedTask, gotArgs.Task) - if expectedTeamID != nil { - require.NotNil(t, gotArgs.TeamID) - require.Equal(t, *expectedTeamID, *gotArgs.TeamID) - } else { - require.Nil(t, gotArgs.TeamID) - } - require.Equal(t, expectedSerials, gotArgs.HostSerialNumbers) - - if expectedJobID != 0 { - require.Equal(t, expectedJobID, pending[0].ID) - } - } - - checkNoJobsPending := func() { - pending, err := s.ds.GetQueuedJobs(context.Background(), 1) - require.NoError(t, err) - require.Empty(t, pending) - } - - expectNoJobID := ptr.Uint(0) // used when expect no retry job - checkHostCooldown := func(serial, profUUID string, status fleet.DEPAssignProfileResponseStatus, expectUpdatedAt *time.Time, expectRetryJobID *uint) hostDEPRow { - bySerial := checkHostDEPAssignProfileResponses([]string{serial}, profUUID, status) - d, ok := bySerial[serial] - require.True(t, ok) - if expectUpdatedAt != nil { - require.Equal(t, *expectUpdatedAt, d.ResponseUpdatedAt) - } - if expectRetryJobID != nil { - require.Equal(t, *expectRetryJobID, d.RetryJobID) - } - return d - } - - checkListHostDEPError := func(serial string, expectStatus string, expectError bool) *fleet.HostResponse { - listHostsRes := listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", serial), nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, 1) - require.Equal(t, serial, listHostsRes.Hosts[0].HardwareSerial) - require.Equal(t, expectStatus, *listHostsRes.Hosts[0].MDM.EnrollmentStatus) - require.Equal(t, expectError, listHostsRes.Hosts[0].MDM.DEPProfileError) - - return &listHostsRes.Hosts[0] - } - - setAssignProfileResponseUpdatedAt := func(serial string, updatedAt time.Time) { - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `UPDATE host_dep_assignments SET response_updated_at = ? WHERE host_id = (SELECT id FROM hosts WHERE hardware_serial = ?)`, updatedAt, serial) - return err - }) - } - - expectAssignProfileResponseFailed := "" // set to device serial when testing the failed profile assignment flow - expectAssignProfileResponseNotAccessible := "" // set to device serial when testing the not accessible profile assignment flow - s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - encoder := json.NewEncoder(w) - switch r.URL.Path { - case "/session": - err := encoder.Encode(map[string]string{"auth_session_token": "xyz"}) - require.NoError(t, err) - case "/profile": - err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}) - require.NoError(t, err) - case "/server/devices": - // This endpoint is used to get an initial list of - // devices, return a single device - err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]}) - require.NoError(t, err) - case "/devices/sync": - // This endpoint is polled over time to sync devices from - // ABM, send a repeated serial and a new one - err := encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"}) - require.NoError(t, err) - case "/profile/devices": - b, err := io.ReadAll(r.Body) - require.NoError(t, err) - var prof profileAssignmentReq - require.NoError(t, json.Unmarshal(b, &prof)) - profileAssignmentReqs = append(profileAssignmentReqs, prof) - var resp godep.ProfileResponse - resp.ProfileUUID = prof.ProfileUUID - resp.Devices = make(map[string]string, len(prof.Devices)) - for _, device := range prof.Devices { - switch device { - case expectAssignProfileResponseNotAccessible: - resp.Devices[device] = string(fleet.DEPAssignProfileResponseNotAccessible) - case expectAssignProfileResponseFailed: - resp.Devices[device] = string(fleet.DEPAssignProfileResponseFailed) - default: - resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess) - } - } - err = encoder.Encode(resp) - require.NoError(t, err) - default: - _, _ = w.Write([]byte(`{}`)) - } - })) - - // query all hosts - listHostsRes := listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) - require.Empty(t, listHostsRes.Hosts) - - // trigger a profile sync - s.runDEPSchedule() - - // all hosts should be returned from the hosts endpoint - listHostsRes = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, len(devices)) - var wantSerials []string - var gotSerials []string - for i, device := range devices { - wantSerials = append(wantSerials, device.SerialNumber) - gotSerials = append(gotSerials, listHostsRes.Hosts[i].HardwareSerial) - // entries for all hosts should be created in the host_dep_assignments table - _, err := s.ds.GetHostDEPAssignment(ctx, listHostsRes.Hosts[i].ID) - require.NoError(t, err) - } - require.ElementsMatch(t, wantSerials, gotSerials) - // called two times: - // - one when we get the initial list of devices (/server/devices) - // - one when we do the device sync (/device/sync) - require.Len(t, profileAssignmentReqs, 2) - require.Len(t, profileAssignmentReqs[0].Devices, 1) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - require.Len(t, profileAssignmentReqs[1].Devices, len(devices)) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[1].Devices, profileAssignmentReqs[1].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - // record the default profile to be used in other tests - defaultProfileUUID := profileAssignmentReqs[1].ProfileUUID - - // create a new host - nonDEPHost := createHostAndDeviceToken(t, s.ds, "not-dep") - listHostsRes = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, len(devices)+1) - - // filtering by MDM status works - listHostsRes = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, len(devices)) - - // searching by display name works - listHostsRes = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", url.QueryEscape("MacBook Mini")), nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, 3) - for _, host := range listHostsRes.Hosts { - require.Equal(t, "MacBook Mini", host.HardwareModel) - require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial)) - } - - s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) { - return map[string]*push.Response{}, nil - } - - // Enroll one of the hosts - depURLToken := loadEnrollmentProfileDEPToken(t, s.ds) - mdmDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken) - mdmDevice.SerialNumber = devices[0].SerialNumber - err := mdmDevice.Enroll() - require.NoError(t, err) - - // make sure the host gets post enrollment requests - checkPostEnrollmentCommands(mdmDevice, true) - - // only one shows up as pending - listHostsRes = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, len(devices)-1) - - activities := listActivitiesResponse{} - s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at") - found := false - for _, activity := range activities.Activities { - if activity.Type == "mdm_enrolled" && - strings.Contains(string(*activity.Details), devices[0].SerialNumber) { - found = true - require.Nil(t, activity.ActorID) - require.Nil(t, activity.ActorFullName) - require.JSONEq( - t, - fmt.Sprintf( - `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`, - devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber, - ), - string(*activity.Details), - ) - } - } - require.True(t, found) - - // add devices[1].SerialNumber to a team - teamName := t.Name() + "team1" - team := &fleet.Team{ - Name: teamName, - Description: "desc team1", - } - var createTeamResp teamResponse - s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) - require.NotZero(t, createTeamResp.Team.ID) - team = createTeamResp.Team - for _, h := range listHostsRes.Hosts { - if h.HardwareSerial == devices[1].SerialNumber { - err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{h.ID}) - require.NoError(t, err) - } - } - - // modify the response and trigger another sync to include: - // - // 1. A repeated device with "added" - // 2. A repeated device with "modified" - // 3. A device with "deleted" - // 4. A new device - deletedSerial := devices[2].SerialNumber - addedSerial := uuid.New().String() - devices = []godep.Device{ - {SerialNumber: devices[0].SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added"}, - {SerialNumber: devices[1].SerialNumber, Model: "MacBook Mini", OS: "osx", OpType: "modified"}, - {SerialNumber: deletedSerial, Model: "MacBook Mini", OS: "osx", OpType: "deleted"}, - {SerialNumber: addedSerial, Model: "MacBook Mini", OS: "osx", OpType: "added"}, - } - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - - // all hosts should be returned from the hosts endpoint - listHostsRes = listHostsResponse{} - s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) - // all previous devices + the manually added host + the new `addedSerial` - wantSerials = append(wantSerials, devices[3].SerialNumber, nonDEPHost.HardwareSerial) - require.Len(t, listHostsRes.Hosts, len(wantSerials)) - gotSerials = []string{} - var deletedHostID uint - var addedHostID uint - var mdmDeviceID uint - for _, device := range listHostsRes.Hosts { - gotSerials = append(gotSerials, device.HardwareSerial) - switch device.HardwareSerial { - case deletedSerial: - deletedHostID = device.ID - case addedSerial: - addedHostID = device.ID - case mdmDevice.SerialNumber: - mdmDeviceID = device.ID - } - } - require.ElementsMatch(t, wantSerials, gotSerials) - require.Len(t, profileAssignmentReqs, 3) - - // first request to get a list of profiles - // TODO: seems like we're doing this request on each loop? - require.Len(t, profileAssignmentReqs[0].Devices, 1) - require.Equal(t, devices[0].SerialNumber, profileAssignmentReqs[0].Devices[0]) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - - // profileAssignmentReqs[1] and [2] can be in any order - ix2Devices, ix1Device := 1, 2 - if len(profileAssignmentReqs[1].Devices) == 1 { - ix2Devices, ix1Device = ix1Device, ix2Devices - } - - // - existing device with "added" - // - new device with "added" - require.Len(t, profileAssignmentReqs[ix2Devices].Devices, 2, "%#+v", profileAssignmentReqs) - require.ElementsMatch(t, []string{devices[0].SerialNumber, addedSerial}, profileAssignmentReqs[ix2Devices].Devices) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix2Devices].Devices, profileAssignmentReqs[ix2Devices].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - - // - existing device with "modified" and a different team (thus different profile request) - require.Len(t, profileAssignmentReqs[ix1Device].Devices, 1) - require.Equal(t, devices[1].SerialNumber, profileAssignmentReqs[ix1Device].Devices[0]) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[ix1Device].Devices, profileAssignmentReqs[ix1Device].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - - // entries for all hosts except for the one with OpType = "deleted" - assignment, err := s.ds.GetHostDEPAssignment(ctx, deletedHostID) - require.NoError(t, err) - require.NotZero(t, assignment.DeletedAt) - - _, err = s.ds.GetHostDEPAssignment(ctx, addedHostID) - require.NoError(t, err) - - // send a TokenUpdate command, it shouldn't re-send the post-enrollment commands - err = mdmDevice.TokenUpdate() - require.NoError(t, err) - checkPostEnrollmentCommands(mdmDevice, false) - - // enroll the device again, it should get the post-enrollment commands - err = mdmDevice.Enroll() - require.NoError(t, err) - checkPostEnrollmentCommands(mdmDevice, true) - - // delete the device from Fleet - var delResp deleteHostResponse - s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d", mdmDeviceID), nil, http.StatusOK, &delResp) - - // the device comes back as pending - listHostsRes = listHostsResponse{} - s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts?query=%s", mdmDevice.UUID), nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, 1) - require.Equal(t, mdmDevice.SerialNumber, listHostsRes.Hosts[0].HardwareSerial) - - // we assign a DEP profile to the device - profileAssignmentReqs = []profileAssignmentReq{} - s.runWorker() - require.Equal(t, mdmDevice.SerialNumber, profileAssignmentReqs[0].Devices[0]) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - - // it should get the post-enrollment commands - require.NoError(t, mdmDevice.Enroll()) - checkPostEnrollmentCommands(mdmDevice, true) - - // delete all MDM info - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, listHostsRes.Hosts[0].ID) - return err - }) - - // it should still get the post-enrollment commands - require.NoError(t, mdmDevice.Enroll()) - checkPostEnrollmentCommands(mdmDevice, true) - - // The user unenrolls from Fleet (e.g. was DEP enrolled but with `is_mdm_removable: true` - // so the user removes the enrollment profile). - err = mdmDevice.Checkout() - require.NoError(t, err) - - // Simulate a refetch where we clean up the MDM data since the host is not enrolled anymore - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `DELETE FROM host_mdm WHERE host_id = ?`, mdmDeviceID) - return err - }) - - // Simulate fleetd re-enrolling automatically. - err = mdmDevice.Enroll() - require.NoError(t, err) - - // The last activity should have `installed_from_dep=true`. - s.lastActivityMatches( - "mdm_enrolled", - fmt.Sprintf( - `{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true, "mdm_platform": "apple"}`, - mdmDevice.SerialNumber, mdmDevice.Model, mdmDevice.SerialNumber, - ), - 0, - ) - - // enroll a host into Fleet - eHost, err := s.ds.NewHost(context.Background(), &fleet.Host{ - ID: 1, - OsqueryHostID: ptr.String("Desktop-ABCQWE"), - NodeKey: ptr.String("Desktop-ABCQWE"), - UUID: uuid.New().String(), - Hostname: fmt.Sprintf("%sfoo.local", s.T().Name()), - Platform: "darwin", - HardwareSerial: uuid.New().String(), - }) - require.NoError(t, err) - - // on team transfer, we don't assign a DEP profile to the device - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) - profileAssignmentReqs = []profileAssignmentReq{} - s.runWorker() - require.Empty(t, profileAssignmentReqs) - - // assign the host in ABM - devices = []godep.Device{ - {SerialNumber: eHost.HardwareSerial, Model: "MacBook Pro", OS: "osx", OpType: "modified"}, - } - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - require.NotEmpty(t, profileAssignmentReqs) - require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - - // report MDM info via osquery - require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, eHost.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "")) - checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) - - // transfer to "no team", we assign a DEP profile to the device - profileAssignmentReqs = []profileAssignmentReq{} - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) - s.runWorker() - require.NotEmpty(t, profileAssignmentReqs) - require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) - - // transfer to the team back again, we assign a DEP profile to the device again - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) - profileAssignmentReqs = []profileAssignmentReq{} - s.runWorker() - require.NotEmpty(t, profileAssignmentReqs) - require.Equal(t, eHost.HardwareSerial, profileAssignmentReqs[0].Devices[0]) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) - - // transfer to "no team", but simulate a failed profile assignment - expectAssignProfileResponseFailed = eHost.HardwareSerial - profileAssignmentReqs = []profileAssignmentReq{} - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) - checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0) - - s.runIntegrationsSchedule() - checkAssignProfileRequests(eHost.HardwareSerial, nil) - profUUID := profileAssignmentReqs[0].ProfileUUID - d := checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID) - require.NotZero(t, d.ResponseUpdatedAt) - failedAt := d.ResponseUpdatedAt - checkNoJobsPending() - // list hosts shows dep profile error - checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) - - // run the integrations schedule during the cooldown period - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // no new request during cooldown - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // create a new team - var tmResp teamResponse - s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ - Name: t.Name() + "dummy", - Description: "desc dummy", - }, http.StatusOK, &tmResp) - require.NotZero(t, createTeamResp.Team.ID) - dummyTeam := tmResp.Team - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: &dummyTeam.ID, HostIDs: []uint{eHost.ID}}, http.StatusOK) - checkPendingMacOSSetupAssistantJob("hosts_transferred", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) - - // expect no assign profile request during cooldown - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // screened for cooldown - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // cooldown hosts are screened from update profile jobs that would assign profiles - _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantUpdateProfile, &dummyTeam.ID, eHost.HardwareSerial) - require.NoError(t, err) - checkPendingMacOSSetupAssistantJob("update_profile", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // screened for cooldown - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // cooldown hosts are screened from delete profile jobs that would assign profiles - _, err = worker.QueueMacosSetupAssistantJob(ctx, s.ds, kitlog.NewNopLogger(), worker.MacosSetupAssistantProfileDeleted, &dummyTeam.ID, eHost.HardwareSerial) - require.NoError(t, err) - checkPendingMacOSSetupAssistantJob("profile_deleted", &dummyTeam.ID, []string{eHost.HardwareSerial}, 0) - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // screened for cooldown - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // // TODO: Restore this test when FIXME on DeleteTeam is addressed - // s.Do("DELETE", fmt.Sprintf("/api/v1/fleet/teams/%d", dummyTeam.ID), nil, http.StatusOK) - // checkPendingMacOSSetupAssistantJob("team_deleted", nil, []string{eHost.HardwareSerial}, 0) - // s.runIntegrationsSchedule() - // require.Empty(t, profileAssignmentReqs) // screened for cooldown - // bySerial = checkHostDEPAssignProfileResponses([]string{eHost.HardwareSerial}, profUUID, fleet.DEPAssignProfileResponseFailed) - // d, ok = bySerial[eHost.HardwareSerial] - // require.True(t, ok) - // require.Equal(t, failedAt, d.ResponseUpdatedAt) - // require.Zero(t, d.RetryJobID) // cooling down so no retry job - // checkNoJobsPending() - - // transfer back to no team, expect no assign profile request during cooldown - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{eHost.ID}}, http.StatusOK) - checkPendingMacOSSetupAssistantJob("hosts_transferred", nil, []string{eHost.HardwareSerial}, 0) - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // screened for cooldown - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // simulate expired cooldown - failedAt = failedAt.Add(-2 * time.Hour) - setAssignProfileResponseUpdatedAt(eHost.HardwareSerial, failedAt) - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run - d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil) - require.NotZero(t, d.RetryJobID) // retry job created - jobID := d.RetryJobID - checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) - - // running the DEP schedule should not trigger a profile assignment request when the retry job is pending - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, &jobID) // no change - checkPendingMacOSSetupAssistantJob("hosts_cooldown", nil, []string{eHost.HardwareSerial}, jobID) - checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", true) - - // run the inregration schedule and expect success - expectAssignProfileResponseFailed = "" - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - checkAssignProfileRequests(eHost.HardwareSerial, &profUUID) - d = checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared - require.True(t, d.ResponseUpdatedAt.After(failedAt)) - succeededAt := d.ResponseUpdatedAt - checkNoJobsPending() - checkListHostDEPError(eHost.HardwareSerial, "On (automatic)", false) - - // run the integrations schedule and expect no changes - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) - checkHostCooldown(eHost.HardwareSerial, profUUID, fleet.DEPAssignProfileResponseSuccess, &succeededAt, expectNoJobID) // no change - checkNoJobsPending() - - // ingest new device via DEP but the profile assignment fails - serial := uuid.NewString() - devices = []godep.Device{ - {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"}, - } - expectAssignProfileResponseFailed = serial - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - checkAssignProfileRequests(serial, nil) - profUUID = profileAssignmentReqs[0].ProfileUUID - d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, nil, expectNoJobID) - require.NotZero(t, d.ResponseUpdatedAt) - failedAt = d.ResponseUpdatedAt - checkNoJobsPending() - h := checkListHostDEPError(serial, "Pending", true) // list hosts shows device pending and dep profile error - - // transfer to team, no profile assignment request is made during the cooldown period - profileAssignmentReqs = []profileAssignmentReq{} - s.Do("POST", "/api/v1/fleet/hosts/transfer", - addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{h.ID}}, http.StatusOK) - checkPendingMacOSSetupAssistantJob("hosts_transferred", &team.ID, []string{serial}, 0) - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // screened by cooldown - checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // run the integrations schedule and expect no changes - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) - checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // simulate expired cooldown - failedAt = failedAt.Add(-2 * time.Hour) - setAssignProfileResponseUpdatedAt(serial, failedAt) - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) // assign profile request will be made when the retry job is processed on the next worker run - d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseFailed, &failedAt, nil) - require.NotZero(t, d.RetryJobID) // retry job created - jobID = d.RetryJobID - checkPendingMacOSSetupAssistantJob("hosts_cooldown", &team.ID, []string{serial}, jobID) - - // run the inregration schedule and expect success - expectAssignProfileResponseFailed = "" - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - checkAssignProfileRequests(serial, nil) - require.NotEqual(t, profUUID, profileAssignmentReqs[0].ProfileUUID) // retry job will use the current team profile instead - profUUID = profileAssignmentReqs[0].ProfileUUID - d = checkHostCooldown(serial, profUUID, fleet.DEPAssignProfileResponseSuccess, nil, expectNoJobID) // retry job cleared - require.True(t, d.ResponseUpdatedAt.After(failedAt)) - checkNoJobsPending() - // list hosts shows pending (because MDM detail query hasn't been reported) but dep profile - // error has been cleared - checkListHostDEPError(serial, "Pending", false) - - // ingest another device via DEP but the profile assignment is not accessible - serial = uuid.NewString() - devices = []godep.Device{ - {SerialNumber: serial, Model: "MacBook Pro", OS: "osx", OpType: "added"}, - } - expectAssignProfileResponseNotAccessible = serial - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - require.Len(t, profileAssignmentReqs, 2) // FIXME: When new device is added in ABM, we see two profile assign requests when device is not accessible: first during the "fetch" phase, then during the "sync" phase - expectProfileUUID := "" - for _, req := range profileAssignmentReqs { - require.Len(t, req.Devices, 1) - require.Equal(t, serial, req.Devices[0]) - if expectProfileUUID == "" { - expectProfileUUID = req.ProfileUUID - } else { - require.Equal(t, expectProfileUUID, req.ProfileUUID) - } - d := checkHostCooldown(serial, req.ProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, nil, expectNoJobID) // not accessible responses aren't retried - require.NotZero(t, d.ResponseUpdatedAt) - failedAt = d.ResponseUpdatedAt - } - // list hosts shows device pending and no dep profile error for not accessible responses - checkListHostDEPError(serial, "Pending", false) - - // no retry job for not accessible responses even if cooldown expires - failedAt = failedAt.Add(-2 * time.Hour) - setAssignProfileResponseUpdatedAt(serial, failedAt) - profileAssignmentReqs = []profileAssignmentReq{} - s.runIntegrationsSchedule() - require.Empty(t, profileAssignmentReqs) - checkHostCooldown(serial, expectProfileUUID, fleet.DEPAssignProfileResponseNotAccessible, &failedAt, expectNoJobID) // no change - checkNoJobsPending() - - // run with devices that already have valid and invalid profiles - // assigned, we shouldn't re-assign the valid ones. - devices = []godep.Device{ - {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile - {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile - {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: "bar"}, // doesn't match an existing profile - {SerialNumber: uuid.NewString(), Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: "foo"}, // doesn't match an existing profile - {SerialNumber: addedSerial, Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile - {SerialNumber: serial, Model: "MacBook Mini", OS: "osx", OpType: "modified", ProfileUUID: defaultProfileUUID}, // matches existing profile - } - expectAssignProfileResponseNotAccessible = "" - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - require.NotEmpty(t, profileAssignmentReqs) - require.Len(t, profileAssignmentReqs[0].Devices, 2) - require.ElementsMatch(t, []string{devices[2].SerialNumber, devices[3].SerialNumber}, profileAssignmentReqs[0].Devices) - checkHostDEPAssignProfileResponses(profileAssignmentReqs[0].Devices, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess) - - // run with only a device that already has the right profile, no errors and no assignments - devices = []godep.Device{ - {SerialNumber: uuid.NewString(), Model: "MacBook Pro", OS: "osx", OpType: "added", ProfileUUID: defaultProfileUUID}, // matches existing profile - } - profileAssignmentReqs = []profileAssignmentReq{} - s.runDEPSchedule() - require.Empty(t, profileAssignmentReqs) -} - func loadEnrollmentProfileDEPToken(t *testing.T, ds *mysql.Datastore) string { var token string mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -7140,10 +6419,21 @@ func (s *integrationMDMTestSuite) TestGitOpsUserActions() { // s.setTokenForTest(t, "gitops1-mdm@example.com", test.GoodPassword) - // Attempt to edit global MDM settings, should allow. + // Attempt to edit global MDM settings, should allow (also ensure the IdP settings are cleared). acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ - "mdm": { "enable_disk_encryption": true } + "mdm": { + "macos_setup": { + "enable_end_user_authentication": false + }, + "enable_disk_encryption": true, + "end_user_authentication": { + "entity_id": "", + "issuer_uri": "", + "idp_name": "", + "metadata_url": "" + } + } }`), http.StatusOK, &acResp) assert.True(t, acResp.MDM.EnableDiskEncryption.Value) @@ -10643,7 +9933,7 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { func (s *integrationMDMTestSuite) runWorker() { err := s.worker.ProcessJobs(context.Background()) require.NoError(s.T(), err) - pending, err := s.ds.GetQueuedJobs(context.Background(), 1) + pending, err := s.ds.GetQueuedJobs(context.Background(), 1, time.Time{}) require.NoError(s.T(), err) require.Empty(s.T(), pending) } diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index 0939d69ab9..01624d85d0 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "fmt" + "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -25,6 +27,7 @@ type AppleMDMTask string const ( AppleMDMPostDEPEnrollmentTask AppleMDMTask = "post_dep_enrollment" AppleMDMPostManualEnrollmentTask AppleMDMTask = "post_manual_enrollment" + AppleMDMPostDEPReleaseDeviceTask AppleMDMTask = "post_dep_release_device" ) // AppleMDM is the job processor for the apple_mdm job. @@ -41,10 +44,11 @@ func (a *AppleMDM) Name() string { // appleMDMArgs is the payload for the Apple MDM job. type appleMDMArgs struct { - Task AppleMDMTask `json:"task"` - HostUUID string `json:"host_uuid"` - TeamID *uint `json:"team_id,omitempty"` - EnrollReference string `json:"enroll_reference,omitempty"` + Task AppleMDMTask `json:"task"` + HostUUID string `json:"host_uuid"` + TeamID *uint `json:"team_id,omitempty"` + EnrollReference string `json:"enroll_reference,omitempty"` + EnrollmentCommands []string `json:"enrollment_commands,omitempty"` } // Run executes the apple_mdm job. @@ -64,16 +68,22 @@ func (a *AppleMDM) Run(ctx context.Context, argsJSON json.RawMessage) error { case AppleMDMPostDEPEnrollmentTask: err := a.runPostDEPEnrollment(ctx, args) return ctxerr.Wrap(ctx, err, "running post Apple DEP enrollment task") + case AppleMDMPostManualEnrollmentTask: err := a.runPostManualEnrollment(ctx, args) return ctxerr.Wrap(ctx, err, "running post Apple manual enrollment task") + + case AppleMDMPostDEPReleaseDeviceTask: + err := a.runPostDEPReleaseDevice(ctx, args) + return ctxerr.Wrap(ctx, err, "running post Apple DEP release device task") + default: return ctxerr.Errorf(ctx, "unknown task: %v", args.Task) } } func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArgs) error { - if err := a.installFleetd(ctx, args.HostUUID); err != nil { + if _, err := a.installFleetd(ctx, args.HostUUID); err != nil { return ctxerr.Wrap(ctx, err, "installing post-enrollment packages") } @@ -81,13 +91,21 @@ func (a *AppleMDM) runPostManualEnrollment(ctx context.Context, args appleMDMArg } func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) error { - if err := a.installFleetd(ctx, args.HostUUID); err != nil { + var awaitCmdUUIDs []string + + fleetdCmdUUID, err := a.installFleetd(ctx, args.HostUUID) + if err != nil { return ctxerr.Wrap(ctx, err, "installing post-enrollment packages") } + awaitCmdUUIDs = append(awaitCmdUUIDs, fleetdCmdUUID) - if err := a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID); err != nil { + bootstrapCmdUUID, err := a.installBootstrapPackage(ctx, args.HostUUID, args.TeamID) + if err != nil { return ctxerr.Wrap(ctx, err, "installing post-enrollment packages") } + if bootstrapCmdUUID != "" { + awaitCmdUUIDs = append(awaitCmdUUIDs, bootstrapCmdUUID) + } if ref := args.EnrollReference; ref != "" { a.Log.Log("info", "got an enroll_reference", "host_uuid", args.HostUUID, "ref", ref) @@ -112,30 +130,143 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) if ssoEnabled { a.Log.Log("info", "setting username and fullname", "host_uuid", args.HostUUID) + cmdUUID := uuid.New().String() if err := a.Commander.AccountConfiguration( ctx, []string{args.HostUUID}, - uuid.New().String(), + cmdUUID, acct.Fullname, acct.Username, ); err != nil { return ctxerr.Wrap(ctx, err, "sending AccountConfiguration command") } + awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUID) } } + + var manualRelease bool + if args.TeamID == nil { + ac, err := a.Datastore.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "get AppConfig to read enable_release_device_manually") + } + manualRelease = ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value + } else { + tm, err := a.Datastore.Team(ctx, *args.TeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get Team to read enable_release_device_manually") + } + manualRelease = tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value + } + + if !manualRelease { + // send all command uuids for the commands sent here during post-DEP + // enrollment and enqueue a job to look for the status of those commands to + // be final and same for MDM profiles of that host; it means the DEP + // enrollment process is done and the device can be released. + if err := QueueAppleMDMJob(ctx, a.Datastore, a.Log, AppleMDMPostDEPReleaseDeviceTask, + args.HostUUID, args.TeamID, args.EnrollReference, awaitCmdUUIDs...); err != nil { + return ctxerr.Wrap(ctx, err, "queue Apple Post-DEP release device job") + } + } + return nil } -func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) error { +func (a *AppleMDM) runPostDEPReleaseDevice(ctx context.Context, args appleMDMArgs) error { + // Edge cases: + // - if the device goes offline for a long time, should we go ahead and + // release after a while? + // - if some commands/profiles failed (a final state), should we go ahead + // and release? + // - if the device keeps moving team, or profiles keep being added/removed + // from its team, it's possible that its profiles will never settle and + // always have pending statuses. Same as going offline, should we release + // after a while? + // + // We opted "yes" to all those, and we want to release after a few minutes, + // not hours, so we'll allow only a couple retries. + + level.Debug(a.Log).Log( + "task", "runPostDEPReleaseDevice", + "msg", fmt.Sprintf("awaiting commands %v and profiles to settle for host %s", args.EnrollmentCommands, args.HostUUID), + ) + + if retryNum, _ := ctx.Value(retryNumberCtxKey).(int); retryNum > 2 { + // give up and release the device + a.Log.Log("info", "releasing device after too many attempts", "host_uuid", args.HostUUID, "retries", retryNum) + if err := a.Commander.DeviceConfigured(ctx, args.HostUUID, uuid.NewString()); err != nil { + return ctxerr.Wrapf(ctx, err, "failed to enqueue DeviceConfigured command after %d retries", retryNum) + } + return nil + } + + for _, cmdUUID := range args.EnrollmentCommands { + if cmdUUID == "" { + continue + } + + res, err := a.Datastore.GetMDMAppleCommandResults(ctx, cmdUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get MDM command results") + } + + var completed bool + for _, r := range res { + // succeeded or failed, it is done (final state) + if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || + r.Status == fleet.MDMAppleStatusCommandFormatError { + completed = true + break + } + } + + if !completed { + // DEP enrollment commands are not done being delivered to that device, + // cannot release it now. + return fmt.Errorf("device not ready for release, still awaiting result for command %s, will retry", cmdUUID) + } + level.Debug(a.Log).Log( + "task", "runPostDEPReleaseDevice", + "msg", fmt.Sprintf("command %s has completed", cmdUUID), + ) + } + + // all DEP-enrollment commands are done, check the host's profiles + profs, err := a.Datastore.GetHostMDMAppleProfiles(ctx, args.HostUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "failed to get host MDM profiles") + } + for _, prof := range profs { + // if it has any pending profiles, then its profiles are not done being + // delivered (installed or removed). + if prof.Status == nil || *prof.Status == fleet.MDMDeliveryPending { + return fmt.Errorf("device not ready for release, profile %s is still pending, will retry", prof.Identifier) + } + level.Debug(a.Log).Log( + "task", "runPostDEPReleaseDevice", + "msg", fmt.Sprintf("profile %s has been deployed", prof.Identifier), + ) + } + + // release the device + a.Log.Log("info", "releasing device, all DEP enrollment commands and profiles have completed", "host_uuid", args.HostUUID) + if err := a.Commander.DeviceConfigured(ctx, args.HostUUID, uuid.NewString()); err != nil { + return ctxerr.Wrap(ctx, err, "failed to enqueue DeviceConfigured command") + } + return nil +} + +func (a *AppleMDM) installFleetd(ctx context.Context, hostUUID string) (string, error) { cmdUUID := uuid.New().String() if err := a.Commander.InstallEnterpriseApplication(ctx, []string{hostUUID}, cmdUUID, apple_mdm.FleetdPublicManifestURL); err != nil { - return err + return "", err } a.Log.Log("info", "sent command to install fleetd", "host_uuid", hostUUID) - return nil + return cmdUUID, nil } -func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, teamID *uint) error { +func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, teamID *uint) (string, error) { // GetMDMAppleBootstrapPackageMeta expects team id 0 for no team var tmID uint if teamID != nil { @@ -146,34 +277,34 @@ func (a *AppleMDM) installBootstrapPackage(ctx context.Context, hostUUID string, var nfe fleet.NotFoundError if errors.As(err, &nfe) { a.Log.Log("info", "unable to find a bootstrap package for DEP enrolled device, skipping installation", "host_uuid", hostUUID) - return nil + return "", nil } - return err + return "", err } appCfg, err := a.Datastore.AppConfig(ctx) if err != nil { - return err + return "", err } url, err := meta.URL(appCfg.ServerSettings.ServerURL) if err != nil { - return err + return "", err } manifest := appmanifest.NewFromSha(meta.Sha256, url) cmdUUID := uuid.New().String() err = a.Commander.InstallEnterpriseApplicationWithEmbeddedManifest(ctx, []string{hostUUID}, cmdUUID, manifest) if err != nil { - return err + return "", err } err = a.Datastore.RecordHostBootstrapPackage(ctx, cmdUUID, hostUUID) if err != nil { - return err + return "", err } a.Log.Log("info", "sent command to install bootstrap package", "host_uuid", hostUUID) - return nil + return cmdUUID, nil } // QueueAppleMDMJob queues a apple_mdm job for one of the supported tasks, to @@ -186,6 +317,7 @@ func QueueAppleMDMJob( hostUUID string, teamID *uint, enrollReference string, + enrollmentCommandUUIDs ...string, ) error { attrs := []interface{}{ "enabled", "true", @@ -196,15 +328,25 @@ func QueueAppleMDMJob( if teamID != nil { attrs = append(attrs, "team_id", *teamID) } + if len(enrollmentCommandUUIDs) > 0 { + attrs = append(attrs, "enrollment_commands", enrollmentCommandUUIDs) + } level.Info(logger).Log(attrs...) args := &appleMDMArgs{ - Task: task, - HostUUID: hostUUID, - TeamID: teamID, - EnrollReference: enrollReference, + Task: task, + HostUUID: hostUUID, + TeamID: teamID, + EnrollReference: enrollReference, + EnrollmentCommands: enrollmentCommandUUIDs, } - job, err := QueueJob(ctx, ds, appleMDMJobName, args) + + // the release device task is always added with a delay + var delay time.Duration + if task == AppleMDMPostDEPReleaseDeviceTask { + delay = 30 * time.Second + } + job, err := QueueJobWithDelay(ctx, ds, appleMDMJobName, args, delay) if err != nil { return ctxerr.Wrap(ctx, err, "queueing job") } diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index 12a339d6b9..822d67dfd8 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" @@ -40,6 +41,8 @@ func TestAppleMDM(t *testing.T) { // specific internals (sequence and number of calls, etc.). The MDM storage // and pusher are mocks. ds := mysql.CreateMySQLDS(t) + // call TruncateTables immediately as a DB migation may have created jobs + mysql.TruncateTables(t, ds) mdmStorage, err := ds.NewMDMAppleMDMStorage([]byte("test"), []byte("test")) require.NoError(t, err) @@ -92,6 +95,32 @@ func TestAppleMDM(t *testing.T) { return commands } + enableManualRelease := func(t *testing.T, teamID *uint) { + if teamID == nil { + enableAppCfg := func(enable bool) { + ac, err := ds.AppConfig(ctx) + require.NoError(t, err) + ac.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(enable) + err = ds.SaveAppConfig(ctx, ac) + require.NoError(t, err) + } + + enableAppCfg(true) + t.Cleanup(func() { enableAppCfg(false) }) + } else { + enableTm := func(enable bool) { + tm, err := ds.Team(ctx, *teamID) + require.NoError(t, err) + tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(enable) + _, err = ds.SaveTeam(ctx, tm) + require.NoError(t, err) + } + + enableTm(true) + t.Cleanup(func() { enableTm(false) }) + } + } + t.Run("no-op with nil commander", func(t *testing.T) { defer mysql.TruncateTables(t, ds) @@ -115,7 +144,7 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) require.Empty(t, jobs) }) @@ -143,7 +172,7 @@ func TestAppleMDM(t *testing.T) { // ensure the job's not_before allows it to be returned time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Time{}) require.NoError(t, err) require.Len(t, jobs, 1) require.Contains(t, jobs[0].Error, "unknown task: no-such-task") @@ -175,9 +204,49 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) + + // the post-DEP release device job is pending + require.Len(t, jobs, 1) + require.Equal(t, appleMDMJobName, jobs[0].Name) + require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) + require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + + require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) + }) + + t.Run("installs default manifest, manual release", func(t *testing.T) { + t.Cleanup(func() { mysql.TruncateTables(t, ds) }) + + h := createEnrolledHost(t, 1, nil, true) + enableManualRelease(t, nil) + + mdmWorker := &AppleMDM{ + Datastore: ds, + Log: nopLog, + Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}), + } + w := NewWorker(ds, nopLog) + w.Register(mdmWorker) + + err = QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, nil, "") + require.NoError(t, err) + + // run the worker, should succeed + err = w.ProcessJobs(ctx) + require.NoError(t, err) + + // ensure the job's not_before allows it to be returned if it were to run + // again + time.Sleep(time.Second) + + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job + require.NoError(t, err) + + // there is no post-DEP release device job pending require.Empty(t, jobs) + require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) }) @@ -213,9 +282,15 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - require.Empty(t, jobs) + + // the post-DEP release device job is pending + require.Len(t, jobs, 1) + require.Equal(t, appleMDMJobName, jobs[0].Name) + require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) + require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) ms, err := ds.GetHostMDMMacOSSetup(ctx, h.ID) @@ -258,9 +333,64 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) + + // the post-DEP release device job is pending + require.Len(t, jobs, 1) + require.Equal(t, appleMDMJobName, jobs[0].Name) + require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) + require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + + require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) + + ms, err := ds.GetHostMDMMacOSSetup(ctx, h.ID) + require.NoError(t, err) + require.Equal(t, "custom-team-bootstrap", ms.BootstrapPackageName) + }) + + t.Run("installs custom bootstrap manifest of a team, manual release", func(t *testing.T) { + t.Cleanup(func() { mysql.TruncateTables(t, ds) }) + + tm, err := ds.NewTeam(ctx, &fleet.Team{Name: "test"}) + require.NoError(t, err) + enableManualRelease(t, &tm.ID) + + h := createEnrolledHost(t, 1, &tm.ID, true) + err = ds.InsertMDMAppleBootstrapPackage(ctx, &fleet.MDMAppleBootstrapPackage{ + Name: "custom-team-bootstrap", + TeamID: tm.ID, + Bytes: []byte("test"), + Sha256: []byte("test"), + Token: "token", + }) + require.NoError(t, err) + + mdmWorker := &AppleMDM{ + Datastore: ds, + Log: nopLog, + Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}), + } + w := NewWorker(ds, nopLog) + w.Register(mdmWorker) + + err = QueueAppleMDMJob(ctx, ds, nopLog, AppleMDMPostDEPEnrollmentTask, h.UUID, &tm.ID, "") + require.NoError(t, err) + + // run the worker, should succeed + err = w.ProcessJobs(ctx) + require.NoError(t, err) + + // ensure the job's not_before allows it to be returned if it were to run + // again + time.Sleep(time.Second) + + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job + require.NoError(t, err) + + // there is no post-DEP release device job pending require.Empty(t, jobs) + require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) ms, err := ds.GetHostMDMMacOSSetup(ctx, h.ID) @@ -292,7 +422,7 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Time{}) require.NoError(t, err) require.Len(t, jobs, 1) require.Contains(t, jobs[0].Error, "MDMIdPAccount with uuid abcd was not found") @@ -334,9 +464,15 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - require.Empty(t, jobs) + + // the post-DEP release device job is pending, having failed its first attempt + require.Len(t, jobs, 1) + require.Equal(t, appleMDMJobName, jobs[0].Name) + require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) + require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + // confirm that AccountConfiguration command was not enqueued require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) }) @@ -383,9 +519,15 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) - require.Empty(t, jobs) + + // the post-DEP release device job is pending + require.Len(t, jobs, 1) + require.Equal(t, appleMDMJobName, jobs[0].Name) + require.Contains(t, string(*jobs[0].Args), AppleMDMPostDEPReleaseDeviceTask) + require.Equal(t, 0, jobs[0].Retries) // hasn't run yet + require.ElementsMatch(t, []string{"InstallEnterpriseApplication", "AccountConfiguration"}, getEnqueuedCommandTypes(t)) }) @@ -413,7 +555,7 @@ func TestAppleMDM(t *testing.T) { // again time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 1) + jobs, err := ds.GetQueuedJobs(ctx, 1, time.Now().UTC().Add(time.Minute)) // look in the future to catch any delayed job require.NoError(t, err) require.Empty(t, jobs) require.ElementsMatch(t, []string{"InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) diff --git a/server/worker/macos_setup_assistant_test.go b/server/worker/macos_setup_assistant_test.go index e2633363bf..0a9d20bf1f 100644 --- a/server/worker/macos_setup_assistant_test.go +++ b/server/worker/macos_setup_assistant_test.go @@ -25,6 +25,8 @@ import ( func TestMacosSetupAssistant(t *testing.T) { ctx := context.Background() ds := mysql.CreateMySQLDS(t) + // call TruncateTables immediately as some DB migrations may create jobs + mysql.TruncateTables(t, ds) // create a couple hosts for no team, team 1 and team 2 (none for team 3) hosts := make([]*fleet.Host, 6) @@ -140,7 +142,7 @@ func TestMacosSetupAssistant(t *testing.T) { err = w.ProcessJobs(ctx) require.NoError(t, err) // no remaining jobs to process - pending, err := ds.GetQueuedJobs(ctx, 10) + pending, err := ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Empty(t, pending) } diff --git a/server/worker/worker.go b/server/worker/worker.go index ec10c5e52d..b670a67029 100644 --- a/server/worker/worker.go +++ b/server/worker/worker.go @@ -12,11 +12,17 @@ import ( "github.com/go-kit/kit/log/level" ) +type ctxKey int + const ( maxRetries = 5 // nvdCVEURL is the base link to a CVE on the NVD website, only the CVE code // needs to be appended to make it a valid link. nvdCVEURL = "https://nvd.nist.gov/vuln/detail/" + + // context key for the retry number of a job, made available via the context + // to the job processor. + retryNumberCtxKey = ctxKey(0) ) const ( @@ -89,14 +95,26 @@ func (w *Worker) Register(jobs ...Job) { // identified by the name (e.g. "jira"). The args value is marshaled as JSON // and provided to the job processor when the job is executed. func QueueJob(ctx context.Context, ds fleet.Datastore, name string, args interface{}) (*fleet.Job, error) { + return QueueJobWithDelay(ctx, ds, name, args, 0) +} + +// QueueJobWithDelay is like QueueJob but does not make the job available +// before a specified delay (or no delay if delay is <= 0). +func QueueJobWithDelay(ctx context.Context, ds fleet.Datastore, name string, args interface{}, delay time.Duration) (*fleet.Job, error) { argsJSON, err := json.Marshal(args) if err != nil { return nil, ctxerr.Wrap(ctx, err, "marshal args") } + + var notBefore time.Time + if delay > 0 { + notBefore = time.Now().UTC().Add(delay) + } job := &fleet.Job{ - Name: name, - Args: (*json.RawMessage)(&argsJSON), - State: fleet.JobStateQueued, + Name: name, + Args: (*json.RawMessage)(&argsJSON), + State: fleet.JobStateQueued, + NotBefore: notBefore, } return ds.NewJob(ctx, job) @@ -122,7 +140,7 @@ func (w *Worker) ProcessJobs(ctx context.Context) error { // process jobs until there are none left or the context is cancelled seen := make(map[uint]struct{}) for { - jobs, err := w.ds.GetQueuedJobs(ctx, maxNumJobs) + jobs, err := w.ds.GetQueuedJobs(ctx, maxNumJobs, time.Time{}) if err != nil { return ctxerr.Wrap(ctx, err, "get queued jobs") } @@ -191,6 +209,7 @@ func (w *Worker) processJob(ctx context.Context, job *fleet.Job) error { args = *job.Args } + ctx = context.WithValue(ctx, retryNumberCtxKey, job.Retries) return j.Run(ctx, args) } diff --git a/server/worker/worker_test.go b/server/worker/worker_test.go index 093c587f7c..4e0446bff5 100644 --- a/server/worker/worker_test.go +++ b/server/worker/worker_test.go @@ -35,7 +35,7 @@ func TestWorker(t *testing.T) { // set up mocks getQueuedJobsCalled := 0 - ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) { + ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) { if getQueuedJobsCalled > 0 { return nil, nil } @@ -93,7 +93,7 @@ func TestWorkerRetries(t *testing.T) { State: fleet.JobStateQueued, Retries: 0, } - ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) { + ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) { if theJob.State == fleet.JobStateQueued { return []*fleet.Job{theJob}, nil } @@ -173,7 +173,7 @@ func TestWorkerMiddleJobFails(t *testing.T) { Retries: 0, }, } - ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int) ([]*fleet.Job, error) { + ds.GetQueuedJobsFunc = func(ctx context.Context, maxNumJobs int, now time.Time) ([]*fleet.Job, error) { var queued []*fleet.Job for _, j := range jobs { if j.State == fleet.JobStateQueued { @@ -241,6 +241,8 @@ func TestWorkerMiddleJobFails(t *testing.T) { func TestWorkerWithRealDatastore(t *testing.T) { ctx := context.Background() ds := mysql.CreateMySQLDS(t) + // call TruncateTables immediately, because a DB migration may create jobs + mysql.TruncateTables(t, ds) oldDelayPerRetry := delayPerRetry delayPerRetry = []time.Duration{ @@ -295,7 +297,7 @@ func TestWorkerWithRealDatastore(t *testing.T) { // timestamp in mysql vs the one set in ProcessJobs (time.Now().Add(...)). time.Sleep(time.Second) - jobs, err := ds.GetQueuedJobs(ctx, 10) + jobs, err := ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Len(t, jobs, 1) require.Equal(t, j2.ID, jobs[0].ID) @@ -311,7 +313,7 @@ func TestWorkerWithRealDatastore(t *testing.T) { // timestamp in mysql vs the one set in ProcessJobs (time.Now().Add(...)). time.Sleep(time.Second) - jobs, err = ds.GetQueuedJobs(ctx, 10) + jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Len(t, jobs, 1) require.Equal(t, j2.ID, jobs[0].ID) @@ -326,7 +328,7 @@ func TestWorkerWithRealDatastore(t *testing.T) { time.Sleep(time.Second) - jobs, err = ds.GetQueuedJobs(ctx, 10) + jobs, err = ds.GetQueuedJobs(ctx, 10, time.Time{}) require.NoError(t, err) require.Empty(t, jobs) From 8253e772640a88718c9b22ebae938438de26102b Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 26 Mar 2024 08:15:57 -0400 Subject: [PATCH 3/6] Enable release device: copy global settings to new teams created via puppet (#17842) --- .github/workflows/test-go.yaml | 4 ++-- ee/server/service/mdm.go | 28 +++++++++++++--------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test-go.yaml b/.github/workflows/test-go.yaml index 69c9b3be6c..a5c02b14cd 100644 --- a/.github/workflows/test-go.yaml +++ b/.github/workflows/test-go.yaml @@ -49,7 +49,7 @@ jobs: env: RACE_ENABLED: false - GO_TEST_TIMEOUT: 15m + GO_TEST_TIMEOUT: 20m steps: - name: Harden Runner @@ -74,7 +74,7 @@ jobs: run: | sudo cp tools/smtp4dev/fleet.crt /usr/local/share/ca-certificates/ sudo update-ca-certificates - + # It seems faster not to cache Go dependencies - name: Install Go Dependencies run: make deps-go diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index aaa1507412..3273402668 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -948,23 +948,21 @@ func (svc *Service) getOrCreatePreassignTeam(ctx context.Context, groups []strin return nil, err } - payload.MDM = &fleet.TeamPayloadMDM{ - EnableDiskEncryption: optjson.SetBool(true), - MacOSSetup: &fleet.MacOSSetup{ - MacOSSetupAssistant: ac.MDM.MacOSSetup.MacOSSetupAssistant, - // NOTE: BootstrapPackage is currently ignored by svc.ModifyTeam and gets set - // instead by CopyDefaultMDMAppleBootstrapPackage below - // BootstrapPackage: ac.MDM.MacOSSetup.BootstrapPackage, - EnableEndUserAuthentication: ac.MDM.MacOSSetup.EnableEndUserAuthentication, - // TODO(mna): should we copy the EnableReleaseDeviceManually setting from the global config? + spec := &fleet.TeamSpec{ + Name: teamName, + MDM: fleet.TeamSpecMDM{ + EnableDiskEncryption: optjson.SetBool(true), + MacOSSetup: fleet.MacOSSetup{ + MacOSSetupAssistant: ac.MDM.MacOSSetup.MacOSSetupAssistant, + // NOTE: BootstrapPackage gets set by + // CopyDefaultMDMAppleBootstrapPackage below + // BootstrapPackage: ac.MDM.MacOSSetup.BootstrapPackage, + EnableEndUserAuthentication: ac.MDM.MacOSSetup.EnableEndUserAuthentication, + EnableReleaseDeviceManually: ac.MDM.MacOSSetup.EnableReleaseDeviceManually, + }, }, } - - // TODO: seems like we don't support enabling disk encryption - // on team creation? - // see https://github.com/fleetdm/fleet/issues/12220 - team, err = svc.ModifyTeam(ctx, team.ID, payload) - if err != nil { + if _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}); err != nil { return nil, err } if err := svc.ds.CopyDefaultMDMAppleBootstrapPackage(ctx, ac, team.ID); err != nil { From a292e704de49cdcb9c42214deb21feb46ff0ca10 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 26 Mar 2024 14:46:33 +0000 Subject: [PATCH 4/6] add startup assistant to the UI (#17731) relates to #9147 add the setup assistant page to the UI. This includes: - new setup assistant page - uploaded profile and release device manually form - preview for setup assistant flow - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Martin Angers --- frontend/__mocks__/configMock.ts | 1 + frontend/components/Card/Card.tsx | 14 +- frontend/components/Card/_styles.scss | 22 +++ .../components/FileUploader/FileUploader.tsx | 7 +- frontend/components/FileUploader/_styles.scss | 4 +- frontend/interfaces/config.ts | 1 + frontend/interfaces/team.ts | 3 +- .../SetupExperienceNavItems.tsx | 7 + .../BootstrapPackagePreview/_styles.scss | 4 +- .../cards/SetupAssistant/SetupAssistant.tsx | 142 ++++++++++++++++++ .../cards/SetupAssistant/_styles.scss | 25 +++ .../AdvancedOptionsForm.tsx | 71 +++++++++ .../AdvancedOptionsForm/_styles.scss | 10 ++ .../components/AdvancedOptionsForm/index.ts | 1 + .../DeleteAutoEnrollmentProfile.tsx | 59 ++++++++ .../DeleteAutoEnrollmentProfile/index.ts | 1 + .../SetupAssistantPreview.tsx | 35 +++++ .../SetupAssistantPreview/_styles.scss | 16 ++ .../components/SetupAssistantPreview/index.ts | 1 + .../SetupAssistantProfileCard.tsx | 64 ++++++++ .../SetupAssistantProfileCard/_styles.scss | 30 ++++ .../SetupAssistantProfileCard/index.ts | 1 + .../SetupAssistantProfileUploader.tsx | 62 ++++++++ .../SetupAssistantProfileUploader/helpers.ts | 13 ++ .../SetupAssistantProfileUploader/index.ts | 1 + .../cards/SetupAssistant/index.ts | 1 + frontend/router/paths.ts | 1 + frontend/services/entities/config.ts | 4 +- frontend/services/entities/mdm.ts | 76 ++++++++++ frontend/services/entities/teams.ts | 10 ++ .../date_format/date_format.tests.ts | 13 ++ frontend/utilities/date_format/index.ts | 6 + frontend/utilities/endpoints.ts | 2 + 33 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts create mode 100644 frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts create mode 100644 frontend/utilities/date_format/date_format.tests.ts create mode 100644 frontend/utilities/date_format/index.ts diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 26b996662a..5d56609f41 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -145,6 +145,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { bootstrap_package: "", enable_end_user_authentication: false, macos_setup_assistant: null, + enable_release_device_manually: false, }, macos_migration: { enable: false, diff --git a/frontend/components/Card/Card.tsx b/frontend/components/Card/Card.tsx index 08d3c777d7..0e394250ea 100644 --- a/frontend/components/Card/Card.tsx +++ b/frontend/components/Card/Card.tsx @@ -8,14 +8,20 @@ type CardColor = "white" | "gray" | "purple" | "yellow"; interface ICardProps { children?: React.ReactNode; - /** The size of the border radius. Defaults to `small` */ + /** The size of the border radius. Defaults to `small`. */ borderRadiusSize?: BorderRadiusSize; /** Includes the card shadows. Defaults to `false` */ includeShadow?: boolean; /** The color of the card. Defaults to `white` */ color?: CardColor; className?: string; - /** Increases to 40px padding. Defaults to `false` */ + /** The size of the padding around the content of the card. Defaults to `large`. + * + * These correspond to the padding sizes in the design system. Look at `padding.scss` for values */ + paddingSize?: "small" | "medium" | "large" | "xlarge" | "xxlarge"; + /** NOTE: DEPRICATED. Use `paddingSize` prop instead. + * + * Increases to 40px padding. Defaults to `false` */ largePadding?: boolean; } @@ -30,12 +36,16 @@ const Card = ({ color = "white", className, largePadding = false, + paddingSize = "large", }: ICardProps) => { const classNames = classnames( baseClass, `${baseClass}__${color}`, `${baseClass}__radius-${borderRadiusSize}`, { + // TODO: simplify this when we've replaced largePadding prop with paddingSize + [`${baseClass}__padding-${paddingSize}`]: + !largePadding && paddingSize !== undefined, [`${baseClass}__shadow`]: includeShadow, [`${baseClass}__large-padding`]: largePadding, }, diff --git a/frontend/components/Card/_styles.scss b/frontend/components/Card/_styles.scss index 4fec9ffc64..0fac0a084a 100644 --- a/frontend/components/Card/_styles.scss +++ b/frontend/components/Card/_styles.scss @@ -22,7 +22,29 @@ box-shadow: $box-shadow; } + &__padding-small { + padding: $pad-small; + } + + &__padding-medium { + padding: $pad-medium; + } + + &__padding-large { + padding: $pad-large; + } + + &__padding-xlarge { + padding: $pad-xlarge; + } + + &__padding-xxlarge { + padding: $pad-xxlarge; + } + // 40px padding + // TODO: remove when we've replaced all instances of largePadding with + // paddingSize prop &__large-padding { padding: $pad-xxlarge; } diff --git a/frontend/components/FileUploader/FileUploader.tsx b/frontend/components/FileUploader/FileUploader.tsx index 8f139d1730..52ab307656 100644 --- a/frontend/components/FileUploader/FileUploader.tsx +++ b/frontend/components/FileUploader/FileUploader.tsx @@ -32,6 +32,8 @@ interface IFileUploaderProps { * https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept */ accept?: string; + /** The text to display on the upload button */ + buttonMessage?: string; className?: string; onFileUpload: (files: FileList | null) => void; } @@ -45,6 +47,7 @@ const FileUploader = ({ additionalInfo, isLoading = false, accept, + buttonMessage = "Upload", className, onFileUpload, }: IFileUploaderProps) => { @@ -73,11 +76,11 @@ const FileUploader = ({ variant="brand" isLoading={isLoading} > - + { onFileUpload(e.target.files); diff --git a/frontend/components/FileUploader/_styles.scss b/frontend/components/FileUploader/_styles.scss index 1cdb033ce3..4a6835d3ec 100644 --- a/frontend/components/FileUploader/_styles.scss +++ b/frontend/components/FileUploader/_styles.scss @@ -30,12 +30,12 @@ &__upload-button { margin-top: 8px; + // we handle the padding in the label so the entire button is clickable padding: 0; } label { - height: 36px; - width: 78.2667px; + padding: $pad-small $pad-medium; display: flex; align-items: center; justify-content: center; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 1df44de33d..f0bf37203f 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -48,6 +48,7 @@ export interface IMdmConfig { bootstrap_package: string | null; enable_end_user_authentication: boolean; macos_setup_assistant: string | null; + enable_release_device_manually: boolean | null; }; macos_migration: IMacOsMigrationSettings; windows_updates: { diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 435075902a..3569ec26c9 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -56,7 +56,8 @@ export interface ITeam extends ITeamSummary { macos_setup: { bootstrap_package: string | null; enable_end_user_authentication: boolean; - macos_setup_assistant: string | null; // TODO: types? + macos_setup_assistant: string | null; + enable_release_device_manually: boolean | null; }; windows_updates: { deadline_days: number | null; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx index ca9021ac9d..f8fed7e832 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/SetupExperienceNavItems.tsx @@ -4,6 +4,7 @@ import { ISideNavItem } from "pages/admin/components/SideNav/SideNav"; import EndUserAuthentication from "./cards/EndUserAuthentication/EndUserAuthentication"; import BootstrapPackage from "./cards/BootstrapPackage"; +import SetupAssistant from "./cards/SetupAssistant"; interface ISetupExperienceCardProps { currentTeamId?: number; @@ -25,6 +26,12 @@ const SETUP_EXPERIENCE_NAV_ITEMS: ISideNavItem< path: PATHS.CONTROLS_BOOTSTRAP_PACKAGE, Card: BootstrapPackage, }, + { + title: "Setup assistant", + urlSection: "setup-assistant", + path: PATHS.CONTROLS_SETUP_ASSITANT, + Card: SetupAssistant, + }, ]; export default SETUP_EXPERIENCE_NAV_ITEMS; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss index c5702ed0db..03d790b2f6 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/components/BootstrapPackagePreview/_styles.scss @@ -6,7 +6,9 @@ font-size: $x-small; h2 { - font-size: $x-small; // needed to override global h2 style + margin: 0; + font-size: $small; + font-weight: normal; } &__preview-img { diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx new file mode 100644 index 0000000000..f670a8e710 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/SetupAssistant.tsx @@ -0,0 +1,142 @@ +import React, { useState } from "react"; +import { useQuery } from "react-query"; +import { AxiosError } from "axios"; + +import { IConfig } from "interfaces/config"; +import { API_NO_TEAM_ID, ITeamConfig } from "interfaces/team"; +import configAPI from "services/entities/config"; +import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; +import mdmAPI, { + IAppleSetupEnrollmentProfileResponse, +} from "services/entities/mdm"; +import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants"; + +import SectionHeader from "components/SectionHeader"; +import Spinner from "components/Spinner"; +import CustomLink from "components/CustomLink"; + +import SetupAssistantPreview from "./components/SetupAssistantPreview"; +import SetupAssistantProfileUploader from "./components/SetupAssistantProfileUploader"; +import SetuAssistantProfileCard from "./components/SetupAssistantProfileCard/SetupAssistantProfileCard"; +import DeleteAutoEnrollmentProfile from "./components/DeleteAutoEnrollmentProfile"; +import AdvancedOptionsForm from "./components/AdvancedOptionsForm"; + +const baseClass = "setup-assistant"; + +interface ISetupAssistantProps { + currentTeamId: number; +} + +const SetupAssistant = ({ currentTeamId }: ISetupAssistantProps) => { + const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false); + + const { data: globalConfig, isLoading: isLoadingGlobalConfig } = useQuery< + IConfig, + Error + >(["config", currentTeamId], () => configAPI.loadAll(), { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + enabled: currentTeamId === API_NO_TEAM_ID, + }); + + const { data: teamConfig, isLoading: isLoadingTeamConfig } = useQuery< + ILoadTeamResponse, + Error, + ITeamConfig + >(["team", currentTeamId], () => teamsAPI.load(currentTeamId), { + ...DEFAULT_USE_QUERY_OPTIONS, + refetchOnWindowFocus: false, + retry: false, + enabled: currentTeamId !== API_NO_TEAM_ID, + select: (res) => res.team, + }); + + const { + data: enrollmentProfileData, + isLoading: isLoadingEnrollmentProfile, + error: enrollmentProfileError, + refetch: refetchEnrollmentProfile, + } = useQuery( + ["enrollment_profile", currentTeamId], + () => mdmAPI.getSetupEnrollmentProfile(currentTeamId), + { + ...DEFAULT_USE_QUERY_OPTIONS, + retry: false, + } + ); + + const getReleaseDeviceSetting = () => { + if (currentTeamId === API_NO_TEAM_ID) { + return ( + globalConfig?.mdm.macos_setup.enable_release_device_manually || false + ); + } + return teamConfig?.mdm?.macos_setup.enable_release_device_manually || false; + }; + + const onUpload = () => { + refetchEnrollmentProfile(); + }; + + const onDelete = () => { + setShowDeleteProfileModal(false); + refetchEnrollmentProfile(); + }; + + const defaultReleaseDeviceSetting = getReleaseDeviceSetting(); + + const isLoading = + isLoadingGlobalConfig || isLoadingTeamConfig || isLoadingEnrollmentProfile; + const enrollmentProfileNotFound = enrollmentProfileError?.status === 404; + + return ( +
+ + {isLoading ? ( + + ) : ( +
+
+

+ Add an automatic enrollment profile to customize the macOS Setup + Assistant. + +

+ {enrollmentProfileNotFound || !enrollmentProfileData ? ( + + ) : ( + setShowDeleteProfileModal(true)} + /> + )} + +
+
+ +
+
+ )} + {showDeleteProfileModal && ( + setShowDeleteProfileModal(false)} + /> + )} +
+ ); +}; + +export default SetupAssistant; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss new file mode 100644 index 0000000000..dc2e054fb3 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/_styles.scss @@ -0,0 +1,25 @@ +.setup-assistant { + &__content { + max-width: $break-xxl; + margin: 0 auto; + display: flex; + justify-content: space-between; + gap: $pad-xxlarge; + } + + &__upload-container { + display: flex; + flex-direction: column; + gap: $pad-large; + } + + &__section-description { + margin: 0; + } + + @media (max-width: $break-md) { + &__content { + flex-direction: column; + } + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx new file mode 100644 index 0000000000..3e3c94a797 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/AdvancedOptionsForm.tsx @@ -0,0 +1,71 @@ +import React, { useContext, useState } from "react"; + +import mdmAPI from "services/entities/mdm"; + +import TooltipWrapper from "components/TooltipWrapper"; +import Checkbox from "components/forms/fields/Checkbox"; +import Button from "components/buttons/Button"; +import { NotificationContext } from "context/notification"; +import RevealButton from "components/buttons/RevealButton"; + +const baseClass = "advanced-options-form"; + +interface IAdvancedOptionsFormProps { + currentTeamId: number; + defaultReleaseDevice: boolean; +} + +const AdvancedOptionsForm = ({ + currentTeamId, + defaultReleaseDevice, +}: IAdvancedOptionsFormProps) => { + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + const [releaseDevice, setReleaseDevice] = useState(defaultReleaseDevice); + const { renderFlash } = useContext(NotificationContext); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await mdmAPI.updateReleaseDeviceSetting(currentTeamId, releaseDevice); + renderFlash("success", "Successfully updated."); + } catch { + renderFlash("error", "Something went wrong. Please try again."); + } + }; + + const tooltip = ( + <> + When enabled, you're responsible for sending the DeviceConfigured + command. (Default: Off) + + ); + + return ( +
+ setShowAdvancedOptions(!showAdvancedOptions)} + /> + {showAdvancedOptions && ( +
+ setReleaseDevice(!releaseDevice)} + > + + Release device manually + + + +
+ )} +
+ ); +}; + +export default AdvancedOptionsForm; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss new file mode 100644 index 0000000000..b08bafb01e --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/_styles.scss @@ -0,0 +1,10 @@ +.advanced-options-form { + display: flex; + flex-direction: column; + gap: $pad-large; + + &__accordion-title { + // use this so we dont center the text. + justify-content: normal; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts new file mode 100644 index 0000000000..1b538503ff --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/AdvancedOptionsForm/index.ts @@ -0,0 +1 @@ +export { default } from "./AdvancedOptionsForm"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx new file mode 100644 index 0000000000..0ab9ef7026 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/DeleteAutoEnrollmentProfile.tsx @@ -0,0 +1,59 @@ +import React, { useContext } from "react"; + +import mdmAPI from "services/entities/mdm"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import { NotificationContext } from "context/notification"; + +interface DeleteAutoEnrollProfileProps { + currentTeamId: number; + onCancel: () => void; + onDelete: () => void; +} + +const baseClass = "delete-auto-enrollment-profile-modal"; + +const DeleteAutoEnrollProfile = ({ + currentTeamId, + onCancel, + onDelete, +}: DeleteAutoEnrollProfileProps) => { + const { renderFlash } = useContext(NotificationContext); + + const handleDelete = async () => { + try { + await mdmAPI.deleteSetupEnrollmentProfile(currentTeamId); + renderFlash("success", "Successfully deleted!"); + } catch { + renderFlash("error", "Couldn’t delete. Please try again."); + } + onDelete(); + }; + + return ( + + <> +

Delete the automatic enrollment profile to upload a new one.

+

+ Without an automatic enrollment profile, new macOS hosts will + automatically enroll with the default setup settings. +

+
+ + +
+ +
+ ); +}; + +export default DeleteAutoEnrollProfile; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts new file mode 100644 index 0000000000..dfd08b7064 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/DeleteAutoEnrollmentProfile/index.ts @@ -0,0 +1 @@ +export { default } from "./DeleteAutoEnrollmentProfile"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx new file mode 100644 index 0000000000..9e080644d6 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import Card from "components/Card"; + +import OsSetupPreview from "../../../../../../../../assets/images/os-setup-preview.gif"; + +const baseClass = "setup-assistant-preview"; + +const SetupAssistantPreview = () => { + return ( + +

End user experience

+

+ After the end user continues past the Remote Management screen, + macOS Setup Assistant displays several screens by default. +

+

+ By adding an automatic enrollment profile you can customize which + screens are displayed and more. +

+ OS setup preview +
+ ); +}; + +export default SetupAssistantPreview; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss new file mode 100644 index 0000000000..07f7e9b797 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/_styles.scss @@ -0,0 +1,16 @@ +.setup-assistant-preview { + font-size: $x-small; + + h2 { + margin: 0; + font-size: $small; + font-weight: normal; + } + + &__preview-img { + margin-top: $pad-xxlarge; + width: 100%; + display: block; + margin: 40px auto 0; + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts new file mode 100644 index 0000000000..ecdc67cdf4 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistantPreview"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx new file mode 100644 index 0000000000..8d6d64dd41 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/SetupAssistantProfileCard.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import FileSaver from "file-saver"; + +import { uploadedFromNow } from "utilities/date_format"; + +import Icon from "components/Icon"; +import Card from "components/Card"; +import Graphic from "components/Graphic"; +import Button from "components/buttons/Button"; +import { IAppleSetupEnrollmentProfileResponse } from "services/entities/mdm"; + +const baseClass = "setup-assistant-profile-card"; +interface ISetupAssistantProfileCardProps { + profile: IAppleSetupEnrollmentProfileResponse; + onDelete: () => void; +} + +const SetupAssistantProfileCard = ({ + profile, + onDelete, +}: ISetupAssistantProfileCardProps) => { + const onDownload = () => { + const date = new Date(); + const filename = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${ + profile.name + }`; + const file = new global.window.File( + [JSON.stringify(profile.enrollment_profile)], + filename + ); + + FileSaver.saveAs(file); + }; + + return ( + + +
+ {profile.name} + + {uploadedFromNow(profile.uploaded_at)} + +
+
+ + +
+
+ ); +}; + +export default SetupAssistantProfileCard; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss new file mode 100644 index 0000000000..7d8d4bbc63 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/_styles.scss @@ -0,0 +1,30 @@ +.setup-assistant-profile-card { + display: flex; + gap: $pad-medium; + align-items: center; + + // TODO: create reusable list item component and use instead of all these styles. + &__info { + display: flex; + flex-direction: column; + } + + &__profile-name { + font-size: $x-small; + font-weight: $bold; + } + &__uploaded-at { + font-size: $xx-small; + } + + &__actions { + display: flex; + gap: $pad-medium; + flex: 1; + justify-content: flex-end; + } + + &__download-button, &__delete-button { + padding: 11px; // TODO: use a padding value from existing variables. talk to design. + } +} diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts new file mode 100644 index 0000000000..00e2af3c93 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileCard/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistantProfileCard"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx new file mode 100644 index 0000000000..936e9f58fa --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/SetupAssistantProfileUploader.tsx @@ -0,0 +1,62 @@ +import React, { useContext, useState } from "react"; +import { AxiosResponse } from "axios"; + +import { IApiError } from "interfaces/errors"; +import { NotificationContext } from "context/notification"; +import mdmAPI from "services/entities/mdm"; + +import FileUploader from "components/FileUploader"; + +import { getErrorMessage } from "./helpers"; + +const baseClass = "setup-assistant-profile-uploader"; + +interface ISetupAssistantProfileUploaderProps { + currentTeamId: number; + onUpload: () => void; +} + +const SetupAssistantProfileUploader = ({ + currentTeamId, + onUpload, +}: ISetupAssistantProfileUploaderProps) => { + const { renderFlash } = useContext(NotificationContext); + const [showLoading, setShowLoading] = useState(false); + + const onUploadFile = async (files: FileList | null) => { + setShowLoading(true); + + if (!files || files.length === 0) { + setShowLoading(false); + return; + } + + const file = files[0]; + + try { + await mdmAPI.uploadSetupEnrollmentProfile(file, currentTeamId); + renderFlash("success", "Successfully uploaded!"); + onUpload(); + } catch (e) { + const error = e as AxiosResponse; + const errMessage = getErrorMessage(error); + renderFlash("error", errMessage); + } finally { + setShowLoading(false); + } + }; + + return ( + + ); +}; + +export default SetupAssistantProfileUploader; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts new file mode 100644 index 0000000000..bd7538a73a --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/helpers.ts @@ -0,0 +1,13 @@ +import { getErrorReason } from "interfaces/errors"; + +const UPLOAD_ERROR_MESSAGES = { + default: { + message: "Couldn't upload. Please try again.", + }, +}; + +// eslint-disable-next-line import/prefer-default-export +export const getErrorMessage = (err: unknown) => { + if (typeof err === "string") return err; + return getErrorReason(err) || UPLOAD_ERROR_MESSAGES.default.message; +}; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts new file mode 100644 index 0000000000..f27221879b --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantProfileUploader/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistantProfileUploader"; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts new file mode 100644 index 0000000000..473124c799 --- /dev/null +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/index.ts @@ -0,0 +1 @@ +export { default } from "./SetupAssistant"; diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 114e42dd8e..55ff6d8c62 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -14,6 +14,7 @@ export default { CONTROLS_SETUP_EXPERIENCE: `${URL_PREFIX}/controls/setup-experience`, CONTROLS_END_USER_AUTHENTICATION: `${URL_PREFIX}/controls/setup-experience/end-user-auth`, CONTROLS_BOOTSTRAP_PACKAGE: `${URL_PREFIX}/controls/setup-experience/bootstrap-package`, + CONTROLS_SETUP_ASSITANT: `${URL_PREFIX}/controls/setup-experience/setup-assistant`, CONTROLS_SCRIPTS: `${URL_PREFIX}/controls/scripts`, DASHBOARD: `${URL_PREFIX}/dashboard`, diff --git a/frontend/services/entities/config.ts b/frontend/services/entities/config.ts index 78af25563d..685826a7dc 100644 --- a/frontend/services/entities/config.ts +++ b/frontend/services/entities/config.ts @@ -57,8 +57,8 @@ export default { }, /** - * updateMDMConfig is a special case of update that is used to update the MDM - * config. + * updateMDMConfig is a special case of update that is used to update the app + * MDM config. * * If the request fails and `skipParseError` is `true`, the caller is * responsible for verifying that the value of the rejected promise is an AxiosError diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index 29a8e5fab9..90101a1135 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -4,6 +4,7 @@ import { IMdmProfile, MdmProfileStatus, } from "interfaces/mdm"; +import { API_NO_TEAM_ID } from "interfaces/team"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; @@ -46,6 +47,19 @@ export interface IUploadProfileApiParams { labels?: string[]; } +interface IUpdateSetupExperienceBody { + team_id?: number; + enable_release_device_manually: boolean; +} + +export interface IAppleSetupEnrollmentProfileResponse { + team_id: number | null; + name: string; + uploaded_at: string; + // enrollment profile is an object with keys found here https://developer.apple.com/documentation/devicemanagement/profile. + enrollment_profile: Record; +} + const mdmService = { downloadDeviceUserEnrollmentProfile: (token: string) => { const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints; @@ -226,6 +240,68 @@ const mdmService = { enable_end_user_authentication: isEnabled, }); }, + + updateReleaseDeviceSetting: (teamId: number, isEnabled: boolean) => { + const { MDM_SETUP_EXPERIENCE } = endpoints; + + const body: IUpdateSetupExperienceBody = { + enable_release_device_manually: isEnabled, + }; + + if (teamId !== API_NO_TEAM_ID) { + body.team_id = teamId; + } + + return sendRequest("PATCH", MDM_SETUP_EXPERIENCE, body); + }, + getSetupEnrollmentProfile: (teamId?: number) => { + const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; + if (!teamId || teamId === API_NO_TEAM_ID) { + return sendRequest("GET", MDM_APPLE_SETUP_ENROLLMENT_PROFILE); + } + + const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams( + { team_id: teamId } + )}`; + return sendRequest("GET", path); + }, + uploadSetupEnrollmentProfile: (file: File, teamId: number) => { + const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; + + const reader = new FileReader(); + reader.readAsText(file); + + return new Promise((resolve, reject) => { + reader.addEventListener("load", () => { + try { + const body: Record = { + name: file.name, + enrollment_profile: JSON.parse(reader.result as string), + }; + if (teamId !== API_NO_TEAM_ID) { + body.team_id = teamId; + } + resolve( + sendRequest("POST", MDM_APPLE_SETUP_ENROLLMENT_PROFILE, body) + ); + } catch { + // catches invalid JSON + reject("Couldn’t upload. The file should include valid JSON."); + } + }); + }); + }, + deleteSetupEnrollmentProfile: (teamId: number) => { + const { MDM_APPLE_SETUP_ENROLLMENT_PROFILE } = endpoints; + if (teamId === API_NO_TEAM_ID) { + return sendRequest("DELETE", MDM_APPLE_SETUP_ENROLLMENT_PROFILE); + } + + const path = `${MDM_APPLE_SETUP_ENROLLMENT_PROFILE}?${buildQueryStringFromParams( + { team_id: teamId } + )}`; + return sendRequest("DELETE", path); + }, }; export default mdmService; diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index 3c7e1a2619..eced1b9c6d 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -139,6 +139,16 @@ export default { return sendRequest("PATCH", path, requestBody); }, + + /** + * updates the team config. This can take any partial data that is in the team config. + */ + updateConfig: (data: any, teamId: number): Promise => { + const { TEAMS } = endpoints; + const path = `${TEAMS}/${teamId}`; + return sendRequest("PATCH", path, data); + }, + addUsers: (teamId: number | undefined, newUsers: INewTeamUsersBody) => { if (!teamId || teamId <= API_NO_TEAM_ID) { return Promise.reject( diff --git a/frontend/utilities/date_format/date_format.tests.ts b/frontend/utilities/date_format/date_format.tests.ts new file mode 100644 index 0000000000..102f34d048 --- /dev/null +++ b/frontend/utilities/date_format/date_format.tests.ts @@ -0,0 +1,13 @@ +import { uploadedFromNow } from "."; + +describe("date_format", () => { + describe("uploadedFromNow util", () => { + it("returns an user friendly uploaded at message", () => { + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - 2); + const twoDaysAgo = currentDate.toISOString(); + + expect(uploadedFromNow(twoDaysAgo)).toEqual("Uploaded 2 days ago"); + }); + }); +}); diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts new file mode 100644 index 0000000000..356eaef6f2 --- /dev/null +++ b/frontend/utilities/date_format/index.ts @@ -0,0 +1,6 @@ +import { formatDistanceToNow } from "date-fns"; + +// eslint-disable-next-line import/prefer-default-export +export const uploadedFromNow = (date: string) => { + return `Uploaded ${formatDistanceToNow(new Date(date))} ago`; +}; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index b54ec4fd97..58c2b4c5db 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -79,11 +79,13 @@ export default { } return `/api/mdm/apple/enroll?${query}`; }, + MDM_APPLE_SETUP_ENROLLMENT_PROFILE: `/${API_VERSION}/fleet/mdm/apple/enrollment_profile`, MDM_BOOTSTRAP_PACKAGE_METADATA: (teamId: number) => `/${API_VERSION}/fleet/mdm/bootstrap/${teamId}/metadata`, MDM_BOOTSTRAP_PACKAGE: `/${API_VERSION}/fleet/mdm/bootstrap`, MDM_BOOTSTRAP_PACKAGE_SUMMARY: `/${API_VERSION}/fleet/mdm/bootstrap/summary`, MDM_SETUP: `/${API_VERSION}/fleet/mdm/apple/setup`, + MDM_SETUP_EXPERIENCE: `/${API_VERSION}/fleet/setup_experience`, MDM_EULA: (token: string) => `/${API_VERSION}/fleet/mdm/setup/eula/${token}`, MDM_EULA_UPLOAD: `/${API_VERSION}/fleet/mdm/setup/eula`, MDM_EULA_METADATA: `/${API_VERSION}/fleet/mdm/setup/eula/metadata`, From 74d60dca4dfd8ec8cd80bf7cc639c59b1a7d2f97 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 26 Mar 2024 13:41:40 -0400 Subject: [PATCH 5/6] Update/fix/add tests for puppet-related flow (preassign+match) (#17859) --- ee/server/service/mdm.go | 15 +- ee/server/service/mdm_export_for_test.go | 27 ++ ee/server/service/mdm_external_test.go | 533 +++++++++++++++++++++++ ee/server/service/mdm_test.go | 430 ------------------ ee/server/service/teams.go | 13 +- server/service/appconfig.go | 4 +- 6 files changed, 563 insertions(+), 459 deletions(-) create mode 100644 ee/server/service/mdm_export_for_test.go create mode 100644 ee/server/service/mdm_external_test.go diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 3273402668..8f523a4d4a 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -228,7 +228,7 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl return err } - var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSReleaseDevice bool + var didUpdate, didUpdateMacOSEndUserAuth bool if payload.EnableEndUserAuthentication != nil { if ac.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication { ac.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication @@ -241,7 +241,6 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl if ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually { ac.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually) didUpdate = true - didUpdateMacOSReleaseDevice = true } } @@ -249,11 +248,6 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl if err := svc.ds.SaveAppConfig(ctx, ac); err != nil { return err } - if didUpdateMacOSReleaseDevice { - if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, nil, nil); err != nil { - return err - } - } if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, ac.MDM.MacOSSetup.EnableEndUserAuthentication, nil, nil); err != nil { return err @@ -263,13 +257,6 @@ func (svc *Service) updateAppConfigMDMAppleSetup(ctx context.Context, payload fl return nil } -func (svc *Service) updateMacOSSetupEnableReleaseDevice(ctx context.Context, enable bool, teamID *uint, teamName *string) error { - if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil { - return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") - } - return nil -} - func (svc *Service) updateMacOSSetupEnableEndUserAuth(ctx context.Context, enable bool, teamID *uint, teamName *string) error { if _, err := worker.QueueMacosSetupAssistantJob(ctx, svc.ds, svc.logger, worker.MacosSetupAssistantUpdateProfile, teamID); err != nil { return ctxerr.Wrap(ctx, err, "queue macos setup assistant update profile job") diff --git a/ee/server/service/mdm_export_for_test.go b/ee/server/service/mdm_export_for_test.go new file mode 100644 index 0000000000..c7bbf32fab --- /dev/null +++ b/ee/server/service/mdm_export_for_test.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + + "github.com/fleetdm/fleet/v4/server/fleet" +) + +// This file exports internal functions and methods only for testing purposes. +// Those are used by mdm_external_test.go which runs the tests as an external +// package to avoid import cycles, and as such needs to be able to call these +// unexported symbols. + +func (svc *Service) GetOrCreatePreassignTeam(ctx context.Context, groups []string) (*fleet.Team, error) { + return svc.getOrCreatePreassignTeam(ctx, groups) +} + +func TeamNameFromPreassignGroups(groups []string) string { + return teamNameFromPreassignGroups(groups) +} + +type NotFoundError = notFoundError + +var ( + TestCert = testCert + TestKey = testKey +) diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go new file mode 100644 index 0000000000..3f3db2f215 --- /dev/null +++ b/ee/server/service/mdm_external_test.go @@ -0,0 +1,533 @@ +package service_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/WatchBeam/clock" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/server/config" + authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/contexts/viewer" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage" + "github.com/fleetdm/fleet/v4/server/mock" + nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/fleetdm/fleet/v4/server/test" + "github.com/fleetdm/fleet/v4/server/worker" + kitlog "github.com/go-kit/kit/log" + "github.com/stretchr/testify/require" +) + +func setupMockDatastorePremiumService() (*mock.Store, *eeservice.Service, context.Context) { + ds := new(mock.Store) + lic := &fleet.LicenseInfo{Tier: fleet.TierPremium} + ctx := license.NewContext(context.Background(), lic) + + logger := kitlog.NewNopLogger() + fleetConfig := config.FleetConfig{ + MDM: config.MDMConfig{ + AppleSCEPCertBytes: eeservice.TestCert, + AppleSCEPKeyBytes: eeservice.TestKey, + }, + } + var depStorage nanodep_storage.AllDEPStorage = &nanodep_mock.Storage{} + + freeSvc, err := service.NewService( + ctx, + ds, + nil, + nil, + logger, + nil, + fleetConfig, + nil, + clock.C, + nil, + nil, + ds, + nil, + nil, + &fleet.NoOpGeoIP{}, + nil, + depStorage, + nil, + nil, + "", + nil, + nil, + ) + if err != nil { + panic(err) + } + svc, err := eeservice.NewService( + freeSvc, + ds, + logger, + fleetConfig, + nil, + clock.C, + depStorage, + nil, + "", + nil, + nil, + ) + if err != nil { + panic(err) + } + return ds, svc, ctx +} + +func TestGetOrCreatePreassignTeam(t *testing.T) { + ds, svc, ctx := setupMockDatastorePremiumService() + + ssoSettings := fleet.SSOProviderSettings{ + EntityID: "foo", + MetadataURL: "https://example.com/metadata.xml", + IssuerURI: "https://example.com", + } + appConfig := &fleet.AppConfig{MDM: fleet.MDM{ + EnabledAndConfigured: true, + EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: ssoSettings}, + MacOSSetup: fleet.MacOSSetup{ + BootstrapPackage: optjson.SetString("https://example.com/bootstrap.pkg"), + EnableEndUserAuthentication: true, + EnableReleaseDeviceManually: optjson.SetBool(true), + }, + }} + preassignGroups := []string{"one", "three"} + + // initialize team store with team one and two already created, one matches + // preassign group [0], two does not match any preassign group + team1 := &fleet.Team{ + ID: 1, + Name: preassignGroups[0], + } + team2 := &fleet.Team{ + ID: 2, + Name: "two", + Config: fleet.TeamConfig{ + MDM: fleet.TeamMDM{ + MacOSSetup: fleet.MacOSSetup{MacOSSetupAssistant: optjson.SetString("foo/bar")}, + }, + }, + } + teamStore := map[uint]*fleet.Team{1: team1, 2: team2} + + resetInvoked := func() { + ds.TeamByNameFuncInvoked = false + ds.NewTeamFuncInvoked = false + ds.SaveTeamFuncInvoked = false + ds.NewMDMAppleConfigProfileFuncInvoked = false + ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked = false + ds.AppConfigFuncInvoked = false + ds.NewJobFuncInvoked = false + ds.GetMDMAppleSetupAssistantFuncInvoked = false + ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked = false + } + setupDS := func(t *testing.T) { + resetInvoked() + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return appConfig, nil + } + ds.NewActivityFunc = func(ctx context.Context, u *fleet.User, a fleet.ActivityDetails) error { + return nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + for _, team := range teamStore { + if team.Name == name { + return team, nil + } + } + return nil, ctxerr.Wrap(ctx, &eeservice.NotFoundError{}) + } + ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { + tm, ok := teamStore[id] + if !ok { + return nil, errors.New("team id not found") + } + if id != tm.ID { + // sanity chec + return nil, errors.New("team id mismatch") + } + return tm, nil + } + ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + return nil, errors.New("not implemented") + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + return nil, errors.New("not implemented") + } + ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { + return nil, errors.New("not implemented") + } + ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc = func(ctx context.Context, teamID *uint, profileIdentifier string) error { + return errors.New("not implemented") + } + ds.CopyDefaultMDMAppleBootstrapPackageFunc = func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error { + return errors.New("not implemented") + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return nil, errors.New("not implemented") + } + ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { + return nil, errors.New("not implemented") + } + } + + authzCtx := &authz_ctx.AuthorizationContext{} + ctx = authz_ctx.NewContext(ctx, authzCtx) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: test.UserAdmin}) + actx, _ := authz_ctx.FromContext(ctx) + actx.SetChecked() + + t.Run("get preassign team", func(t *testing.T) { + setupDS(t) + + // preasign group corresponds to existing team so simply get it + team, err := svc.GetOrCreatePreassignTeam(ctx, preassignGroups[0:1]) + require.NoError(t, err) + require.Equal(t, uint(1), team.ID) + require.Equal(t, preassignGroups[0], team.Name) + require.True(t, ds.TeamByNameFuncInvoked) + require.False(t, ds.NewTeamFuncInvoked) + require.False(t, ds.SaveTeamFuncInvoked) + require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.False(t, ds.AppConfigFuncInvoked) + require.False(t, ds.NewJobFuncInvoked) + resetInvoked() + }) + + t.Run("create preassign team", func(t *testing.T) { + setupDS(t) + + lastTeamID := uint(0) + ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + for _, tm := range teamStore { + if tm.Name == team.Name { + return nil, errors.New("team name already exists") + } + } + id := uint(len(teamStore) + 1) + _, ok := teamStore[id] + require.False(t, ok) // sanity check + team.ID = id + teamStore[id] = team + lastTeamID = id + return team, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + tm, ok := teamStore[team.ID] + if !ok { + return nil, errors.New("invalid team id") + } + require.Equal(t, tm.ID, team.ID) // sanity check + require.Equal(t, tm.Name, team.Name) // sanity check + // NOTE: BootstrapPackage gets set by CopyDefaultMDMAppleBootstrapPackage + // require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // set to default + require.Equal(t, appConfig.MDM.MacOSSetup.MacOSSetupAssistant, team.Config.MDM.MacOSSetup.MacOSSetupAssistant) // set to default + require.Equal(t, appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // set to default + teamStore[tm.ID] = team + return team, nil + } + ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { + require.Equal(t, lastTeamID, *profile.TeamID) + require.Equal(t, mobileconfig.FleetFileVaultPayloadIdentifier, profile.Identifier) + return &profile, nil + } + ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc = func(ctx context.Context, teamID *uint, profileIdentifier string) error { + require.Equal(t, lastTeamID, *teamID) + require.Equal(t, mobileconfig.FleetFileVaultPayloadIdentifier, profileIdentifier) + return nil + } + ds.CopyDefaultMDMAppleBootstrapPackageFunc = func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error { + require.Equal(t, lastTeamID, toTeamID) + require.NotNil(t, ac) + require.Equal(t, "https://example.com/bootstrap.pkg", ac.MDM.MacOSSetup.BootstrapPackage.Value) + teamStore[toTeamID].Config.MDM.MacOSSetup.BootstrapPackage = optjson.SetString(ac.MDM.MacOSSetup.BootstrapPackage.Value) + return nil + } + var jobTask string + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + // first task is UpdateProfile, next is ProfileChanged (when setup assistant is set) + if jobTask == "" { + jobTask = string(worker.MacosSetupAssistantUpdateProfile) + } else { + jobTask = string(worker.MacosSetupAssistantProfileChanged) + } + wantArgs, err := json.Marshal(map[string]interface{}{ + "task": jobTask, + "team_id": lastTeamID, + }) + require.NoError(t, err) + wantJob := &fleet.Job{ + Name: "macos_setup_assistant", + Args: (*json.RawMessage)(&wantArgs), + State: fleet.JobStateQueued, + } + require.Equal(t, wantJob.Name, job.Name) + require.Equal(t, string(*wantJob.Args), string(*job.Args)) + require.Equal(t, wantJob.State, job.State) + return job, nil + } + setupAsstByTeam := make(map[uint]*fleet.MDMAppleSetupAssistant) + globalSetupAsst := &fleet.MDMAppleSetupAssistant{ + ID: 15, + TeamID: nil, + Name: "test asst", + Profile: json.RawMessage(`{"foo": "bar"}`), + ProfileUUID: "abc-def", + } + setupAsstByTeam[0] = globalSetupAsst + ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { + var tmID uint + if teamID != nil { + tmID = *teamID + } + asst := setupAsstByTeam[tmID] + if asst == nil { + return nil, eeservice.NotFoundError{} + } + return asst, nil + } + ds.SetOrUpdateMDMAppleSetupAssistantFunc = func(ctx context.Context, asst *fleet.MDMAppleSetupAssistant) (*fleet.MDMAppleSetupAssistant, error) { + require.Equal(t, globalSetupAsst.Name, asst.Name) + require.JSONEq(t, string(globalSetupAsst.Profile), string(asst.Profile)) + require.NotNil(t, asst.TeamID) + require.EqualValues(t, lastTeamID, *asst.TeamID) + setupAsstByTeam[*asst.TeamID] = asst + return asst, nil + } + + // new team ("one - three") is created with bootstrap package and end user auth based on app config + team, err := svc.GetOrCreatePreassignTeam(ctx, preassignGroups) + require.NoError(t, err) + require.Equal(t, uint(3), team.ID) + require.Equal(t, eeservice.TeamNameFromPreassignGroups(preassignGroups), team.Name) + require.True(t, ds.TeamByNameFuncInvoked) + require.True(t, ds.NewTeamFuncInvoked) + require.True(t, ds.SaveTeamFuncInvoked) + require.True(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.True(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.True(t, ds.AppConfigFuncInvoked) + require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked) + require.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) + require.NotEmpty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value, team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value) + require.True(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.True(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.True(t, ds.NewJobFuncInvoked) + resetInvoked() + + jobTask = "" + + // when called again, simply get the previously created team + team, err = svc.GetOrCreatePreassignTeam(ctx, preassignGroups) + require.NoError(t, err) + require.Equal(t, uint(3), team.ID) + require.Equal(t, eeservice.TeamNameFromPreassignGroups(preassignGroups), team.Name) + require.True(t, ds.TeamByNameFuncInvoked) + require.False(t, ds.NewTeamFuncInvoked) + require.False(t, ds.SaveTeamFuncInvoked) + require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.False(t, ds.AppConfigFuncInvoked) + require.False(t, ds.NewJobFuncInvoked) + require.False(t, ds.GetMDMAppleSetupAssistantFuncInvoked) + require.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) + require.NotEmpty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value, team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value) + require.True(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.True(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + resetInvoked() + + jobTask = "" + + // when a custom setup assistant is not set for "no team", we don't create + // a custom setup assistant + setupAsstByTeam[0] = nil + + preassignGrousWithFoo := append(preassignGroups, "foo") + team, err = svc.GetOrCreatePreassignTeam(ctx, preassignGrousWithFoo) + require.NoError(t, err) + require.Equal(t, uint(4), team.ID) + require.Equal(t, eeservice.TeamNameFromPreassignGroups(preassignGrousWithFoo), team.Name) + require.True(t, ds.TeamByNameFuncInvoked) + require.True(t, ds.NewTeamFuncInvoked) + require.True(t, ds.SaveTeamFuncInvoked) + require.True(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.True(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.True(t, ds.AppConfigFuncInvoked) + require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked) + require.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) + require.NotEmpty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) + require.True(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) + require.True(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + require.Equal(t, appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) + resetInvoked() + }) + + t.Run("modify team via apply team spec", func(t *testing.T) { + setupDS(t) + + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + tm, ok := teamStore[team.ID] + if !ok { + return nil, errors.New("invalid team id") + } + require.Equal(t, tm.ID, team.ID) // sanity check + require.Equal(t, tm.Name, team.Name) // sanity check + require.Empty(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not modified + require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not modified + require.False(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // not modified + return teamStore[tm.ID], nil + } + + // apply team spec does not apply defaults + spec := &fleet.TeamSpec{ + Name: team2.Name, + } + _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) + require.NoError(t, err) + require.True(t, ds.SaveTeamFuncInvoked) + require.True(t, ds.AppConfigFuncInvoked) + require.True(t, ds.TeamByNameFuncInvoked) + require.False(t, ds.NewTeamFuncInvoked) + require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.False(t, ds.NewJobFuncInvoked) + resetInvoked() + }) + + t.Run("new team", func(t *testing.T) { + setupDS(t) + + ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + for _, tm := range teamStore { + if tm.Name == team.Name { + return nil, errors.New("team name already exists") + } + } + id := uint(len(teamStore) + 1) + _, ok := teamStore[id] + require.False(t, ok) // sanity check + require.Equal(t, "new team", team.Name) + require.Equal(t, "new description", team.Description) + require.False(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not set + require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not set + require.False(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // not set + team.ID = id + teamStore[id] = team + return team, nil + } + + // new team does not apply defaults + _, err := svc.NewTeam(ctx, fleet.TeamPayload{ + Name: ptr.String("new team"), + Description: ptr.String("new description"), + }) + require.NoError(t, err) + require.True(t, ds.NewTeamFuncInvoked) + require.True(t, ds.AppConfigFuncInvoked) + require.False(t, ds.TeamByNameFuncInvoked) + require.False(t, ds.SaveTeamFuncInvoked) + require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.False(t, ds.NewJobFuncInvoked) + resetInvoked() + }) + + t.Run("apply team spec", func(t *testing.T) { + setupDS(t) + + ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + for _, tm := range teamStore { + if tm.Name == team.Name { + return nil, errors.New("team name already exists") + } + } + id := uint(len(teamStore) + 1) + _, ok := teamStore[id] + require.False(t, ok) // sanity check + require.Equal(t, "new team spec", team.Name) // set + require.Equal(t, "12.0", team.Config.MDM.MacOSUpdates.MinimumVersion.Value) // set + require.Equal(t, "2024-01-01", team.Config.MDM.MacOSUpdates.Deadline.Value) // set + require.False(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not set + require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not set + require.False(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // not set + team.ID = id + teamStore[id] = team + return team, nil + } + ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { + tm, ok := teamStore[team.ID] + if !ok { + return nil, errors.New("invalid team id") + } + require.Equal(t, tm.ID, team.ID) // sanity check + require.Equal(t, tm.Name, team.Name) // sanity check + require.Equal(t, "12.0", team.Config.MDM.MacOSUpdates.MinimumVersion.Value) // unchanged + require.Equal(t, "2025-01-01", team.Config.MDM.MacOSUpdates.Deadline.Value) // modified + require.Empty(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not set + require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not set + require.False(t, team.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // not set + + return teamStore[tm.ID], nil + } + + spec := &fleet.TeamSpec{ + Name: "new team spec", + MDM: fleet.TeamSpecMDM{ + MacOSUpdates: fleet.MacOSUpdates{ + MinimumVersion: optjson.SetString("12.0"), + Deadline: optjson.SetString("2024-01-01"), + }, + }, + } + + // apply team spec creates new team without defaults + _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) + require.NoError(t, err) + require.True(t, ds.NewTeamFuncInvoked) + require.True(t, ds.AppConfigFuncInvoked) + require.True(t, ds.TeamByNameFuncInvoked) + require.False(t, ds.SaveTeamFuncInvoked) + require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.False(t, ds.NewJobFuncInvoked) + resetInvoked() + + // apply team spec edits existing team without applying defaults + spec.MDM.MacOSUpdates.Deadline = optjson.SetString("2025-01-01") + _, err = svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) + require.NoError(t, err) + require.True(t, ds.SaveTeamFuncInvoked) + require.True(t, ds.AppConfigFuncInvoked) + require.True(t, ds.TeamByNameFuncInvoked) + require.False(t, ds.NewTeamFuncInvoked) + require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) + require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) + require.False(t, ds.NewJobFuncInvoked) + resetInvoked() + }) +} diff --git a/ee/server/service/mdm_test.go b/ee/server/service/mdm_test.go index f64e294ead..ed5abe56a1 100644 --- a/ee/server/service/mdm_test.go +++ b/ee/server/service/mdm_test.go @@ -2,24 +2,16 @@ package service import ( "context" - "encoding/json" "errors" "strings" "testing" - "github.com/fleetdm/fleet/v4/pkg/optjson" - "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" - "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mock" "github.com/fleetdm/fleet/v4/server/ptr" - "github.com/fleetdm/fleet/v4/server/test" - "github.com/fleetdm/fleet/v4/server/worker" - "github.com/go-kit/log" "github.com/stretchr/testify/require" ) @@ -90,428 +82,6 @@ func TestMDMAppleDisableFileVaultAndEscrow(t *testing.T) { require.True(t, ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFuncInvoked) } -func TestGetOrCreatePreassignTeam(t *testing.T) { - t.Skip("this test requires a mock or a way to import service.Service") - ds, svc := setup(t) - a, err := authz.NewAuthorizer() - require.NoError(t, err) - svc.authz = a - svc.logger = log.NewNopLogger() - - ssoSettings := fleet.SSOProviderSettings{ - EntityID: "foo", - MetadataURL: "https://example.com/metadata.xml", - IssuerURI: "https://example.com", - } - appConfig := &fleet.AppConfig{MDM: fleet.MDM{ - EnabledAndConfigured: true, - EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: ssoSettings}, - MacOSSetup: fleet.MacOSSetup{ - BootstrapPackage: optjson.SetString("https://example.com/bootstrap.pkg"), - EnableEndUserAuthentication: true, - }, - }} - preassignGroups := []string{"one", "three"} - // initialize team store with team one and two already created - team1 := &fleet.Team{ - ID: 1, - Name: preassignGroups[0], - } - team2 := &fleet.Team{ - ID: 2, - Name: "two", - Config: fleet.TeamConfig{ - MDM: fleet.TeamMDM{ - MacOSSetup: fleet.MacOSSetup{MacOSSetupAssistant: optjson.SetString("foo/bar")}, - }, - }, - } - teamStore := map[uint]*fleet.Team{1: team1, 2: team2} - - resetInvoked := func() { - ds.TeamByNameFuncInvoked = false - ds.NewTeamFuncInvoked = false - ds.SaveTeamFuncInvoked = false - ds.NewMDMAppleConfigProfileFuncInvoked = false - ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked = false - ds.AppConfigFuncInvoked = false - ds.NewJobFuncInvoked = false - ds.GetMDMAppleSetupAssistantFuncInvoked = false - ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked = false - } - setupDS := func(t *testing.T) { - resetInvoked() - - ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return appConfig, nil - } - ds.NewActivityFunc = func(ctx context.Context, u *fleet.User, a fleet.ActivityDetails) error { - return nil - } - ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { - for _, team := range teamStore { - if team.Name == name { - return team, nil - } - } - return nil, ctxerr.Wrap(ctx, ¬FoundError{}) - } - ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { - tm, ok := teamStore[id] - if !ok { - return nil, errors.New("team id not found") - } - if id != tm.ID { - // sanity chec - return nil, errors.New("team id mismatch") - } - return tm, nil - } - ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - return nil, errors.New("not implemented") - } - ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - return nil, errors.New("not implemented") - } - ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { - return nil, errors.New("not implemented") - } - ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc = func(ctx context.Context, teamID *uint, profileIdentifier string) error { - return errors.New("not implemented") - } - ds.CopyDefaultMDMAppleBootstrapPackageFunc = func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error { - return errors.New("not implemented") - } - ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { - return nil, errors.New("not implemented") - } - ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { - return nil, errors.New("not implemented") - } - } - - ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: test.UserAdmin}) - - t.Run("get preassign team", func(t *testing.T) { - setupDS(t) - - // preasign group corresponds to existing team so simply get it - team, err := svc.getOrCreatePreassignTeam(ctx, preassignGroups[0:1]) - require.NoError(t, err) - require.Equal(t, uint(1), team.ID) - require.Equal(t, preassignGroups[0], team.Name) - require.True(t, ds.TeamByNameFuncInvoked) - require.False(t, ds.NewTeamFuncInvoked) - require.False(t, ds.SaveTeamFuncInvoked) - require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.False(t, ds.AppConfigFuncInvoked) - require.False(t, ds.NewJobFuncInvoked) - resetInvoked() - }) - - t.Run("create preassign team", func(t *testing.T) { - // setup ds with assertions for this test - setupDS(t) - lastTeamID := uint(0) - ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - for _, tm := range teamStore { - if tm.Name == team.Name { - return nil, errors.New("team name already exists") - } - } - id := uint(len(teamStore) + 1) - _, ok := teamStore[id] - require.False(t, ok) // sanity check - team.ID = id - teamStore[id] = team - lastTeamID = id - return team, nil - } - ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - tm, ok := teamStore[team.ID] - if !ok { - return nil, errors.New("invalid team id") - } - require.Equal(t, tm.ID, team.ID) // sanity check - require.Equal(t, tm.Name, team.Name) // sanity check - // // NOTE: BootstrapPackage is currently ignored by svc.ModifyTeam and gets set - // // instead by CopyDefaultMDMAppleBootstrapPackage below - // require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // set to default - require.Equal(t, appConfig.MDM.MacOSSetup.MacOSSetupAssistant, team.Config.MDM.MacOSSetup.MacOSSetupAssistant) // set to default - teamStore[tm.ID] = team - return team, nil - } - ds.NewMDMAppleConfigProfileFunc = func(ctx context.Context, profile fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) { - require.Equal(t, lastTeamID, *profile.TeamID) - require.Equal(t, mobileconfig.FleetFileVaultPayloadIdentifier, profile.Identifier) - return &profile, nil - } - ds.DeleteMDMAppleConfigProfileByTeamAndIdentifierFunc = func(ctx context.Context, teamID *uint, profileIdentifier string) error { - require.Equal(t, lastTeamID, *teamID) - require.Equal(t, mobileconfig.FleetFileVaultPayloadIdentifier, profileIdentifier) - return nil - } - ds.CopyDefaultMDMAppleBootstrapPackageFunc = func(ctx context.Context, ac *fleet.AppConfig, toTeamID uint) error { - require.Equal(t, lastTeamID, toTeamID) - require.NotNil(t, ac) - require.Equal(t, "https://example.com/bootstrap.pkg", ac.MDM.MacOSSetup.BootstrapPackage.Value) - teamStore[toTeamID].Config.MDM.MacOSSetup.BootstrapPackage = optjson.SetString(ac.MDM.MacOSSetup.BootstrapPackage.Value) - return nil - } - ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { - wantArgs, err := json.Marshal(map[string]interface{}{ - "task": worker.MacosSetupAssistantUpdateProfile, - "team_id": lastTeamID, - }) - require.NoError(t, err) - wantJob := &fleet.Job{ - Name: "macos_setup_assistant", - Args: (*json.RawMessage)(&wantArgs), - State: fleet.JobStateQueued, - } - require.Equal(t, wantJob.Name, job.Name) - require.Equal(t, string(*wantJob.Args), string(*job.Args)) - require.Equal(t, wantJob.State, job.State) - return job, nil - } - globalSetupAsst := &fleet.MDMAppleSetupAssistant{ - ID: 15, - TeamID: nil, - Name: "test asst", - Profile: json.RawMessage(`{"foo": "bar"}`), - ProfileUUID: "abc-def", - } - getSetupAsstFuncCalls := 0 - ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { - // first call is to grab the global team setup assistant, the - // rest are for the team being created - if getSetupAsstFuncCalls == 0 { - require.Nil(t, teamID) - } else { - require.NotNil(t, teamID) - require.EqualValues(t, lastTeamID, *teamID) - } - getSetupAsstFuncCalls++ - return globalSetupAsst, nil - } - ds.SetOrUpdateMDMAppleSetupAssistantFunc = func(ctx context.Context, asst *fleet.MDMAppleSetupAssistant) (*fleet.MDMAppleSetupAssistant, error) { - require.Equal(t, globalSetupAsst.Name, asst.Name) - require.JSONEq(t, string(globalSetupAsst.Profile), string(asst.Profile)) - require.NotNil(t, asst.TeamID) - require.EqualValues(t, lastTeamID, *asst.TeamID) - return asst, nil - } - - // new team is created with bootstrap package and end user auth based on app config - team, err := svc.getOrCreatePreassignTeam(ctx, preassignGroups) - require.NoError(t, err) - require.Equal(t, uint(3), team.ID) - require.Equal(t, teamNameFromPreassignGroups(preassignGroups), team.Name) - require.True(t, ds.TeamByNameFuncInvoked) - require.True(t, ds.NewTeamFuncInvoked) - require.True(t, ds.SaveTeamFuncInvoked) - require.True(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.True(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.True(t, ds.AppConfigFuncInvoked) - require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked) - require.True(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) - require.NotEmpty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.Equal(t, appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value, team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value) - require.True(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) - require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) - require.True(t, ds.NewJobFuncInvoked) - resetInvoked() - - // when called again, simply get the previously created team - team, err = svc.getOrCreatePreassignTeam(ctx, preassignGroups) - require.NoError(t, err) - require.Equal(t, uint(3), team.ID) - require.Equal(t, teamNameFromPreassignGroups(preassignGroups), team.Name) - require.True(t, ds.TeamByNameFuncInvoked) - require.False(t, ds.NewTeamFuncInvoked) - require.False(t, ds.SaveTeamFuncInvoked) - require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.False(t, ds.AppConfigFuncInvoked) - require.False(t, ds.NewJobFuncInvoked) - require.False(t, ds.GetMDMAppleSetupAssistantFuncInvoked) - require.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) - require.NotEmpty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.Equal(t, appConfig.MDM.MacOSSetup.MacOSSetupAssistant.Value, team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value) - require.True(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) - require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) - resetInvoked() - - // when a custom setup assistant is not set for "no team", we don't create a custom setup assistant - ds.GetMDMAppleSetupAssistantFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) { - require.Nil(t, teamID) - return nil, ctxerr.Wrap(ctx, ¬FoundError{}) - } - preassignGrousWithFoo := append(preassignGroups, "foo") - team, err = svc.getOrCreatePreassignTeam(ctx, preassignGrousWithFoo) - require.NoError(t, err) - require.Equal(t, uint(4), team.ID) - require.Equal(t, teamNameFromPreassignGroups(preassignGrousWithFoo), team.Name) - require.True(t, ds.TeamByNameFuncInvoked) - require.True(t, ds.NewTeamFuncInvoked) - require.True(t, ds.SaveTeamFuncInvoked) - require.True(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.True(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.True(t, ds.AppConfigFuncInvoked) - require.True(t, ds.GetMDMAppleSetupAssistantFuncInvoked) - require.False(t, ds.SetOrUpdateMDMAppleSetupAssistantFuncInvoked) - require.NotEmpty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.Equal(t, appConfig.MDM.MacOSSetup.BootstrapPackage.Value, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) - require.True(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) - require.Equal(t, appConfig.MDM.MacOSSetup.EnableEndUserAuthentication, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) - resetInvoked() - }) - - t.Run("modify team", func(t *testing.T) { - // setup ds with assertions this test - setupDS(t) - ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - tm, ok := teamStore[team.ID] - if !ok { - return nil, errors.New("invalid team id") - } - require.Equal(t, tm.ID, team.ID) // sanity check - require.Equal(t, tm.Name, team.Name) // sanity check - require.Empty(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not modified - require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not modified - require.NotEmpty(t, team.Description) // modified - teamStore[tm.ID].Description = team.Description - return teamStore[tm.ID], nil - } - - // modify team does not apply defaults - _, err := svc.ModifyTeam(ctx, 2, fleet.TeamPayload{Description: ptr.String("new description")}) - require.NoError(t, err) - require.True(t, ds.SaveTeamFuncInvoked) - require.True(t, ds.AppConfigFuncInvoked) - require.False(t, ds.TeamByNameFuncInvoked) - require.False(t, ds.NewTeamFuncInvoked) - require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.False(t, ds.NewJobFuncInvoked) - resetInvoked() - }) - - t.Run("new team", func(t *testing.T) { - // setup ds with assertions this test - setupDS(t) - ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - for _, tm := range teamStore { - if tm.Name == team.Name { - return nil, errors.New("team name already exists") - } - } - id := uint(len(teamStore) + 1) - _, ok := teamStore[id] - require.False(t, ok) // sanity check - require.Equal(t, "new team", team.Name) - require.Equal(t, "new description", team.Description) - require.Empty(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not set - require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not set - team.ID = id - teamStore[id] = team - return team, nil - } - - // new team does not apply defaults - _, err := svc.NewTeam(ctx, fleet.TeamPayload{ - Name: ptr.String("new team"), - Description: ptr.String("new description"), - }) - require.NoError(t, err) - require.True(t, ds.NewTeamFuncInvoked) - require.True(t, ds.AppConfigFuncInvoked) - require.False(t, ds.TeamByNameFuncInvoked) - require.False(t, ds.SaveTeamFuncInvoked) - require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.False(t, ds.NewJobFuncInvoked) - resetInvoked() - }) - - t.Run("apply team spec", func(t *testing.T) { - // setup ds with assertions this test - setupDS(t) - ds.NewTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - for _, tm := range teamStore { - if tm.Name == team.Name { - return nil, errors.New("team name already exists") - } - } - id := uint(len(teamStore) + 1) - _, ok := teamStore[id] - require.False(t, ok) // sanity check - require.Equal(t, "new team spec", team.Name) // set - require.Equal(t, "12.0", team.Config.MDM.MacOSUpdates.MinimumVersion.Value) // set - require.Equal(t, "2024-01-01", team.Config.MDM.MacOSUpdates.Deadline.Value) // set // not set - require.Empty(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not set - require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not set - team.ID = id - teamStore[id] = team - return team, nil - } - ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { - tm, ok := teamStore[team.ID] - if !ok { - return nil, errors.New("invalid team id") - } - require.Equal(t, tm.ID, team.ID) // sanity check - require.Equal(t, tm.Name, team.Name) // sanity check - require.Equal(t, "12.0", team.Config.MDM.MacOSUpdates.MinimumVersion.Value) // unchanged - require.Equal(t, "2025-01-01", team.Config.MDM.MacOSUpdates.Deadline.Value) // modified // not set - require.Empty(t, team.Config.MDM.MacOSSetup.EnableEndUserAuthentication) // not set - require.Empty(t, team.Config.MDM.MacOSSetup.BootstrapPackage.Value) // not set - - teamStore[tm.ID].Description = team.Description - return teamStore[tm.ID], nil - } - - spec := &fleet.TeamSpec{ - Name: "new team spec", - MDM: fleet.TeamSpecMDM{ - MacOSUpdates: fleet.MacOSUpdates{ - MinimumVersion: optjson.SetString("12.0"), - Deadline: optjson.SetString("2024-01-01"), - }, - }, - } - - // apply team spec creates new team without defaults - _, err := svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) - require.NoError(t, err) - require.True(t, ds.NewTeamFuncInvoked) - require.True(t, ds.AppConfigFuncInvoked) - require.True(t, ds.TeamByNameFuncInvoked) - require.False(t, ds.SaveTeamFuncInvoked) - require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.False(t, ds.NewJobFuncInvoked) - resetInvoked() - - // apply team spec edits existing team without applying defaults - spec.MDM.MacOSUpdates.Deadline = optjson.SetString("2025-01-01") - _, err = svc.ApplyTeamSpecs(ctx, []*fleet.TeamSpec{spec}, fleet.ApplySpecOptions{}) - require.NoError(t, err) - require.True(t, ds.SaveTeamFuncInvoked) - require.True(t, ds.AppConfigFuncInvoked) - require.True(t, ds.TeamByNameFuncInvoked) - require.False(t, ds.NewTeamFuncInvoked) - require.False(t, ds.NewMDMAppleConfigProfileFuncInvoked) - require.False(t, ds.CopyDefaultMDMAppleBootstrapPackageFuncInvoked) - require.False(t, ds.NewJobFuncInvoked) - resetInvoked() - }) -} - var ( testCert = `-----BEGIN CERTIFICATE----- MIID6DCCAdACFGX99Sw4aF2qKGLucoIWQRAXHrs1MA0GCSqGSIb3DQEBCwUAMDUx diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index bf5b29f029..4f9ab22e91 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1136,11 +1136,6 @@ func (svc *Service) editTeamFromSpec( } } - if didUpdateEnableReleaseManually { - if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, spec.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, &team.ID, &team.Name); err != nil { - return err - } - } if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil { return err @@ -1246,7 +1241,7 @@ func (svc *Service) updateTeamMDMDiskEncryption(ctx context.Context, tm *fleet.T } func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, payload fleet.MDMAppleSetupPayload) error { - var didUpdate, didUpdateMacOSEndUserAuth, didUpdateMacOSReleaseDevice bool + var didUpdate, didUpdateMacOSEndUserAuth bool if payload.EnableEndUserAuthentication != nil { if tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication != *payload.EnableEndUserAuthentication { tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication = *payload.EnableEndUserAuthentication @@ -1259,7 +1254,6 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, if tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != *payload.EnableReleaseDeviceManually { tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually = optjson.SetBool(*payload.EnableReleaseDeviceManually) didUpdate = true - didUpdateMacOSReleaseDevice = true } } @@ -1267,11 +1261,6 @@ func (svc *Service) updateTeamMDMAppleSetup(ctx context.Context, tm *fleet.Team, if _, err := svc.ds.SaveTeam(ctx, tm); err != nil { return err } - if didUpdateMacOSReleaseDevice { - if err := svc.updateMacOSSetupEnableReleaseDevice(ctx, tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value, &tm.ID, &tm.Name); err != nil { - return err - } - } if didUpdateMacOSEndUserAuth { if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, tm.Config.MDM.MacOSSetup.EnableEndUserAuthentication, &tm.ID, &tm.Name); err != nil { return err diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 701dbb0878..d0454b027a 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -630,9 +630,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle mdmSSOSettingsChanged := oldAppConfig.MDM.EndUserAuthentication.SSOProviderSettings != appConfig.MDM.EndUserAuthentication.SSOProviderSettings serverURLChanged := oldAppConfig.ServerSettings.ServerURL != appConfig.ServerSettings.ServerURL - mdmEnableReleaseDeviceChanged := oldAppConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value != - appConfig.MDM.MacOSSetup.EnableReleaseDeviceManually.Value - if (mdmEnableEndUserAuthChanged || mdmEnableReleaseDeviceChanged || mdmSSOSettingsChanged || serverURLChanged) && license.IsPremium() { + if (mdmEnableEndUserAuthChanged || mdmSSOSettingsChanged || serverURLChanged) && license.IsPremium() { if err := svc.EnterpriseOverrides.MDMAppleSyncDEPProfiles(ctx); err != nil { return nil, ctxerr.Wrap(ctx, err, "sync DEP profiles") } From 98bccf54e3e38792ca8b2c27bde7464c26e12d42 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 27 Mar 2024 08:39:41 -0400 Subject: [PATCH 6/6] Fix test with expected Google Calendar integration --- cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index 78e6052da4..10a3d36b13 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -8,6 +8,8 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_settings: