diff --git a/changes/issue-13281-backend-changes b/changes/issue-13281-backend-changes new file mode 100644 index 0000000000..2ec2a90727 --- /dev/null +++ b/changes/issue-13281-backend-changes @@ -0,0 +1,4 @@ +- Introduced `POST /mdm/profiles` for uploading Windows or macOS custom profiles. +- New endpoints for managing MDM profiles: `DELETE /mdm/profiles/{id}`, `GET /mdm/profiles/{id}`, `GET /mdm/profiles` (paginated list), `GET /mdm/profiles/summary`. +- Updated `GET /api/v1/hosts/:id` to include Windows MDM profiles. +- Fleetctl now supports configuration of Windows MDM profiles. diff --git a/changes/issue-14359-windows-profiles b/changes/issue-14359-windows-profiles new file mode 100644 index 0000000000..ac1545bf91 --- /dev/null +++ b/changes/issue-14359-windows-profiles @@ -0,0 +1 @@ +- add UI to upload, delete, download, and view windows custom MDM profiles. diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 7648e6ce4c..65eb427f9d 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -976,9 +976,9 @@ func newMDMProfileManager( schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error { return service.ReconcileAppleProfiles(ctx, ds, commander, logger) }), - //schedule.WithJob("manage_windows_profiles", func(ctx context.Context) error { - // return service.ReconcileWindowsProfiles(ctx, ds, logger) - //}), + schedule.WithJob("manage_windows_profiles", func(ctx context.Context) error { + return service.ReconcileWindowsProfiles(ctx, ds, logger) + }), ) return s, nil diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index c4e7bbadb4..b50b6469ca 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -160,7 +160,7 @@ func TestApplyTeamSpecs(t *testing.T) { return nil } - ds.BatchSetMDMAppleProfilesFunc = func(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error { return nil } @@ -906,7 +906,7 @@ func TestApplyAsGitOps(t *testing.T) { teamEnrollSecrets = secrets return nil } - ds.BatchSetMDMAppleProfilesFunc = func(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs, profileIDs []uint, profileUUIDs, hostUUIDs []string) error { @@ -1086,7 +1086,7 @@ spec: }, savedTeam.Config.MDM) assert.Equal(t, []*fleet.EnrollSecret{{Secret: "BBB"}}, teamEnrollSecrets) assert.True(t, ds.ApplyEnrollSecretsFuncInvoked) - assert.True(t, ds.BatchSetMDMAppleProfilesFuncInvoked) + assert.True(t, ds.BatchSetMDMProfilesFuncInvoked) // add macos setup assistant to team name = writeTmpYml(t, fmt.Sprintf(` diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 215a4cf71a..d32af906ce 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -2015,7 +2015,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { } return nil, fmt.Errorf("team not found: %s", name) } - ds.BatchSetMDMAppleProfilesFunc = func(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error { return nil } ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs, profileIDs []uint, profileUUIDs, uuids []string) error { diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index b8ba5c2a89..36e04eb41d 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -104,6 +104,9 @@ "enable_end_user_authentication": false, "macos_setup_assistant": null }, + "windows_settings": { + "custom_settings": null + }, "end_user_authentication": { "entity_id": "", "issuer_uri": "", diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index c6d273df1c..9747451485 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -33,6 +33,8 @@ spec: bootstrap_package: enable_end_user_authentication: false macos_setup_assistant: + windows_settings: + custom_settings: null end_user_authentication: idp_name: "" issuer_uri: "" diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 81ef405ac5..1fec3ce9e6 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -62,6 +62,9 @@ "enable_end_user_authentication": false, "macos_setup_assistant": null }, + "windows_settings": { + "custom_settings": null + }, "end_user_authentication": { "entity_id": "", "issuer_uri": "", diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 1ddb36b944..1b7eb7ac36 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -33,6 +33,8 @@ spec: bootstrap_package: enable_end_user_authentication: false macos_setup_assistant: + windows_settings: + custom_settings: end_user_authentication: idp_name: "" issuer_uri: "" diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index c8b587d149..c078c4f6bd 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -36,6 +36,9 @@ "bootstrap_package": null, "enable_end_user_authentication": false, "macos_setup_assistant": null + }, + "windows_settings": { + "custom_settings": null } }, "scripts": null, @@ -97,6 +100,9 @@ "bootstrap_package": null, "enable_end_user_authentication": false, "macos_setup_assistant": null + }, + "windows_settings": { + "custom_settings": null } }, "scripts": null, diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 61643d07db..59a5c21964 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -13,6 +13,8 @@ spec: deadline: null macos_settings: custom_settings: + windows_settings: + custom_settings: macos_setup: bootstrap_package: enable_end_user_authentication: false @@ -43,6 +45,8 @@ spec: deadline: "2021-12-14" macos_settings: custom_settings: + windows_settings: + custom_settings: macos_setup: bootstrap_package: enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index d2de051703..67cb539b3d 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -26,6 +26,8 @@ spec: webhook_url: "" macos_settings: custom_settings: null + windows_settings: + custom_settings: null macos_setup: bootstrap_package: null enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index f343987160..1c2e8b6130 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -26,6 +26,8 @@ spec: webhook_url: "" macos_settings: custom_settings: null + windows_settings: + custom_settings: null macos_setup: bootstrap_package: %s enable_end_user_authentication: false diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 4c50064fa2..95f847be2e 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -10,6 +10,8 @@ spec: enable_disk_encryption: false macos_settings: custom_settings: null + windows_settings: + custom_settings: null macos_setup: bootstrap_package: null enable_end_user_authentication: false @@ -31,6 +33,8 @@ spec: enable_disk_encryption: false macos_settings: custom_settings: null + windows_settings: + custom_settings: null macos_setup: bootstrap_package: null macos_setup_assistant: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 0af52083b2..4acfb24742 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -10,6 +10,8 @@ spec: enable_disk_encryption: false macos_settings: custom_settings: null + windows_settings: + custom_settings: null macos_setup: bootstrap_package: %s enable_end_user_authentication: false @@ -31,6 +33,8 @@ spec: enable_disk_encryption: false macos_settings: custom_settings: null + windows_settings: + custom_settings: null macos_setup: bootstrap_package: %s macos_setup_assistant: %s diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index a32bec16f3..e9972438aa 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -17,6 +17,8 @@ spec: macos_updates: deadline: null minimum_version: null + windows_settings: + custom_settings: null scripts: null name: tm1 diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index be803dfece..4d2f3831af 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -935,16 +935,16 @@ func (svc *Service) editTeamFromSpec( } team.Config.MDM.MacOSSetup.EnableEndUserAuthentication = spec.MDM.MacOSSetup.EnableEndUserAuthentication - // if spec.MDM.WindowsSettings.CustomSettings.Set { - // if !appCfg.MDM.WindowsEnabledAndConfigured && - // len(spec.MDM.WindowsSettings.CustomSettings.Value) > 0 && - // !server.SliceStringsMatch(team.Config.MDM.WindowsSettings.CustomSettings.Value, spec.MDM.WindowsSettings.CustomSettings.Value) { - // return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_settings.custom_settings", - // `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) - // } + if spec.MDM.WindowsSettings.CustomSettings.Set { + if !appCfg.MDM.WindowsEnabledAndConfigured && + len(spec.MDM.WindowsSettings.CustomSettings.Value) > 0 && + !server.SliceStringsMatch(team.Config.MDM.WindowsSettings.CustomSettings.Value, spec.MDM.WindowsSettings.CustomSettings.Value) { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_settings.custom_settings", + `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) + } - // team.Config.MDM.WindowsSettings.CustomSettings = spec.MDM.WindowsSettings.CustomSettings - // } + team.Config.MDM.WindowsSettings.CustomSettings = spec.MDM.WindowsSettings.CustomSettings + } if spec.Scripts.Set { team.Config.Scripts = spec.Scripts diff --git a/frontend/__mocks__/mdmMock.ts b/frontend/__mocks__/mdmMock.ts index 78408c7827..fa1a0f26dc 100644 --- a/frontend/__mocks__/mdmMock.ts +++ b/frontend/__mocks__/mdmMock.ts @@ -18,9 +18,11 @@ const DEFAULT_MDM_PROFILE_DATA: IMdmProfile = { profile_id: 1, team_id: 0, name: "Test Profile", + platform: "darwin", identifier: "com.test.profile", created_at: "2021-01-01T00:00:00Z", updated_at: "2021-01-01T00:00:00Z", + checksum: "123abc", }; export const createMockMdmProfile = ( diff --git a/frontend/components/Pagination/Pagination.jsx b/frontend/components/Pagination/Pagination.jsx index e9d6eb0b12..26f6996323 100644 --- a/frontend/components/Pagination/Pagination.jsx +++ b/frontend/components/Pagination/Pagination.jsx @@ -6,9 +6,11 @@ import FleetIcon from "components/icons/FleetIcon"; const baseClass = "pagination"; -// TODO: Refactor to typescript -// Already seeing issues with prop types (currentPage passed through as string instead of number) - +/** + * WARNING: DEPRICATED: + * This pagination component is DEPRICATED. It is being kept around until we replace its + * use. For now use the Pagination component in the pages/ManageControlsPage/components. + */ class Pagination extends PureComponent { static propTypes = { currentPage: PropTypes.number, diff --git a/frontend/interfaces/mdm.ts b/frontend/interfaces/mdm.ts index cab185ece7..4ea2d9dcc5 100644 --- a/frontend/interfaces/mdm.ts +++ b/frontend/interfaces/mdm.ts @@ -55,29 +55,27 @@ export interface IMdmSummaryResponse { mobile_device_management_solution: IMdmSolution[] | null; } -type SupportedMdmPlatform = "darwin" | "windows"; +type ProfilePlatform = "darwin" | "windows"; export interface IMdmProfile { - profile_id: number; + profile_id: number | string; // string for windows profiles team_id: number; name: string; - identifier: string; + platform: ProfilePlatform; + identifier: string | null; // null for windows profiles created_at: string; updated_at: string; -} - -export interface IMdmProfilesResponse { - profiles: IMdmProfile[] | null; + checksum: string | null; // null for windows profiles } export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed"; -export type MacMdmProfileOperationType = "remove" | "install"; +export type ProfileOperationType = "remove" | "install"; export interface IHostMdmProfile { profile_id: number; name: string; - operation_type: MacMdmProfileOperationType | null; + operation_type: ProfileOperationType | null; status: MdmProfileStatus; detail: string; } diff --git a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx index 9eb28475ba..20974fd842 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/OSSettings.tsx @@ -16,6 +16,7 @@ const baseClass = "os-settings"; interface IOSSettingsProps { params: Params; router: InjectedRouter; + currentPage: number; location: { search: string; }; @@ -23,6 +24,7 @@ interface IOSSettingsProps { const OSSettings = ({ router, + currentPage, location: { search: queryString }, params, }: IOSSettingsProps) => { @@ -41,8 +43,7 @@ const OSSettings = ({ isLoading: isLoadingAggregateProfileStatus, } = useQuery( ["aggregateProfileStatuses", teamId], - () => - mdmAPI.getAggregateProfileStatuses(teamId, config?.mdm_enabled ?? false), + () => mdmAPI.getProfilesStatusSummary(teamId), { refetchOnWindowFocus: false, retry: false, @@ -87,6 +88,8 @@ const OSSettings = ({ key={teamId} currentTeamId={teamId} onMutation={refetchAggregateProfileStatus} + router={router} + currentPage={currentPage} /> } /> diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx index f1c45adf9d..8ef734adc9 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/CustomSettings.tsx @@ -1,14 +1,18 @@ -import React, { useContext, useRef, useState } from "react"; +import React, { useCallback, useContext, useRef, useState } from "react"; +import { InjectedRouter } from "react-router"; import { useQuery } from "react-query"; -import { IMdmProfile, IMdmProfilesResponse } from "interfaces/mdm"; -import mdmAPI from "services/entities/mdm"; +import { IMdmProfile } from "interfaces/mdm"; +import mdmAPI, { IMdmProfilesResponse } from "services/entities/mdm"; import { NotificationContext } from "context/notification"; +import PATHS from "router/paths"; import CustomLink from "components/CustomLink"; import Spinner from "components/Spinner"; import DataError from "components/DataError"; +import Pagination from "pages/ManageControlsPage/components/Pagination"; + import UploadList from "../../../components/UploadList"; import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal"; @@ -16,10 +20,14 @@ import ProfileListItem from "./components/ProfileListItem"; import ProfileListHeading from "./components/ProfileListHeading"; import ProfileUploader from "./components/ProfileUploader"; +const PROFILES_PER_PAGE = 10; + const baseClass = "custom-settings"; interface ICustomSettingsProps { currentTeamId: number; + router: InjectedRouter; // v3 + currentPage: number; /** handler that fires when a change occures on the section (e.g. disk encryption * enabled, profile uploaded) */ onMutation: () => void; @@ -27,6 +35,8 @@ interface ICustomSettingsProps { const CustomSettings = ({ currentTeamId, + router, + currentPage, onMutation, }: ICustomSettingsProps) => { const { renderFlash } = useContext(NotificationContext); @@ -35,21 +45,27 @@ const CustomSettings = ({ const selectedProfile = useRef(null); - const onClickDelete = (profile: IMdmProfile) => { - selectedProfile.current = profile; - setShowDeleteProfileModal(true); - }; - const { - data: profiles, + data: profilesData, isLoading: isLoadingProfiles, isError: isErrorProfiles, refetch: refetchProfiles, - } = useQuery( - ["profiles", currentTeamId], - () => mdmAPI.getProfiles(currentTeamId), + } = useQuery( + [ + { + scope: "profiles", + team_id: currentTeamId, + page: currentPage, + per_page: PROFILES_PER_PAGE, + }, + ], + () => + mdmAPI.getProfiles({ + team_id: currentTeamId, + page: currentPage, + per_page: PROFILES_PER_PAGE, + }), { - select: (data) => data.profiles, refetchOnWindowFocus: false, } ); @@ -64,7 +80,7 @@ const CustomSettings = ({ setShowDeleteProfileModal(false); }; - const onDeleteProfile = async (profileId: number) => { + const onDeleteProfile = async (profileId: number | string) => { try { await mdmAPI.deleteProfile(profileId); refetchProfiles(); @@ -78,6 +94,24 @@ const CustomSettings = ({ } }; + // pagination controls + const path = PATHS.CONTROLS_CUSTOM_SETTINGS.concat( + `?team_id=${currentTeamId}` + ); + + const onPrevPage = useCallback(() => { + router.push(path.concat(`&page=${currentPage - 1}`)); + }, [router, path, currentPage]); + + const onNextPage = useCallback(() => { + router.push(path.concat(`&page=${currentPage + 1}`)); + }, [router, path, currentPage]); + + const onClickDelete = (profile: IMdmProfile) => { + selectedProfile.current = profile; + setShowDeleteProfileModal(true); + }; + const renderProfileList = () => { if (isLoadingProfiles) { return ; @@ -87,18 +121,32 @@ const CustomSettings = ({ return ; } - if (!profiles || profiles.length === 0) { + if ( + !profilesData || + !profilesData.profiles || + profilesData.profiles.length === 0 + ) { return null; } + const { profiles, meta } = profilesData; return ( - ( - - )} - /> + <> + ( + + )} + /> + + ); }; diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss index df0049b33e..f90750ea18 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/_styles.scss @@ -29,6 +29,12 @@ margin: 0; } + &__pagination-controls { + display: flex; + justify-content: flex-end; + margin: $pad-large 0; + } + &__file-uploader { margin-top: $pad-xxlarge; } diff --git a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/DeleteProfileModal/DeleteProfileModal.tsx b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/DeleteProfileModal/DeleteProfileModal.tsx index 66a593e635..09f584bb27 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/DeleteProfileModal/DeleteProfileModal.tsx +++ b/frontend/pages/ManageControlsPage/OSSettings/cards/CustomSettings/components/DeleteProfileModal/DeleteProfileModal.tsx @@ -7,9 +7,9 @@ import Button from "components/buttons/Button"; interface DeleteProfileModalProps { profileName: string; - profileId: number; + profileId: number | string; onCancel: () => void; - onDelete: (profileId: number) => void; + onDelete: (profileId: number | string) => void; } const baseClass = "delete-profile-modal"; @@ -43,7 +43,7 @@ const DeleteProfileModal = ({

This action will delete configuration profile{" "} {profileName}{" "} - from all macOS hosts{messageSuffix}. + from all hosts{messageSuffix}.

+ +
+ ); +}; + +export default Pagination; diff --git a/frontend/pages/ManageControlsPage/components/Pagination/_styles.scss b/frontend/pages/ManageControlsPage/components/Pagination/_styles.scss new file mode 100644 index 0000000000..4a4037b16b --- /dev/null +++ b/frontend/pages/ManageControlsPage/components/Pagination/_styles.scss @@ -0,0 +1,16 @@ +.pagination-new { + display: flex; + align-items: center; + gap: $pad-large; + + &__pagination-button { + color: $core-vibrant-blue; + font-weight: $bold; + padding: $pad-small; + } + + button:hover, + button:focus { + background-color: $ui-vibrant-blue-10; + } +} diff --git a/frontend/pages/ManageControlsPage/components/Pagination/index.ts b/frontend/pages/ManageControlsPage/components/Pagination/index.ts new file mode 100644 index 0000000000..34fcdf47aa --- /dev/null +++ b/frontend/pages/ManageControlsPage/components/Pagination/index.ts @@ -0,0 +1 @@ +export { default } from "./Pagination"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx index 977ba051bd..58f103bafa 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tests.tsx @@ -1,13 +1,13 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { createCustomRenderer } from "test/test-utils"; -import { MacMdmProfileOperationType } from "interfaces/mdm"; +import { ProfileOperationType } from "interfaces/mdm"; import OSSettingStatusCell from "./OSSettingStatusCell"; describe("OS setting status cell", () => { it("Correctly displays the status text of a profile", () => { const status = "verifying"; - const operationType: MacMdmProfileOperationType = "install"; + const operationType: ProfileOperationType = "install"; render( { it("Correctly displays the tooltip text for a profile", async () => { const status = "verifying"; - const operationType: MacMdmProfileOperationType = "install"; + const operationType: ProfileOperationType = "install"; const customRender = createCustomRenderer(); diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx index 8a2661cefc..34f91caeac 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/OSSettingStatusCell.tsx @@ -3,140 +3,28 @@ import ReactTooltip from "react-tooltip"; import { uniqueId } from "lodash"; import Icon from "components/Icon"; -import { IconNames } from "components/icons"; import TextCell from "components/TableContainer/DataTable/TextCell"; import { FLEET_FILEVAULT_PROFILE_DISPLAY_NAME, - MacMdmProfileOperationType, + ProfileOperationType, } from "interfaces/mdm"; import { isMdmProfileStatus, OsSettingsTableStatusValue, } from "../OSSettingsTableConfig"; -import TooltipContent, { - TooltipInnerContentFunc, - TooltipInnerContentOption, -} from "./components/Tooltip/TooltipContent"; -import TooltipInnerContentActionRequired from "./components/Tooltip/ActionRequired"; +import TooltipContent from "./components/Tooltip/TooltipContent"; +import { + PROFILE_DISPLAY_CONFIG, + ProfileDisplayOption, + WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG, +} from "./helpers"; const baseClass = "os-setting-status-cell"; -type ProfileDisplayOption = { - statusText: string; - iconName: IconNames; - tooltip: TooltipInnerContentOption | null; -} | null; - -type OperationTypeOption = Record< - OsSettingsTableStatusValue, - ProfileDisplayOption ->; -type ProfileDisplayConfig = Record< - MacMdmProfileOperationType, - OperationTypeOption ->; - -const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { - install: { - pending: { - statusText: "Enforcing (pending)", - iconName: "pending-outline", - tooltip: (innerProps) => - innerProps.isDiskEncryptionProfile - ? "The hosts will receive the MDM command to turn on disk encryption " + - "when the hosts come online." - : "The host will receive the MDM command to install the configuration profile when the " + - "host comes online.", - }, - action_required: { - statusText: "Action required (pending)", - iconName: "pending-outline", - tooltip: TooltipInnerContentActionRequired as TooltipInnerContentFunc, - }, - verified: { - statusText: "Verified", - iconName: "success", - tooltip: (innerProps) => - innerProps.isDiskEncryptionProfile - ? "The host turned disk encryption on and sent the key to Fleet. " + - "Fleet verified with osquery." - : "The host installed the configuration profile. Fleet verified with osquery.", - }, - verifying: { - statusText: "Verifying", - iconName: "success-outline", - tooltip: (innerProps) => - innerProps.isDiskEncryptionProfile - ? "The host acknowledged the MDM command to turn on disk encryption. " + - "Fleet is verifying with osquery and retrieving the disk encryption key. " + - "This may take up to one hour." - : "The host acknowledged the MDM command to install the configuration profile. Fleet is " + - "verifying with osquery.", - }, - failed: { - statusText: "Failed", - iconName: "error", - tooltip: null, - }, - }, - remove: { - pending: { - statusText: "Removing enforcement (pending)", - iconName: "pending-outline", - tooltip: (innerProps) => - innerProps.isDiskEncryptionProfile - ? "The host will receive the MDM command to remove the disk encryption profile when the " + - "host comes online." - : "The host will receive the MDM command to remove the configuration profile when the host " + - "comes online.", - }, - action_required: null, // should not be reached - verified: null, // should not be reached - verifying: null, // should not be reached - failed: { - statusText: "Failed", - iconName: "error", - tooltip: null, - }, - }, -}; - -type WindowsDiskEncryptionDisplayConfig = Omit< - OperationTypeOption, - "action_required" ->; - -const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDisplayConfig = { - verified: { - statusText: "Verified", - iconName: "success", - tooltip: () => - "The host turned disk encryption on and sent the key to Fleet. Fleet verified with osquery.", - }, - verifying: { - statusText: "Verifying", - iconName: "success-outline", - tooltip: () => - "The host acknowledged the MDM command to turn on disk encryption. Fleet is verifying with osquery and retrieving " + - "the disk encryption key. This may take up to one hour.", - }, - pending: { - statusText: "Enforcing (pending)", - iconName: "pending-outline", - tooltip: () => - "The host will receive the MDM command to turn on disk encryption when the host comes online.", - }, - failed: { - statusText: "Failed", - iconName: "error", - tooltip: null, - }, -}; - interface IOSSettingStatusCellProps { status: OsSettingsTableStatusValue; - operationType: MacMdmProfileOperationType | null; + operationType: ProfileOperationType | null; profileName: string; } diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts new file mode 100644 index 0000000000..14c1840394 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingStatusCell/helpers.ts @@ -0,0 +1,120 @@ +import { ProfileOperationType } from "interfaces/mdm"; + +import { IconNames } from "components/icons"; +import { + TooltipInnerContentFunc, + TooltipInnerContentOption, +} from "./components/Tooltip/TooltipContent"; + +import { OsSettingsTableStatusValue } from "../OSSettingsTableConfig"; +import TooltipInnerContentActionRequired from "./components/Tooltip/ActionRequired"; + +export type ProfileDisplayOption = { + statusText: string; + iconName: IconNames; + tooltip: TooltipInnerContentOption | null; +} | null; + +type OperationTypeOption = Record< + OsSettingsTableStatusValue, + ProfileDisplayOption +>; + +type ProfileDisplayConfig = Record; + +export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = { + install: { + verified: { + statusText: "Verified", + iconName: "success", + tooltip: (innerProps) => + innerProps.isDiskEncryptionProfile + ? "The host turned disk encryption on and sent the key to Fleet. " + + "Fleet verified with osquery." + : "The host applied the setting. Fleet verified with osquery.", + }, + verifying: { + statusText: "Verifying", + iconName: "success-outline", + tooltip: (innerProps) => + innerProps.isDiskEncryptionProfile + ? "The host acknowledged the MDM command to turn on disk encryption. " + + "Fleet is verifying with osquery and retrieving the disk encryption key. " + + "This may take up to one hour." + : "The host acknowledged the MDM command to apply the setting. Fleet is " + + "verifying with osquery.", + }, + pending: { + statusText: "Enforcing (pending)", + iconName: "pending-outline", + tooltip: (innerProps) => + innerProps.isDiskEncryptionProfile + ? "The hosts will receive the MDM command to turn on disk encryption " + + "when the hosts come online." + : "The host will receive the MDM command to apply the settung when the " + + "host comes online.", + }, + action_required: { + statusText: "Action required (pending)", + iconName: "pending-outline", + tooltip: TooltipInnerContentActionRequired as TooltipInnerContentFunc, + }, + failed: { + statusText: "Failed", + iconName: "error", + tooltip: null, + }, + }, + remove: { + pending: { + statusText: "Removing enforcement (pending)", + iconName: "pending-outline", + tooltip: (innerProps) => + innerProps.isDiskEncryptionProfile + ? "The host will receive the MDM command to remove the disk encryption profile when the " + + "host comes online." + : "The host will receive the MDM command to remove the setting when the host " + + "comes online.", + }, + action_required: null, // should not be reached + verified: null, // should not be reached + verifying: null, // should not be reached + failed: { + statusText: "Failed", + iconName: "error", + tooltip: null, + }, + }, +}; + +type WindowsDiskEncryptionDisplayConfig = Omit< + OperationTypeOption, + "action_required" +>; + +export const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDisplayConfig = { + verified: { + statusText: "Verified", + iconName: "success", + tooltip: () => + "The host turned disk encryption on and sent the key to Fleet. Fleet verified with osquery.", + }, + verifying: { + statusText: "Verifying", + iconName: "success-outline", + tooltip: () => + "The host acknowledged the MDM command to turn on disk encryption. Fleet is verifying with " + + "osquery and retrieving the disk encryption key. This may take up to one hour.", + }, + pending: { + statusText: "Enforcing (pending)", + iconName: "pending-outline", + tooltip: () => + "The host will receive the MDM command to turn on disk encryption when the host comes online.", + }, + failed: { + statusText: "Failed", + iconName: "error", + tooltip: null, + }, +}; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx index 634c03b49e..dfeeb5a523 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTableConfig.tsx @@ -98,21 +98,28 @@ const tableHeaders: IDataColumn[] = [ }, ]; -const makeWindowsRows = ({ os_settings }: IHostMdmData) => { - if ( - !os_settings?.disk_encryption?.status || - !isWindowsDiskEncryptionStatus(os_settings.disk_encryption.status) - ) { - return null; +const makeWindowsRows = ({ profiles, os_settings }: IHostMdmData) => { + const rows: ITableRowOsSettings[] = []; + + if (profiles) { + rows.push(...profiles); } - const rows: ITableRowOsSettings[] = []; - rows.push( - generateWinDiskEncryptionProfile( - os_settings.disk_encryption.status, - os_settings.disk_encryption.detail - ) - ); + if ( + os_settings?.disk_encryption?.status && + isWindowsDiskEncryptionStatus(os_settings.disk_encryption.status) + ) { + rows.push( + generateWinDiskEncryptionProfile( + os_settings.disk_encryption.status, + os_settings.disk_encryption.detail + ) + ); + } + + if (rows.length === 0 && !profiles) { + return null; + } return rows; }; diff --git a/frontend/services/entities/mdm.ts b/frontend/services/entities/mdm.ts index 41301f3d3b..0919dd6fda 100644 --- a/frontend/services/entities/mdm.ts +++ b/frontend/services/entities/mdm.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { DiskEncryptionStatus, MdmProfileStatus } from "interfaces/mdm"; -import { APP_CONTEXT_NO_TEAM_ID } from "interfaces/team"; +import { + DiskEncryptionStatus, + IMdmProfile, + MdmProfileStatus, +} from "interfaces/mdm"; import sendRequest from "services"; import endpoints from "utilities/endpoints"; import { buildQueryStringFromParams } from "utilities/url"; @@ -23,37 +26,19 @@ export type IDiskEncryptionSummaryResponse = Record< IDiskEncryptionStatusAggregate >; -// This function combines the profile status summary and the disk encryption summary -// to generate the aggregate profile status summary. We are doing this as a temporary -// solution until we have the API that will return the aggregate profile status summary -// from one call. -// TODO: API INTEGRATION: remove when API is implemented that returns windows -// data in the aggregate profile status summary. -const generateCombinedProfileStatusSummary = ( - profileStatuses: ProfileStatusSummaryResponse, - diskEncryptionSummary: IDiskEncryptionSummaryResponse -): ProfileStatusSummaryResponse => { - const { verified, verifying, failed, pending } = profileStatuses; - const { - verified: verifiedDiskEncryption, - verifying: verifyingDiskEncryption, - failed: failedDiskEncryption, - action_required: actionRequiredDiskEncryption, - enforcing: enforcingDiskEncryption, - removing_enforcement: removingEnforcementDiskEncryption, - } = diskEncryptionSummary; +export interface IGetProfilesApiParams { + page?: number; + per_page?: number; + team_id?: number; +} - return { - verified: verified + verifiedDiskEncryption.windows, - verifying: verifying + verifyingDiskEncryption.windows, - failed: failed + failedDiskEncryption.windows, - pending: - pending + - actionRequiredDiskEncryption.windows + - enforcingDiskEncryption.windows + - removingEnforcementDiskEncryption.windows, +export interface IMdmProfilesResponse { + profiles: IMdmProfile[] | null; + meta: { + has_next_results: boolean; + has_previous_results: boolean; }; -}; +} const mdmService = { downloadDeviceUserEnrollmentProfile: (token: string) => { @@ -83,9 +68,12 @@ const mdmService = { }); }, - getProfiles: (teamId = APP_CONTEXT_NO_TEAM_ID) => { - const path = `${endpoints.MDM_PROFILES}?${buildQueryStringFromParams({ - team_id: teamId, + getProfiles: ( + params: IGetProfilesApiParams + ): Promise => { + const { MDM_PROFILES } = endpoints; + const path = `${MDM_PROFILES}?${buildQueryStringFromParams({ + ...params, })}`; return sendRequest("GET", path); @@ -104,47 +92,26 @@ const mdmService = { return sendRequest("POST", MDM_PROFILES, formData); }, - downloadProfile: (profileId: number) => { + downloadProfile: (profileId: number | string) => { const { MDM_PROFILE } = endpoints; - return sendRequest("GET", MDM_PROFILE(profileId)); + const path = `${MDM_PROFILE(profileId)}?${buildQueryStringFromParams({ + alt: "media", + })}`; + return sendRequest("GET", path); }, - deleteProfile: (profileId: number) => { + deleteProfile: (profileId: number | string) => { const { MDM_PROFILE } = endpoints; return sendRequest("DELETE", MDM_PROFILE(profileId)); }, - // TODO: API INTEGRATION: we need to rework this when we create API call that - // will return the aggregate statuses for windows included in the response. - // Currently to get windows data included we will need to make a separate call. - // We will likely change this to go back to single "getProfileStatusSummary" API call. - getAggregateProfileStatuses: async ( - teamId = APP_CONTEXT_NO_TEAM_ID, - // TODO: WINDOWS FEATURE FLAG: remove when we windows feature is released. - includeWindows: boolean - ) => { - // if we are not including windows we can just call the existing profile summary API - if (!includeWindows) { - return mdmService.getProfileStatusSummary(teamId); + getProfilesStatusSummary: (teamId: number) => { + let { MDM_PROFILES_STATUS_SUMMARY: path } = endpoints; + + if (teamId) { + path = `${path}?${buildQueryStringFromParams({ team_id: teamId })}`; } - // otherwise we have to make two calls and combine the results. - return mdmService - .getAggregateProfileStatusesWithWindows(teamId) - .then((res) => generateCombinedProfileStatusSummary(...res)); - }, - - getAggregateProfileStatusesWithWindows: async (teamId: number) => { - return Promise.all([ - mdmService.getProfileStatusSummary(teamId), - mdmService.getDiskEncryptionSummary(teamId), - ]); - }, - - getProfileStatusSummary: (teamId = APP_CONTEXT_NO_TEAM_ID) => { - const path = `${ - endpoints.MDM_PROFILES_AGGREGATE_STATUSES - }?${buildQueryStringFromParams({ team_id: teamId })}`; return sendRequest("GET", path); }, diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index e322061b07..cff89aff23 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -47,10 +47,14 @@ export default { MDM_APPLE_BM_KEYS: `/${API_VERSION}/fleet/mdm/apple/dep/key_pair`, MDM_SUMMARY: `/${API_VERSION}/fleet/hosts/summary/mdm`, MDM_REQUEST_CSR: `/${API_VERSION}/fleet/mdm/apple/request_csr`, - MDM_PROFILES: `/${API_VERSION}/fleet/mdm/apple/profiles`, - MDM_PROFILE: (id: number) => `/${API_VERSION}/fleet/mdm/apple/profiles/${id}`, + + // MDM profile endpoints + MDM_PROFILES: `/${API_VERSION}/fleet/mdm/profiles`, + MDM_PROFILE: (id: number | string) => + `/${API_VERSION}/fleet/mdm/profiles/${id}`, + MDM_UPDATE_APPLE_SETTINGS: `/${API_VERSION}/fleet/mdm/apple/settings`, - MDM_PROFILES_AGGREGATE_STATUSES: `/${API_VERSION}/fleet/mdm/apple/profiles/summary`, + MDM_PROFILES_STATUS_SUMMARY: `/${API_VERSION}/fleet/mdm/profiles/summary`, MDM_DISK_ENCRYPTION_SUMMARY: `/${API_VERSION}/fleet/mdm/disk_encryption/summary`, MDM_APPLE_SSO: `/${API_VERSION}/fleet/mdm/sso`, MDM_APPLE_ENROLLMENT_PROFILE: (token: string, ref?: string) => { diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 6669aee8c1..30ba14fe08 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -1914,9 +1914,11 @@ func windowsConfigProfileForTest(t *testing.T, name, locURI string) *fleet.MDMWi Name: name, SyncML: []byte(fmt.Sprintf(` - - %s - + + + %s + + `, locURI)), } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 88ab73096a..27ff6bbab2 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\": \"\"}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 3b3c00beb7..5b5fd2279e 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -590,9 +590,9 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { BootstrapPackage: optjson.SetString("bootstrap"), MacOSSetupAssistant: optjson.SetString("assistant"), }, - //WindowsSettings: fleet.WindowsSettings{ - // CustomSettings: optjson.SetSlice([]string{"foo", "bar"}), - //}, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.SetSlice([]string{"foo", "bar"}), + }, }, }, }) @@ -609,9 +609,9 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { BootstrapPackage: optjson.SetString("bootstrap"), MacOSSetupAssistant: optjson.SetString("assistant"), }, - //WindowsSettings: fleet.WindowsSettings{ - // CustomSettings: optjson.SetSlice([]string{"foo", "bar"}), - //}, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.SetSlice([]string{"foo", "bar"}), + }, }, mdm) }) } diff --git a/server/fleet/app.go b/server/fleet/app.go index 42ef378ee2..98580abad3 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -161,7 +161,7 @@ type MDM struct { EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` - // WindowsSettings WindowsSettings `json:"windows_settings"` + WindowsSettings WindowsSettings `json:"windows_settings"` ///////////////////////////////////////////////////////////////// // WARNING: If you add to this struct make sure it's taken into @@ -502,11 +502,11 @@ func (c *AppConfig) Copy() *AppConfig { clone.Scripts = optjson.SetSlice(scripts) } - // if c.MDM.WindowsSettings.CustomSettings.Set { - // windowsSettings := make([]string, len(c.MDM.WindowsSettings.CustomSettings.Value)) - // copy(windowsSettings, c.MDM.WindowsSettings.CustomSettings.Value) - // clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) - // } + if c.MDM.WindowsSettings.CustomSettings.Set { + windowsSettings := make([]string, len(c.MDM.WindowsSettings.CustomSettings.Value)) + copy(windowsSettings, c.MDM.WindowsSettings.CustomSettings.Value) + clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) + } return &clone } diff --git a/server/fleet/teams.go b/server/fleet/teams.go index acfbe494b5..a3a4546e0b 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -32,11 +32,11 @@ type TeamPayload struct { // need to be able which part of the MDM config was provided in the request, // so the fields are pointers to structs. type TeamPayloadMDM struct { - EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` - MacOSUpdates *MacOSUpdates `json:"macos_updates"` - MacOSSettings *MacOSSettings `json:"macos_settings"` - MacOSSetup *MacOSSetup `json:"macos_setup"` - // WindowsSettings *WindowsSettings `json:"windows_settings"` + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + MacOSUpdates *MacOSUpdates `json:"macos_updates"` + MacOSSettings *MacOSSettings `json:"macos_settings"` + MacOSSetup *MacOSSetup `json:"macos_setup"` + WindowsSettings *WindowsSettings `json:"windows_settings"` } // Team is the data representation for the "Team" concept (group of hosts and @@ -154,7 +154,7 @@ type TeamMDM struct { MacOSSettings MacOSSettings `json:"macos_settings"` MacOSSetup MacOSSetup `json:"macos_setup"` - // WindowsSettings WindowsSettings `json:"windows_settings"` + WindowsSettings WindowsSettings `json:"windows_settings"` // NOTE: TeamSpecMDM must be kept in sync with TeamMDM. ///////////////////////////////////////////////////////////////// @@ -188,11 +188,11 @@ func (t *TeamMDM) Copy() *TeamMDM { if t.MacOSSettings.DeprecatedEnableDiskEncryption != nil { clone.MacOSSettings.DeprecatedEnableDiskEncryption = ptr.Bool(*t.MacOSSettings.DeprecatedEnableDiskEncryption) } - //if t.WindowsSettings.CustomSettings.Set { - // windowsSettings := make([]string, len(t.WindowsSettings.CustomSettings.Value)) - // copy(windowsSettings, t.WindowsSettings.CustomSettings.Value) - // clone.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) - //} + if t.WindowsSettings.CustomSettings.Set { + windowsSettings := make([]string, len(t.WindowsSettings.CustomSettings.Value)) + copy(windowsSettings, t.WindowsSettings.CustomSettings.Value) + clone.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) + } return &clone } @@ -209,7 +209,7 @@ type TeamSpecMDM struct { MacOSSettings map[string]interface{} `json:"macos_settings"` MacOSSetup MacOSSetup `json:"macos_setup"` - // WindowsSettings WindowsSettings `json:"windows_settings"` + WindowsSettings WindowsSettings `json:"windows_settings"` // NOTE: TeamMDM must be kept in sync with TeamSpecMDM. } @@ -419,7 +419,7 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { delete(mdmSpec.MacOSSettings, "enable_disk_encryption") mdmSpec.MacOSSetup = t.Config.MDM.MacOSSetup mdmSpec.EnableDiskEncryption = optjson.SetBool(t.Config.MDM.EnableDiskEncryption) - // mdmSpec.WindowsSettings = t.Config.MDM.WindowsSettings + mdmSpec.WindowsSettings = t.Config.MDM.WindowsSettings return &TeamSpec{ Name: t.Name, AgentOptions: agentOptions, diff --git a/server/fleet/windows_mdm.go b/server/fleet/windows_mdm.go index e471d44e22..68f12a0029 100644 --- a/server/fleet/windows_mdm.go +++ b/server/fleet/windows_mdm.go @@ -68,7 +68,7 @@ func (m *MDMWindowsConfigProfile) ValidateUserProvided() error { return errors.New("Only supported as a top level element. Make sure you don't have other top level elements.") } - for _, locURI := range element.FindElements("//Target/LocURI") { + for _, locURI := range element.FindElements("//Item/Target/LocURI") { if locURI != nil { if err := validateFleetProvidedLocURI(locURI.Text()); err != nil { return err diff --git a/server/fleet/windows_mdm_test.go b/server/fleet/windows_mdm_test.go index 0eecdabcc9..f4b5334b42 100644 --- a/server/fleet/windows_mdm_test.go +++ b/server/fleet/windows_mdm_test.go @@ -15,42 +15,85 @@ func TestValidateUserProvided(t *testing.T) { { name: "Valid XML with Replace", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`Custom/URI`), + SyncML: []byte(` + + + Custom/URI + + + `), }, wantErr: false, }, { name: "Invalid Platform", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`Custom/URI`), + SyncML: []byte(` + + + + Custom/URI + + + + `), }, wantErr: true, }, { name: "Invalid XML Structure", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`Custom/URI`), + SyncML: []byte(` + + + Custom/URI + + + `), }, wantErr: true, }, { name: "Reserved LocURI", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`./Device/Vendor/MSFT/BitLocker/Foo`), + SyncML: []byte(` + + + ./Device/Vendor/MSFT/BitLocker/Foo + + + `), }, wantErr: true, }, { name: "Reserved LocURI with implicit ./Device prefix", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`./Vendor/MSFT/BitLocker/Foo`), + SyncML: []byte(` + + + ./Vendor/MSFT/BitLocker/Foo + + + `), }, wantErr: true, }, { name: "XML with Multiple Replace Elements", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`Custom/URI1Custom/URI2`), + SyncML: []byte(` + + + Custom/URI1 + + + + + Custom/URI2 + + + `), }, wantErr: false, }, @@ -64,14 +107,36 @@ func TestValidateUserProvided(t *testing.T) { { name: "XML with Multiple Replace Elements, One with Reserved LocURI", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`Custom/URI./Device/Vendor/MSFT/BitLocker/Bar`), + SyncML: []byte(` + + + Custom/URI + + + + + ./Device/Vendor/MSFT/BitLocker/Bar + + + `), }, wantErr: true, }, { name: "XML with Mixed Replace and Add", profile: MDMWindowsConfigProfile{ - SyncML: []byte(`Custom/URIAnother/URI`), + SyncML: []byte(` + + + Custom/URI + + + + + Another/URI + + + `), }, wantErr: true, }, diff --git a/server/service/appconfig.go b/server/service/appconfig.go index aa644ce9bb..97dbdb24d1 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -645,14 +645,14 @@ func (svc *Service) validateMDM( } } - // if !mdm.WindowsEnabledAndConfigured { - // if mdm.WindowsSettings.CustomSettings.Set && - // len(mdm.WindowsSettings.CustomSettings.Value) > 0 && - // !server.SliceStringsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { - // invalid.Append("windows_settings.custom_settings", - // `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) - // } - // } + if !mdm.WindowsEnabledAndConfigured { + if mdm.WindowsSettings.CustomSettings.Set && + len(mdm.WindowsSettings.CustomSettings.Value) > 0 && + !server.SliceStringsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { + invalid.Append("windows_settings.custom_settings", + `Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) + } + } if name := mdm.AppleBMDefaultTeam; name != "" && name != oldMdm.AppleBMDefaultTeam { if !license.IsPremium() { diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index d77d9c92c1..2df35922b2 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -813,7 +813,7 @@ func TestMDMAppleConfig(t *testing.T) { MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, - // WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, + WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, }, { name: "newDefaultTeamNoLicense", @@ -841,7 +841,7 @@ func TestMDMAppleConfig(t *testing.T) { MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, - // WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, + WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, }, { name: "foundEdit", @@ -854,7 +854,7 @@ func TestMDMAppleConfig(t *testing.T) { MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, - // WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, + WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, }, { name: "ssoFree", @@ -873,7 +873,7 @@ func TestMDMAppleConfig(t *testing.T) { MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, - // WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, + WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, }, { name: "ssoAllFields", @@ -895,7 +895,7 @@ func TestMDMAppleConfig(t *testing.T) { MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, - // WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, + WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, }, }, { name: "ssoShortEntityID", diff --git a/server/service/client.go b/server/service/client.go index 5ace2c0cbf..c4faf4f31e 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -380,8 +380,7 @@ func (c *Client) ApplyGroup( } if specs.AppConfig != nil { - windowsCustomSettings := []string{} - // windowsCustomSettings := extractAppCfgWindowsCustomSettings(specs.AppConfig) + windowsCustomSettings := extractAppCfgWindowsCustomSettings(specs.AppConfig) macosCustomSettings := extractAppCfgMacOSCustomSettings(specs.AppConfig) allCustomSettings := append(macosCustomSettings, windowsCustomSettings...) @@ -644,42 +643,42 @@ func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string { return csStrings } -//func extractAppCfgWindowsCustomSettings(appCfg interface{}) []string { -// asMap, ok := appCfg.(map[string]interface{}) -// if !ok { -// return nil -// } -// mmdm, ok := asMap["mdm"].(map[string]interface{}) -// if !ok { -// return nil -// } -// mos, ok := mmdm["windows_settings"].(map[string]interface{}) -// if !ok || mos == nil { -// return nil -// } -// -// cs, ok := mos["custom_settings"] -// if !ok { -// // custom settings is not present -// return nil -// } -// -// csAny, ok := cs.([]interface{}) -// if !ok || csAny == nil { -// // return a non-nil, empty slice instead, so the caller knows that the -// // custom_settings key was actually provided. -// return []string{} -// } -// -// csStrings := make([]string, 0, len(csAny)) -// for _, v := range csAny { -// s, _ := v.(string) -// if s != "" { -// csStrings = append(csStrings, s) -// } -// } -// return csStrings -//} +func extractAppCfgWindowsCustomSettings(appCfg interface{}) []string { + asMap, ok := appCfg.(map[string]interface{}) + if !ok { + return nil + } + mmdm, ok := asMap["mdm"].(map[string]interface{}) + if !ok { + return nil + } + mos, ok := mmdm["windows_settings"].(map[string]interface{}) + if !ok || mos == nil { + return nil + } + + cs, ok := mos["custom_settings"] + if !ok { + // custom settings is not present + return nil + } + + csAny, ok := cs.([]interface{}) + if !ok || csAny == nil { + // return a non-nil, empty slice instead, so the caller knows that the + // custom_settings key was actually provided. + return []string{} + } + + csStrings := make([]string, 0, len(csAny)) + for _, v := range csAny { + s, _ := v.(string) + if s != "" { + csStrings = append(csStrings, s) + } + } + return csStrings +} func extractAppCfgScripts(appCfg interface{}) []string { asMap, ok := appCfg.(map[string]interface{}) @@ -721,7 +720,7 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]str CustomSettings json.RawMessage `json:"custom_settings"` } `json:"macos_settings"` WindowsSettings struct { - CustomSettings json.RawMessage `json:"-"` // FIXME: allow unmarshalling + CustomSettings json.RawMessage `json:"custom_settings"` } `json:"windows_settings"` } `json:"mdm"` } diff --git a/server/service/client_appconfig.go b/server/service/client_appconfig.go index f7e04b5122..5e53717fac 100644 --- a/server/service/client_appconfig.go +++ b/server/service/client_appconfig.go @@ -15,13 +15,8 @@ func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions // ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts // in no team. func (c *Client) ApplyNoTeamProfiles(profiles map[string][]byte, opts fleet.ApplySpecOptions) error { - var profilesBytes [][]byte - for _, pb := range profiles { - profilesBytes = append(profilesBytes, pb) - - } - verb, path := "POST", "/api/latest/fleet/mdm/apple/profiles/batch" - return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profilesBytes}, verb, path, nil, opts.RawQuery()) + verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch" + return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery()) } // GetAppConfig fetches the application config from the server API diff --git a/server/service/client_teams.go b/server/service/client_teams.go index ccb47d8490..7889b625ab 100644 --- a/server/service/client_teams.go +++ b/server/service/client_teams.go @@ -65,18 +65,13 @@ func (c *Client) ApplyTeams(specs []json.RawMessage, opts fleet.ApplySpecOptions // ApplyTeamProfiles sends the list of profiles to be applied for the specified // team. func (c *Client) ApplyTeamProfiles(tmName string, profiles map[string][]byte, opts fleet.ApplySpecOptions) error { - var profilesBytes [][]byte - for _, pb := range profiles { - profilesBytes = append(profilesBytes, pb) - - } - verb, path := "POST", "/api/latest/fleet/mdm/apple/profiles/batch" + verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch" query, err := url.ParseQuery(opts.RawQuery()) if err != nil { return err } query.Add("team_name", tmName) - return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profilesBytes}, verb, path, nil, query.Encode()) + return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, query.Encode()) } // ApplyPolicies sends the list of Policies to be applied to the diff --git a/server/service/client_test.go b/server/service/client_test.go index 2233a7f901..cdb790bad0 100644 --- a/server/service/client_test.go +++ b/server/service/client_test.go @@ -99,94 +99,94 @@ spec: } } -//func TestExtractAppConfigWindowsCustomSettings(t *testing.T) { -// cases := []struct { -// desc string -// yaml string -// want []string -// }{ -// { -// "no settings", -// ` -//apiVersion: v1 -//kind: config -//spec: -//`, -// nil, -// }, -// { -// "no custom settings", -// ` -//apiVersion: v1 -//kind: config -//spec: -// org_info: -// org_name: "Fleet" -// mdm: -// windows_settings: -//`, -// nil, -// }, -// { -// "empty custom settings", -// ` -//apiVersion: v1 -//kind: config -//spec: -// org_info: -// org_name: "Fleet" -// mdm: -// windows_settings: -// custom_settings: -//`, -// []string{}, -// }, -// { -// "custom settings specified", -// ` -//apiVersion: v1 -//kind: config -//spec: -// org_info: -// org_name: "Fleet" -// mdm: -// windows_settings: -// custom_settings: -// - "a" -// - "b" -//`, -// []string{"a", "b"}, -// }, -// { -// "empty and invalid custom settings", -// ` -//apiVersion: v1 -//kind: config -//spec: -// org_info: -// org_name: "Fleet" -// mdm: -// windows_settings: -// custom_settings: -// - "a" -// - "" -// - 4 -// - "c" -//`, -// []string{"a", "c"}, -// }, -// } -// for _, c := range cases { -// t.Run(c.desc, func(t *testing.T) { -// specs, err := spec.GroupFromBytes([]byte(c.yaml)) -// require.NoError(t, err) -// if specs.AppConfig != nil { -// got := extractAppCfgWindowsCustomSettings(specs.AppConfig) -// require.Equal(t, c.want, got) -// } -// }) -// } -//} +func TestExtractAppConfigWindowsCustomSettings(t *testing.T) { + cases := []struct { + desc string + yaml string + want []string + }{ + { + "no settings", + ` +apiVersion: v1 +kind: config +spec: +`, + nil, + }, + { + "no custom settings", + ` +apiVersion: v1 +kind: config +spec: + org_info: + org_name: "Fleet" + mdm: + windows_settings: +`, + nil, + }, + { + "empty custom settings", + ` +apiVersion: v1 +kind: config +spec: + org_info: + org_name: "Fleet" + mdm: + windows_settings: + custom_settings: +`, + []string{}, + }, + { + "custom settings specified", + ` +apiVersion: v1 +kind: config +spec: + org_info: + org_name: "Fleet" + mdm: + windows_settings: + custom_settings: + - "a" + - "b" +`, + []string{"a", "b"}, + }, + { + "empty and invalid custom settings", + ` +apiVersion: v1 +kind: config +spec: + org_info: + org_name: "Fleet" + mdm: + windows_settings: + custom_settings: + - "a" + - "" + - 4 + - "c" +`, + []string{"a", "c"}, + }, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + specs, err := spec.GroupFromBytes([]byte(c.yaml)) + require.NoError(t, err) + if specs.AppConfig != nil { + got := extractAppCfgWindowsCustomSettings(specs.AppConfig) + require.Equal(t, c.want, got) + } + }) + } +} func TestExtractTeamSpecsMDMCustomSettings(t *testing.T) { cases := []struct { @@ -214,6 +214,7 @@ spec: name: Fleet mdm: macos_settings: + windows_settings: --- apiVersion: v1 kind: team @@ -222,6 +223,7 @@ spec: name: Fleet2 mdm: macos_settings: + windows_settings: `, nil, }, @@ -236,6 +238,8 @@ spec: mdm: macos_settings: custom_settings: + windows_settings: + custom_settings: --- apiVersion: v1 kind: team @@ -245,6 +249,8 @@ spec: mdm: macos_settings: custom_settings: + windows_settings: + custom_settings: `, map[string][]string{"Fleet": {}, "Fleet2": {}}, }, @@ -261,8 +267,12 @@ spec: custom_settings: - "a" - "b" + windows_settings: + custom_settings: + - "c" + - "d" `, - map[string][]string{"Fleet": {"a", "b"}}, + map[string][]string{"Fleet": {"a", "b", "c", "d"}}, }, { "invalid custom settings", @@ -279,6 +289,12 @@ spec: - "" - 42 - "c" + windows_settings: + custom_settings: + - "x" + - "" + - 24 + - "y" `, map[string][]string{}, }, diff --git a/server/service/handler.go b/server/service/handler.go index 433477f395..38fd6c37ce 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -537,18 +537,11 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC mdmAnyMW.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{}) mdmAnyMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{}) - // FIXME: endpoints are intentionally disabled to allow a release without this feature. - if false { - mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{}) - mdmAnyMW.POST("/api/_version_/fleet/mdm/profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{}) - mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/{profile_id_or_uuid}", getMDMConfigProfileEndpoint, getMDMConfigProfileRequest{}) - mdmAnyMW.DELETE("/api/_version_/fleet/mdm/profiles/{profile_id_or_uuid}", deleteMDMConfigProfileEndpoint, deleteMDMConfigProfileRequest{}) - mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles", listMDMConfigProfilesEndpoint, listMDMConfigProfilesRequest{}) - // batch-apply is accessible even though MDM is not enabled, it needs - // to support the case where `fleetctl get config`'s output is used as - // input to `fleetctl apply` - ue.POST("/api/_version_/fleet/mdm/profiles/batch", batchSetMDMProfilesEndpoint, batchSetMDMProfilesRequest{}) - } + mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{}) + mdmAnyMW.POST("/api/_version_/fleet/mdm/profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{}) + mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/{profile_id_or_uuid}", getMDMConfigProfileEndpoint, getMDMConfigProfileRequest{}) + mdmAnyMW.DELETE("/api/_version_/fleet/mdm/profiles/{profile_id_or_uuid}", deleteMDMConfigProfileEndpoint, deleteMDMConfigProfileRequest{}) + mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles", listMDMConfigProfilesEndpoint, listMDMConfigProfilesRequest{}) // the following set of mdm endpoints must always be accessible (even // if MDM is not configured) as it bootstraps the setup of MDM @@ -556,7 +549,6 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/mdm/apple/request_csr", requestMDMAppleCSREndpoint, requestMDMAppleCSRRequest{}) ue.POST("/api/_version_/fleet/mdm/apple/dep/key_pair", newMDMAppleDEPKeyPairEndpoint, nil) ue.GET("/api/_version_/fleet/mdm/apple_bm", getAppleBMEndpoint, nil) - // Deprecated: POST /mdm/apple/profiles/batch is now deprecated, replaced by the // platform-agnostic POST /mdm/apple/profiles/batch. It is still supported // indefinitely for backwards compatibility. @@ -566,6 +558,11 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // input to `fleetctl apply` ue.POST("/api/_version_/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesEndpoint, batchSetMDMAppleProfilesRequest{}) + // batch-apply is accessible even though MDM is not enabled, it needs + // to support the case where `fleetctl get config`'s output is used as + // input to `fleetctl apply` + ue.POST("/api/_version_/fleet/mdm/profiles/batch", batchSetMDMProfilesEndpoint, batchSetMDMProfilesRequest{}) + errorLimiter := ratelimit.NewErrorMiddleware(limitStore) // device-authenticated endpoints diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 66fe4273ef..71cb375859 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -140,9 +140,9 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { // 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 // empty). - //WindowsSettings: fleet.WindowsSettings{ - // CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, - //}, + WindowsSettings: fleet.WindowsSettings{ + CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, + }, }, team.Config.MDM) // an activity was created for team spec applied diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 0727ef14e3..b0aefd2363 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "crypto/md5" // nolint:gosec // used only for tests "crypto/x509" "database/sql" "encoding/base64" @@ -5751,29 +5752,29 @@ func (s *integrationMDMTestSuite) assertConfigProfilesByIdentifier(teamID *uint, return profile } -//func (s *integrationMDMTestSuite) assertWindowsConfigProfilesByName(teamID *uint, profileName string, exists bool) { -// t := s.T() -// if teamID == nil { -// teamID = ptr.Uint(0) -// } -// var cfgProfs []*fleet.MDMWindowsConfigProfile -// mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { -// return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, teamID) -// }) -// -// label := "exist" -// if !exists { -// label = "not exist" -// } -// require.Condition(t, func() bool { -// for _, p := range cfgProfs { -// if p.Name == profileName { -// return exists // success if we want it to exist, failure if we don't -// } -// } -// return !exists -// }, "a config profile must %s with name: %s", label, profileName) -//} +func (s *integrationMDMTestSuite) assertWindowsConfigProfilesByName(teamID *uint, profileName string, exists bool) { + t := s.T() + if teamID == nil { + teamID = ptr.Uint(0) + } + var cfgProfs []*fleet.MDMWindowsConfigProfile + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_windows_configuration_profiles WHERE team_id = ?`, teamID) + }) + + label := "exist" + if !exists { + label = "not exist" + } + require.Condition(t, func() bool { + for _, p := range cfgProfs { + if p.Name == profileName { + return exists // success if we want it to exist, failure if we don't + } + } + return !exists + }, "a config profile must %s with name: %s", label, profileName) +} // generates the body and headers part of a multipart request ready to be // used via s.DoRawWithHeaders to POST /api/_version_/fleet/mdm/apple/profiles. @@ -8000,389 +8001,389 @@ func (s *integrationMDMTestSuite) TestHostDiskEncryptionKey() { require.Equal(t, "", hostResp.Host.MDM.OSSettings.DiskEncryption.Detail) } -//func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { -// t := s.T() -// ctx := context.Background() -// -// testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"}) -// require.NoError(t, err) -// -// assertAppleProfile := func(filename, name, ident string, teamID uint, wantStatus int, wantErrMsg string) string { -// var tmPtr *uint -// if teamID > 0 { -// tmPtr = &teamID -// } -// body, headers := generateNewProfileMultipartRequest(t, tmPtr, -// filename, mobileconfigForTest(name, ident), s.token) -// res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), wantStatus, headers) -// -// if wantErrMsg != "" { -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, wantErrMsg) -// return "" -// } -// -// var resp newMDMConfigProfileResponse -// err := json.NewDecoder(res.Body).Decode(&resp) -// require.NoError(t, err) -// require.NotEmpty(t, resp.ProfileID) -// return resp.ProfileID -// } -// createAppleProfile := func(name, ident string, teamID uint) string { -// id := assertAppleProfile(name+".mobileconfig", name, ident, teamID, http.StatusOK, "") -// -// var wantJSON string -// if teamID == 0 { -// wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "profile_identifier": %q}`, name, ident) -// } else { -// wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "profile_identifier": %q}`, teamID, testTeam.Name, name, ident) -// } -// s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedMacosProfile{}.ActivityName(), wantJSON, 0) -// -// return id -// } -// -// assertWindowsProfile := func(filename, name, locURI string, teamID uint, wantStatus int, wantErrMsg string) string { -// var tmPtr *uint -// if teamID > 0 { -// tmPtr = &teamID -// } -// body, headers := generateNewProfileMultipartRequest(t, tmPtr, -// filename, []byte(fmt.Sprintf(`%s`, locURI)), s.token) -// res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), wantStatus, headers) -// -// if wantErrMsg != "" { -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, wantErrMsg) -// return "" -// } -// -// var resp newMDMConfigProfileResponse -// err := json.NewDecoder(res.Body).Decode(&resp) -// require.NoError(t, err) -// require.NotEmpty(t, resp.ProfileID) -// return resp.ProfileID -// } -// createWindowsProfile := func(name string, teamID uint) string { -// id := assertWindowsProfile(name+".xml", name, "./Test", teamID, http.StatusOK, "") -// -// var wantJSON string -// if teamID == 0 { -// wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name) -// } else { -// wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name) -// } -// s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.ActivityName(), wantJSON, 0) -// -// return id -// } -// -// // create a couple Apple profiles for no-team and team -// noTeamAppleProfID := createAppleProfile("apple-global-profile", "test-global-ident", 0) -// teamAppleProfID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID) -// // create a couple Windows profiles for no-team and team -// noTeamWinProfID := createWindowsProfile("win-global-profile", 0) -// teamWinProfID := createWindowsProfile("win-team-profile", testTeam.ID) -// -// // Windows profile name conflicts with Apple's for no team -// assertWindowsProfile("apple-global-profile.xml", "apple-global-profile", "./Test", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") -// // but no conflict for team 1 -// assertWindowsProfile("apple-global-profile.xml", "apple-global-profile", "./Test", testTeam.ID, http.StatusOK, "") -// // Apple profile name conflicts with Windows' for no team -// assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") -// // but no conflict for team 1 -// assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, http.StatusOK, "") -// // Windows profile name conflicts with Apple's for team 1 -// assertWindowsProfile("apple-team-profile.xml", "apple-team-profile", "./Test", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") -// // but no conflict for no-team -// assertWindowsProfile("apple-team-profile.xml", "apple-team-profile", "./Test", 0, http.StatusOK, "") -// // Apple profile name conflicts with Windows' for team 1 -// assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") -// // but no conflict for no-team -// assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, http.StatusOK, "") -// -// // not an xml nor mobileconfig file -// assertWindowsProfile("foo.txt", "foo", "./Test", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") -// assertAppleProfile("foo.txt", "foo", "foo-ident", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") -// -// // Windows-reserved LocURI -// assertWindowsProfile("bitlocker.xml", "bitlocker", microsoft_mdm.FleetBitLockerTargetLocURI, 0, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.") -// assertWindowsProfile("updates.xml", "updates", microsoft_mdm.FleetOSUpdateTargetLocURI, testTeam.ID, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include Windows updates settings.") -// -// // Windows invalid content -// body, headers := generateNewProfileMultipartRequest(t, nil, "win.xml", []byte("\x00\x01\x02"), s.token) -// res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), http.StatusBadRequest, headers) -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, "Couldn't upload. The file should include valid XML:") -// -// // Apple invalid content -// body, headers = generateNewProfileMultipartRequest(t, nil, -// "apple.mobileconfig", []byte("\x00\x01\x02"), s.token) -// res = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), http.StatusBadRequest, headers) -// errMsg = extractServerErrorText(res.Body) -// require.Contains(t, errMsg, "mobileconfig is not XML nor PKCS7 parseable") -// -// // get the existing profiles work -// expectedProfiles := []fleet.MDMConfigProfilePayload{ -// {ProfileID: fmt.Sprint(noTeamAppleProfID), Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil}, -// {ProfileID: fmt.Sprint(teamAppleProfID), Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID}, -// {ProfileID: noTeamWinProfID, Platform: "windows", Name: "win-global-profile", TeamID: nil}, -// {ProfileID: teamWinProfID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID}, -// } -// for _, prof := range expectedProfiles { -// var getResp getMDMConfigProfileResponse -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", prof.ProfileID), nil, http.StatusOK, &getResp) -// require.NotZero(t, getResp.CreatedAt) -// require.NotZero(t, getResp.UpdatedAt) -// if getResp.Platform == "darwin" { -// require.Len(t, getResp.Checksum, 16) -// } else { -// require.Empty(t, getResp.Checksum) -// } -// getResp.CreatedAt, getResp.UpdatedAt = time.Time{}, time.Time{} -// getResp.Checksum = nil -// require.Equal(t, prof, *getResp.MDMConfigProfilePayload) -// -// resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", prof.ProfileID), nil, http.StatusOK, "alt", "media") -// require.NotZero(t, resp.ContentLength) -// require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;") -// if getResp.Platform == "darwin" { -// require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") -// } else { -// require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream") -// } -// require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") -// -// b, err := io.ReadAll(resp.Body) -// require.NoError(t, err) -// require.Equal(t, resp.ContentLength, int64(len(b))) -// } -// -// var getResp getMDMConfigProfileResponse -// // get an unknown Apple profile -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", 99999), nil, http.StatusNotFound, &getResp) -// s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", 99999), nil, http.StatusNotFound, "alt", "media") -// // get an unknown Windows profile -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, &getResp) -// s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, "alt", "media") -// -// var deleteResp deleteMDMConfigProfileResponse -// // delete existing Apple profiles -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", noTeamAppleProfID), nil, http.StatusOK, &deleteResp) -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", teamAppleProfID), nil, http.StatusOK, &deleteResp) -// // delete non-existing Apple profile -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", 99999), nil, http.StatusNotFound, &deleteResp) -// // delete existing Windows profiles -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", noTeamWinProfID), nil, http.StatusOK, &deleteResp) -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", teamWinProfID), nil, http.StatusOK, &deleteResp) -// // delete non-existing Windows profile -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, &deleteResp) -// -// // trying to create/delete profiles managed by Fleet fails -// for p := range mobileconfig.FleetPayloadIdentifiers() { -// assertAppleProfile("foo.mobileconfig", p, p, 0, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p)) -// -// // create it directly in the DB to test deletion -// var id int64 -// mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { -// mc := mcBytesForTest(p, p, uuid.New().String()) -// res, err := q.ExecContext(ctx, -// "INSERT INTO mdm_apple_configuration_profiles (identifier, name, mobileconfig, checksum, team_id) VALUES (?, ?, ?, ?, ?)", -// p, p, mc, "1234", 0) -// if err != nil { -// return err -// } -// id, _ = res.LastInsertId() -// return nil -// }) -// -// var deleteResp deleteMDMConfigProfileResponse -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", id), nil, http.StatusBadRequest, &deleteResp) -// -// mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { -// _, err := q.ExecContext(ctx, -// "DELETE FROM mdm_apple_configuration_profiles WHERE profile_id = ?", -// id) -// return err -// }) -// } -// -// // make fleet add a FileVault profile -// acResp := appConfigResponse{} -// s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ -// "mdm": { "enable_disk_encryption": true } -// }`), http.StatusOK, &acResp) -// assert.True(t, acResp.MDM.EnableDiskEncryption.Value) -// profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) -// -// // try to delete the profile -// s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", profile.ProfileID), nil, http.StatusBadRequest, &deleteResp) -//} +func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { + t := s.T() + ctx := context.Background() -//func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { -// t := s.T() -// ctx := context.Background() -// -// // create some teams -// tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) -// require.NoError(t, err) -// tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) -// require.NoError(t, err) -// tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) -// require.NoError(t, err) -// -// // create 5 profiles for no team and team 1, names are A, B, C ... for global and -// // tA, tB, tC ... for team 1. Alternate macOS and Windows profiles. -// for i := 0; i < 5; i++ { -// name := string('A' + byte(i)) -// if i%2 == 0 { -// prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil) -// require.NoError(t, err) -// _, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof) -// require.NoError(t, err) -// -// tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil) -// require.NoError(t, err) -// tprof.TeamID = &tm1.ID -// _, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof) -// require.NoError(t, err) -// } else { -// _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(``)}) -// require.NoError(t, err) -// _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(``)}) -// require.NoError(t, err) -// } -// } -// -// // create a couple profiles (Win and mac) for team 2, and none for team 3 -// tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil) -// require.NoError(t, err) -// tprof.TeamID = &tm2.ID -// tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof) -// require.NoError(t, err) -// // checksum is not returned by New..., so compute it manually -// checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test -// -// tm2ProfF.Checksum = checkSum[:] -// tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "tG", TeamID: &tm2.ID, SyncML: []byte(``)}) -// require.NoError(t, err) -// -// // test that all fields are correctly returned with team 2 -// var listResp listMDMConfigProfilesResponse -// s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID)) -// require.Len(t, listResp.Profiles, 2) -// require.NotZero(t, listResp.Profiles[0].CreatedAt) -// require.NotZero(t, listResp.Profiles[0].UpdatedAt) -// require.NotZero(t, listResp.Profiles[1].CreatedAt) -// require.NotZero(t, listResp.Profiles[1].UpdatedAt) -// listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UpdatedAt = time.Time{}, time.Time{} -// listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UpdatedAt = time.Time{}, time.Time{} -// require.Equal(t, &fleet.MDMConfigProfilePayload{ -// ProfileID: fmt.Sprint(tm2ProfF.ProfileID), -// TeamID: tm2ProfF.TeamID, -// Name: tm2ProfF.Name, -// Platform: "darwin", -// Identifier: tm2ProfF.Identifier, -// Checksum: tm2ProfF.Checksum, -// }, listResp.Profiles[0]) -// require.Equal(t, &fleet.MDMConfigProfilePayload{ -// ProfileID: tm2ProfG.ProfileUUID, -// TeamID: tm2ProfG.TeamID, -// Name: tm2ProfG.Name, -// Platform: "windows", -// }, listResp.Profiles[1]) -// -// // list for a non-existing team returns 404 -// s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999") -// -// cases := []struct { -// queries []string // alternate query name and value -// teamID *uint -// wantNames []string -// wantMeta *fleet.PaginationMetadata -// }{ -// { -// wantNames: []string{"A", "B", "C", "D", "E"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, -// }, -// { -// queries: []string{"per_page", "2"}, -// wantNames: []string{"A", "B"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, -// }, -// { -// queries: []string{"per_page", "2", "page", "1"}, -// wantNames: []string{"C", "D"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, -// }, -// { -// queries: []string{"per_page", "2", "page", "2"}, -// wantNames: []string{"E"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, -// }, -// { -// queries: []string{"per_page", "3"}, -// teamID: &tm1.ID, -// wantNames: []string{"tA", "tB", "tC"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, -// }, -// { -// queries: []string{"per_page", "3", "page", "1"}, -// teamID: &tm1.ID, -// wantNames: []string{"tD", "tE"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, -// }, -// { -// queries: []string{"per_page", "3", "page", "2"}, -// teamID: &tm1.ID, -// wantNames: nil, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, -// }, -// { -// queries: []string{"per_page", "3"}, -// teamID: &tm2.ID, -// wantNames: []string{"tF", "tG"}, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, -// }, -// { -// queries: []string{"per_page", "2"}, -// teamID: &tm3.ID, -// wantNames: nil, -// wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, -// }, -// } -// for _, c := range cases { -// t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) { -// var listResp listMDMConfigProfilesResponse -// queryArgs := c.queries -// if c.teamID != nil { -// queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID)) -// } -// s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", nil, http.StatusOK, &listResp, queryArgs...) -// -// require.Equal(t, len(c.wantNames), len(listResp.Profiles)) -// require.Equal(t, c.wantMeta, listResp.Meta) -// -// var gotNames []string -// if len(listResp.Profiles) > 0 { -// gotNames = make([]string, len(listResp.Profiles)) -// for i, p := range listResp.Profiles { -// gotNames[i] = p.Name -// if c.teamID == nil { -// // we set it to 0 for global -// require.NotNil(t, p.TeamID) -// require.Zero(t, *p.TeamID) -// } else { -// require.NotNil(t, p.TeamID) -// require.Equal(t, *c.teamID, *p.TeamID) -// } -// require.NotEmpty(t, p.Platform) -// } -// } -// require.Equal(t, c.wantNames, gotNames) -// }) -// } -//} + testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"}) + require.NoError(t, err) + + assertAppleProfile := func(filename, name, ident string, teamID uint, wantStatus int, wantErrMsg string) string { + var tmPtr *uint + if teamID > 0 { + tmPtr = &teamID + } + body, headers := generateNewProfileMultipartRequest(t, tmPtr, + filename, mobileconfigForTest(name, ident), s.token) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), wantStatus, headers) + + if wantErrMsg != "" { + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, wantErrMsg) + return "" + } + + var resp newMDMConfigProfileResponse + err := json.NewDecoder(res.Body).Decode(&resp) + require.NoError(t, err) + require.NotEmpty(t, resp.ProfileID) + return resp.ProfileID + } + createAppleProfile := func(name, ident string, teamID uint) string { + id := assertAppleProfile(name+".mobileconfig", name, ident, teamID, http.StatusOK, "") + + var wantJSON string + if teamID == 0 { + wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "profile_identifier": %q}`, name, ident) + } else { + wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "profile_identifier": %q}`, teamID, testTeam.Name, name, ident) + } + s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedMacosProfile{}.ActivityName(), wantJSON, 0) + + return id + } + + assertWindowsProfile := func(filename, name, locURI string, teamID uint, wantStatus int, wantErrMsg string) string { + var tmPtr *uint + if teamID > 0 { + tmPtr = &teamID + } + body, headers := generateNewProfileMultipartRequest(t, tmPtr, + filename, []byte(fmt.Sprintf(`%s`, locURI)), s.token) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), wantStatus, headers) + + if wantErrMsg != "" { + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, wantErrMsg) + return "" + } + + var resp newMDMConfigProfileResponse + err := json.NewDecoder(res.Body).Decode(&resp) + require.NoError(t, err) + require.NotEmpty(t, resp.ProfileID) + return resp.ProfileID + } + createWindowsProfile := func(name string, teamID uint) string { + id := assertWindowsProfile(name+".xml", name, "./Test", teamID, http.StatusOK, "") + + var wantJSON string + if teamID == 0 { + wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name) + } else { + wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name) + } + s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.ActivityName(), wantJSON, 0) + + return id + } + + // create a couple Apple profiles for no-team and team + noTeamAppleProfID := createAppleProfile("apple-global-profile", "test-global-ident", 0) + teamAppleProfID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID) + // create a couple Windows profiles for no-team and team + noTeamWinProfID := createWindowsProfile("win-global-profile", 0) + teamWinProfID := createWindowsProfile("win-team-profile", testTeam.ID) + + // Windows profile name conflicts with Apple's for no team + assertWindowsProfile("apple-global-profile.xml", "apple-global-profile", "./Test", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") + // but no conflict for team 1 + assertWindowsProfile("apple-global-profile.xml", "apple-global-profile", "./Test", testTeam.ID, http.StatusOK, "") + // Apple profile name conflicts with Windows' for no team + assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") + // but no conflict for team 1 + assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, http.StatusOK, "") + // Windows profile name conflicts with Apple's for team 1 + assertWindowsProfile("apple-team-profile.xml", "apple-team-profile", "./Test", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") + // but no conflict for no-team + assertWindowsProfile("apple-team-profile.xml", "apple-team-profile", "./Test", 0, http.StatusOK, "") + // Apple profile name conflicts with Windows' for team 1 + assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.") + // but no conflict for no-team + assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, http.StatusOK, "") + + // not an xml nor mobileconfig file + assertWindowsProfile("foo.txt", "foo", "./Test", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") + assertAppleProfile("foo.txt", "foo", "foo-ident", 0, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.") + + // Windows-reserved LocURI + assertWindowsProfile("bitlocker.xml", "bitlocker", microsoft_mdm.FleetBitLockerTargetLocURI, 0, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.") + assertWindowsProfile("updates.xml", "updates", microsoft_mdm.FleetOSUpdateTargetLocURI, testTeam.ID, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include Windows updates settings.") + + // Windows invalid content + body, headers := generateNewProfileMultipartRequest(t, nil, "win.xml", []byte("\x00\x01\x02"), s.token) + res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), http.StatusBadRequest, headers) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Couldn't upload. The file should include valid XML:") + + // Apple invalid content + body, headers = generateNewProfileMultipartRequest(t, nil, + "apple.mobileconfig", []byte("\x00\x01\x02"), s.token) + res = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/profiles", body.Bytes(), http.StatusBadRequest, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "mobileconfig is not XML nor PKCS7 parseable") + + // get the existing profiles work + expectedProfiles := []fleet.MDMConfigProfilePayload{ + {ProfileID: fmt.Sprint(noTeamAppleProfID), Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil}, + {ProfileID: fmt.Sprint(teamAppleProfID), Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID}, + {ProfileID: noTeamWinProfID, Platform: "windows", Name: "win-global-profile", TeamID: nil}, + {ProfileID: teamWinProfID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID}, + } + for _, prof := range expectedProfiles { + var getResp getMDMConfigProfileResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", prof.ProfileID), nil, http.StatusOK, &getResp) + require.NotZero(t, getResp.CreatedAt) + require.NotZero(t, getResp.UpdatedAt) + if getResp.Platform == "darwin" { + require.Len(t, getResp.Checksum, 16) + } else { + require.Empty(t, getResp.Checksum) + } + getResp.CreatedAt, getResp.UpdatedAt = time.Time{}, time.Time{} + getResp.Checksum = nil + require.Equal(t, prof, *getResp.MDMConfigProfilePayload) + + resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", prof.ProfileID), nil, http.StatusOK, "alt", "media") + require.NotZero(t, resp.ContentLength) + require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;") + if getResp.Platform == "darwin" { + require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") + } else { + require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream") + } + require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, resp.ContentLength, int64(len(b))) + } + + var getResp getMDMConfigProfileResponse + // get an unknown Apple profile + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", 99999), nil, http.StatusNotFound, &getResp) + s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", 99999), nil, http.StatusNotFound, "alt", "media") + // get an unknown Windows profile + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, &getResp) + s.Do("GET", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, "alt", "media") + + var deleteResp deleteMDMConfigProfileResponse + // delete existing Apple profiles + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", noTeamAppleProfID), nil, http.StatusOK, &deleteResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", teamAppleProfID), nil, http.StatusOK, &deleteResp) + // delete non-existing Apple profile + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", 99999), nil, http.StatusNotFound, &deleteResp) + // delete existing Windows profiles + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", noTeamWinProfID), nil, http.StatusOK, &deleteResp) + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", teamWinProfID), nil, http.StatusOK, &deleteResp) + // delete non-existing Windows profile + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%s", "no-such-profile"), nil, http.StatusNotFound, &deleteResp) + + // trying to create/delete profiles managed by Fleet fails + for p := range mobileconfig.FleetPayloadIdentifiers() { + assertAppleProfile("foo.mobileconfig", p, p, 0, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p)) + + // create it directly in the DB to test deletion + var id int64 + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + mc := mcBytesForTest(p, p, uuid.New().String()) + res, err := q.ExecContext(ctx, + "INSERT INTO mdm_apple_configuration_profiles (identifier, name, mobileconfig, checksum, team_id) VALUES (?, ?, ?, ?, ?)", + p, p, mc, "1234", 0) + if err != nil { + return err + } + id, _ = res.LastInsertId() + return nil + }) + + var deleteResp deleteMDMConfigProfileResponse + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", id), nil, http.StatusBadRequest, &deleteResp) + + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + "DELETE FROM mdm_apple_configuration_profiles WHERE profile_id = ?", + id) + return err + }) + } + + // make fleet add a FileVault profile + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusOK, &acResp) + assert.True(t, acResp.MDM.EnableDiskEncryption.Value) + profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) + + // try to delete the profile + s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/profiles/%d", profile.ProfileID), nil, http.StatusBadRequest, &deleteResp) +} + +func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { + t := s.T() + ctx := context.Background() + + // create some teams + tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) + require.NoError(t, err) + + // create 5 profiles for no team and team 1, names are A, B, C ... for global and + // tA, tB, tC ... for team 1. Alternate macOS and Windows profiles. + for i := 0; i < 5; i++ { + name := string('A' + byte(i)) + if i%2 == 0 { + prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil) + require.NoError(t, err) + _, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof) + require.NoError(t, err) + + tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil) + require.NoError(t, err) + tprof.TeamID = &tm1.ID + _, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof) + require.NoError(t, err) + } else { + _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(``)}) + require.NoError(t, err) + _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(``)}) + require.NoError(t, err) + } + } + + // create a couple profiles (Win and mac) for team 2, and none for team 3 + tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil) + require.NoError(t, err) + tprof.TeamID = &tm2.ID + tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof) + require.NoError(t, err) + // checksum is not returned by New..., so compute it manually + checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test + + tm2ProfF.Checksum = checkSum[:] + tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "tG", TeamID: &tm2.ID, SyncML: []byte(``)}) + require.NoError(t, err) + + // test that all fields are correctly returned with team 2 + var listResp listMDMConfigProfilesResponse + s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID)) + require.Len(t, listResp.Profiles, 2) + require.NotZero(t, listResp.Profiles[0].CreatedAt) + require.NotZero(t, listResp.Profiles[0].UpdatedAt) + require.NotZero(t, listResp.Profiles[1].CreatedAt) + require.NotZero(t, listResp.Profiles[1].UpdatedAt) + listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UpdatedAt = time.Time{}, time.Time{} + listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UpdatedAt = time.Time{}, time.Time{} + require.Equal(t, &fleet.MDMConfigProfilePayload{ + ProfileID: fmt.Sprint(tm2ProfF.ProfileID), + TeamID: tm2ProfF.TeamID, + Name: tm2ProfF.Name, + Platform: "darwin", + Identifier: tm2ProfF.Identifier, + Checksum: tm2ProfF.Checksum, + }, listResp.Profiles[0]) + require.Equal(t, &fleet.MDMConfigProfilePayload{ + ProfileID: tm2ProfG.ProfileUUID, + TeamID: tm2ProfG.TeamID, + Name: tm2ProfG.Name, + Platform: "windows", + }, listResp.Profiles[1]) + + // list for a non-existing team returns 404 + s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999") + + cases := []struct { + queries []string // alternate query name and value + teamID *uint + wantNames []string + wantMeta *fleet.PaginationMetadata + }{ + { + wantNames: []string{"A", "B", "C", "D", "E"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2"}, + wantNames: []string{"A", "B"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2", "page", "1"}, + wantNames: []string{"C", "D"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "2", "page", "2"}, + wantNames: []string{"E"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3"}, + teamID: &tm1.ID, + wantNames: []string{"tA", "tB", "tC"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "3", "page", "1"}, + teamID: &tm1.ID, + wantNames: []string{"tD", "tE"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3", "page", "2"}, + teamID: &tm1.ID, + wantNames: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, + }, + { + queries: []string{"per_page", "3"}, + teamID: &tm2.ID, + wantNames: []string{"tF", "tG"}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + { + queries: []string{"per_page", "2"}, + teamID: &tm3.ID, + wantNames: nil, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, + }, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) { + var listResp listMDMConfigProfilesResponse + queryArgs := c.queries + if c.teamID != nil { + queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID)) + } + s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", nil, http.StatusOK, &listResp, queryArgs...) + + require.Equal(t, len(c.wantNames), len(listResp.Profiles)) + require.Equal(t, c.wantMeta, listResp.Meta) + + var gotNames []string + if len(listResp.Profiles) > 0 { + gotNames = make([]string, len(listResp.Profiles)) + for i, p := range listResp.Profiles { + gotNames[i] = p.Name + if c.teamID == nil { + // we set it to 0 for global + require.NotNil(t, p.TeamID) + require.Zero(t, *p.TeamID) + } else { + require.NotNil(t, p.TeamID) + require.Equal(t, *c.teamID, *p.TeamID) + } + require.NotEmpty(t, p.Platform) + } + } + require.Equal(t, c.wantNames, gotNames) + }) + } +} // /////////////////////////////////////////////////////////////////////////// // Common MDM config test @@ -8665,65 +8666,65 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { ac := appConfig.Copy() ac.AgentOptions = nil ac.MDM.MacOSSettings.CustomSettings = []string{} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) require.NoError(t, s.ds.SaveAppConfig(ctx, ac)) acResp := checkAppConfig(t, true, true) require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // add custom settings ac.MDM.MacOSSettings.CustomSettings = []string{"foo", "bar"} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, true, true) // both mac and windows mdm enabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // applied - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // applied + acResp = checkAppConfig(t, true, true) // both mac and windows mdm enabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // applied + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // applied // directly set MDM.EnabledAndConfigured to false ac.MDM.EnabledAndConfigured = false require.NoError(t, s.ds.SaveAppConfig(ctx, ac)) - acResp = checkAppConfig(t, false, true) // only windows mdm enabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // still applied - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // still applied + acResp = checkAppConfig(t, false, true) // only windows mdm enabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // still applied + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // still applied // making an unrelated change should not cause validation error ac.OrgInfo.OrgName = "f1337" s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, false, true) // only windows mdm enabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // still applied - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // still applied + acResp = checkAppConfig(t, false, true) // only windows mdm enabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // still applied + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // still applied require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName) // remove custom settings ac.MDM.MacOSSettings.CustomSettings = []string{} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, false, true) // only windows mdm enabled require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // add custom macOS settings fails because only windows is enabled ac.MDM.MacOSSettings.CustomSettings = []string{"foo", "bar"} s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp) acResp = checkAppConfig(t, false, true) // only windows enabled require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // add custom Windows settings suceeds because only macOS is disabled ac.MDM.MacOSSettings.CustomSettings = []string{} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, false, true) // only windows mdm enabled - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // applied - require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) // no change + acResp = checkAppConfig(t, false, true) // only windows mdm enabled + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // applied + require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) // no change // cleanup Windows settings ac.MDM.MacOSSettings.CustomSettings = []string{} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, false, true) // only windows mdm enabled require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // directly set MDM.EnabledAndConfigured to true and windows to false ac.MDM.EnabledAndConfigured = true @@ -8731,23 +8732,23 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { require.NoError(t, s.ds.SaveAppConfig(ctx, ac)) acResp = checkAppConfig(t, true, false) // mac enabled, windows disabled require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // directly applied - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // still empty + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // still empty // add custom windows settings fails because only mac is enabled - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) - // s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp) - // acResp = checkAppConfig(t, true, false) // only mac enabled - // require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp) + acResp = checkAppConfig(t, true, false) // only mac enabled + require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // set this value to empty again so we can test other assertions assuming we're not setting it - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) // changing unrelated config doesn't cause validation error ac.OrgInfo.OrgName = "f1338" s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, true, false) // mac enabled, windows disabled require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // no change + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // no change require.Equal(t, "f1338", acResp.AppConfig.OrgInfo.OrgName) // remove custom settings doesn't cause validation error @@ -8761,7 +8762,7 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, true, false) // mac enabled, windows disabled require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // applied - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // no change + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // no change // temporarily enable and add custom settings for both platforms ac.MDM.EnabledAndConfigured = true @@ -8769,62 +8770,62 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() { require.NoError(t, s.ds.SaveAppConfig(ctx, ac)) acResp = checkAppConfig(t, true, true) // both mac and windows mdm enabled ac.MDM.MacOSSettings.CustomSettings = []string{"foo", "bar"} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, true, true) // both mac and windows mdm enabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // applied - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // applied + acResp = checkAppConfig(t, true, true) // both mac and windows mdm enabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // applied + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // applied // directly set both configs to false ac.MDM.EnabledAndConfigured = false ac.MDM.WindowsEnabledAndConfigured = false require.NoError(t, s.ds.SaveAppConfig(ctx, ac)) - acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change + acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change // changing unrelated config doesn't cause validation error ac.OrgInfo.OrgName = "f1339" s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, false, false) // both disabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change + acResp = checkAppConfig(t, false, false) // both disabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change require.Equal(t, "f1339", acResp.AppConfig.OrgInfo.OrgName) // setting the same values is ok even if mdm is disabled ac.MDM.MacOSSettings.CustomSettings = []string{"foo", "bar"} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) - acResp = checkAppConfig(t, false, false) // both disabled - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change + acResp = checkAppConfig(t, false, false) // both disabled + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change // setting different values fail even if mdm is disabled, and only some of the profiles have changed ac.MDM.MacOSSettings.CustomSettings = []string{"oof", "bar"} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"foo", "zab"}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"foo", "zab"}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp) acResp = checkAppConfig(t, false, false) // both disabled // set the values back so we can compare them ac.MDM.MacOSSettings.CustomSettings = []string{"foo", "bar"} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) - require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change - // require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + require.ElementsMatch(t, acResp.MDM.MacOSSettings.CustomSettings, ac.MDM.MacOSSettings.CustomSettings) // no change + require.ElementsMatch(t, acResp.MDM.WindowsSettings.CustomSettings.Value, ac.MDM.WindowsSettings.CustomSettings.Value) // no change // setting empty values doesn't cause validation error when mdm is disabled ac.MDM.MacOSSettings.CustomSettings = []string{} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp) acResp = checkAppConfig(t, false, false) // both disabled require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) // setting non-empty values fails because mdm disabled ac.MDM.MacOSSettings.CustomSettings = []string{"foo", "bar"} - // ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) + ac.MDM.WindowsSettings.CustomSettings = optjson.SetSlice([]string{"baz", "zab"}) s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp) acResp = checkAppConfig(t, false, false) // both disabled require.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) - // require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) + require.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) }) }) @@ -9363,13 +9364,12 @@ func (s *integrationMDMTestSuite) checkMDMProfilesSummaries(t *testing.T, teamID require.Equal(t, expectedSummary.Verified, apple.Verified) } - // FIXME: commenting out to get the release out - // var combined getMDMProfilesSummaryResponse - // s.DoJSON("GET", "/api/v1/fleet/mdm/profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &combined, queryParams...) - // require.Equal(t, expectedSummary.Failed, combined.Failed) - // require.Equal(t, expectedSummary.Pending, combined.Pending) - // require.Equal(t, expectedSummary.Verifying, combined.Verifying) - // require.Equal(t, expectedSummary.Verified, combined.Verified) + var combined getMDMProfilesSummaryResponse + s.DoJSON("GET", "/api/v1/fleet/mdm/profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &combined, queryParams...) + require.Equal(t, expectedSummary.Failed, combined.Failed) + require.Equal(t, expectedSummary.Pending, combined.Pending) + require.Equal(t, expectedSummary.Verifying, combined.Verifying) + require.Equal(t, expectedSummary.Verified, combined.Verified) } func (s *integrationMDMTestSuite) checkMDMDiskEncryptionSummaries(t *testing.T, teamID *uint, expectedSummary fleet.MDMDiskEncryptionSummary, checkFileVaultSummary bool) { @@ -9473,6 +9473,10 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { if c.Verb == "Atomic" { atomicCmds = append(atomicCmds, c) status = mdmResponseStatus + require.NotEmpty(t, c.Cmd.ReplaceCommands) + for _, rc := range c.Cmd.ReplaceCommands { + require.NotEmpty(t, rc.CmdID) + } } device.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: mdm_types.CmdStatus}, @@ -9583,233 +9587,233 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { verifyProfiles(mdmDevice, 0, false) } -//func (s *integrationMDMTestSuite) TestAppConfigMDMWindowsProfiles() { -// t := s.T() -// -// // set the windows custom settings fields -// acResp := appConfigResponse{} -// s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ -// "mdm": { "windows_settings": { "custom_settings": ["foo", "bar"] } } -// }`), http.StatusOK, &acResp) -// assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) -// -// // check that they are returned by a GET /config -// acResp = appConfigResponse{} -// s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) -// assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) -// -// // patch without specifying the windows custom settings fields and an unrelated -// // field, should not remove them -// acResp = appConfigResponse{} -// s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ -// "mdm": { "enable_disk_encryption": true } -// }`), http.StatusOK, &acResp) -// assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) -// -// // patch with explicitly empty windows custom settings fields, would remove -// // them but this is a dry-run -// acResp = appConfigResponse{} -// s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ -// "mdm": { "windows_settings": { "custom_settings": null } } -// }`), http.StatusOK, &acResp, "dry_run", "true") -// assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) -// -// // patch with explicitly empty windows custom settings fields, removes them -// acResp = appConfigResponse{} -// s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ -// "mdm": { "windows_settings": { "custom_settings": null } } -// }`), http.StatusOK, &acResp) -// assert.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) -//} +func (s *integrationMDMTestSuite) TestAppConfigMDMWindowsProfiles() { + t := s.T() -//func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() { -// t := s.T() -// -// // create a team through the service so it initializes the agent ops -// 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 -// -// rawTeamSpec := func(mdmValue string) json.RawMessage { -// return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue)) -// } -// -// set the windows custom settings fields -// var applyResp applyTeamSpecsResponse -// s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` -// { "windows_settings": { "custom_settings": ["foo", "bar"] } } -// `), http.StatusOK, &applyResp) -// require.Len(t, applyResp.TeamIDsByName, 1) -// -// check that they are returned by a GET /config -// var teamResp getTeamResponse -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) -// require.ElementsMatch(t, []string{"foo", "bar"}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) -// -// patch without specifying the windows custom settings fields and an unrelated -// field, should not remove them -// applyResp = applyTeamSpecsResponse{} -// s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp) -// require.Len(t, applyResp.TeamIDsByName, 1) -// -// check that they are returned by a GET /config -// teamResp = getTeamResponse{} -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) -// require.ElementsMatch(t, []string{"foo", "bar"}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) -// -// patch with explicitly empty windows custom settings fields, would remove -// them but this is a dry-run -// applyResp = applyTeamSpecsResponse{} -// s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` -// { "windows_settings": { "custom_settings": null } } -// `), http.StatusOK, &applyResp, "dry_run", "true") -// require.Len(t, applyResp.TeamIDsByName, 0) -// -// teamResp = getTeamResponse{} -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) -// require.ElementsMatch(t, []string{"foo", "bar"}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) -// -// patch with explicitly empty windows custom settings fields, removes them -// applyResp = applyTeamSpecsResponse{} -// s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` -// { "windows_settings": { "custom_settings": null } } -// `), http.StatusOK, &applyResp) -// require.Len(t, applyResp.TeamIDsByName, 1) -// -// teamResp = getTeamResponse{} -// s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) -// require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) -//} + // set the windows custom settings fields + acResp := appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_settings": { "custom_settings": ["foo", "bar"] } } + }`), http.StatusOK, &acResp) + assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) -//func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { -// t := s.T() -// ctx := context.Background() -// -// // create a new team -// tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) -// require.NoError(t, err) -// -// // apply an empty set to no-team -// s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent) -// s.lastActivityOfTypeMatches( -// fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), -// `{"team_id": null, "team_name": null}`, -// 0, -// ) -// s.lastActivityOfTypeMatches( -// fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), -// `{"team_id": null, "team_name": null}`, -// 0, -// ) -// -// // apply to both team id and name -// s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, -// http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name) -// -// // invalid team name -// s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, -// http.StatusNotFound, "team_name", uuid.New().String()) -// -// // duplicate PayloadDisplayName -// s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTest("N1", "I1"), -// "N2": mobileconfigForTest("N1", "I2"), -// "N3": syncMLForTest("./Foo/Bar"), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// -// // profiles with reserved macOS identifiers -// for p := range mobileconfig.FleetPayloadIdentifiers() { -// res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTest("N1", "I1"), -// p: mobileconfigForTest(p, p), -// "N3": syncMLForTest("./Foo/Bar"), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p)) -// } -// -// // payloads with reserved types -// for p := range mobileconfig.FleetPayloadTypes() { -// res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTestWithContent("N1", "I1", "II1", p), -// "N3": syncMLForTest("./Foo/Bar"), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p)) -// } -// -// // payloads with reserved identifiers -// for p := range mobileconfig.FleetPayloadIdentifiers() { -// res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTestWithContent("N1", "I1", p, "random"), -// "N3": syncMLForTest("./Foo/Bar"), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p)) -// } -// -// // profiles with reserved Windows location URIs -// // bitlocker -// res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTest("N1", "I1"), -// microsoft_mdm.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", microsoft_mdm.FleetBitLockerTargetLocURI)), -// "N3": syncMLForTest("./Foo/Bar"), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg := extractServerErrorText(res.Body) -// require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.") -// -// // os updates -// res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTest("N1", "I1"), -// microsoft_mdm.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", microsoft_mdm.FleetOSUpdateTargetLocURI)), -// "N3": syncMLForTest("./Foo/Bar"), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg = extractServerErrorText(res.Body) -// require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.") -// -// // invalid windows tag -// res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N3": []byte(``), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg = extractServerErrorText(res.Body) -// require.Contains(t, errMsg, "Only supported as a top level element") -// -// // invalid xml -// res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N3": []byte(`foo`), -// }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) -// errMsg = extractServerErrorText(res.Body) -// require.Contains(t, errMsg, "Only supported as a top level element") -// -// // successfully apply windows and macOS a profiles for the team, but it's a dry run -// s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTest("N1", "I1"), -// "N2": syncMLForTest("./Foo/Bar"), -// }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true") -// s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false) -// s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false) -// -// // successfully apply for a team and verify activities -// s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ -// "N1": mobileconfigForTest("N1", "I1"), -// "N2": syncMLForTest("./Foo/Bar"), -// }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID))) -// s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true) -// s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true) -// s.lastActivityOfTypeMatches( -// fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), -// fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), -// 0, -// ) -// s.lastActivityOfTypeMatches( -// fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), -// fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), -// 0, -// ) -//} + // check that they are returned by a GET /config + acResp = appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) + + // patch without specifying the windows custom settings fields and an unrelated + // field, should not remove them + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "enable_disk_encryption": true } + }`), http.StatusOK, &acResp) + assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) + + // patch with explicitly empty windows custom settings fields, would remove + // them but this is a dry-run + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_settings": { "custom_settings": null } } + }`), http.StatusOK, &acResp, "dry_run", "true") + assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.WindowsSettings.CustomSettings.Value) + + // patch with explicitly empty windows custom settings fields, removes them + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "mdm": { "windows_settings": { "custom_settings": null } } + }`), http.StatusOK, &acResp) + assert.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value) +} + +func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() { + t := s.T() + + // create a team through the service so it initializes the agent ops + 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 + + rawTeamSpec := func(mdmValue string) json.RawMessage { + return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue)) + } + + // set the windows custom settings fields + var applyResp applyTeamSpecsResponse + s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` + { "windows_settings": { "custom_settings": ["foo", "bar"] } } + `), http.StatusOK, &applyResp) + require.Len(t, applyResp.TeamIDsByName, 1) + + // check that they are returned by a GET /config + var teamResp getTeamResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.ElementsMatch(t, []string{"foo", "bar"}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) + + // patch without specifying the windows custom settings fields and an unrelated + // field, should not remove them + applyResp = applyTeamSpecsResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp) + require.Len(t, applyResp.TeamIDsByName, 1) + + // check that they are returned by a GET /config + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.ElementsMatch(t, []string{"foo", "bar"}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) + + // patch with explicitly empty windows custom settings fields, would remove + // them but this is a dry-run + applyResp = applyTeamSpecsResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` + { "windows_settings": { "custom_settings": null } } + `), http.StatusOK, &applyResp, "dry_run", "true") + require.Len(t, applyResp.TeamIDsByName, 0) + + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.ElementsMatch(t, []string{"foo", "bar"}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) + + // patch with explicitly empty windows custom settings fields, removes them + applyResp = applyTeamSpecsResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` + { "windows_settings": { "custom_settings": null } } + `), http.StatusOK, &applyResp) + require.Len(t, applyResp.TeamIDsByName, 1) + + teamResp = getTeamResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) + require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) +} + +func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { + t := s.T() + ctx := context.Background() + + // create a new team + tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) + require.NoError(t, err) + + // apply an empty set to no-team + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent) + s.lastActivityOfTypeMatches( + fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), + `{"team_id": null, "team_name": null}`, + 0, + ) + s.lastActivityOfTypeMatches( + fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), + `{"team_id": null, "team_name": null}`, + 0, + ) + + // apply to both team id and name + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, + http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name) + + // invalid team name + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, + http.StatusNotFound, "team_name", uuid.New().String()) + + // duplicate PayloadDisplayName + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + "N2": mobileconfigForTest("N1", "I2"), + "N3": syncMLForTest("./Foo/Bar"), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + + // profiles with reserved macOS identifiers + for p := range mobileconfig.FleetPayloadIdentifiers() { + res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + p: mobileconfigForTest(p, p), + "N3": syncMLForTest("./Foo/Bar"), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p)) + } + + // payloads with reserved types + for p := range mobileconfig.FleetPayloadTypes() { + res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTestWithContent("N1", "I1", "II1", p), + "N3": syncMLForTest("./Foo/Bar"), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p)) + } + + // payloads with reserved identifiers + for p := range mobileconfig.FleetPayloadIdentifiers() { + res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTestWithContent("N1", "I1", p, "random"), + "N3": syncMLForTest("./Foo/Bar"), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p)) + } + + // profiles with reserved Windows location URIs + // bitlocker + res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + microsoft_mdm.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", microsoft_mdm.FleetBitLockerTargetLocURI)), + "N3": syncMLForTest("./Foo/Bar"), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.") + + // os updates + res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + microsoft_mdm.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", microsoft_mdm.FleetOSUpdateTargetLocURI)), + "N3": syncMLForTest("./Foo/Bar"), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.") + + // invalid windows tag + res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N3": []byte(``), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Only supported as a top level element") + + // invalid xml + res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N3": []byte(`foo`), + }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID))) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Only supported as a top level element") + + // successfully apply windows and macOS a profiles for the team, but it's a dry run + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + "N2": syncMLForTest("./Foo/Bar"), + }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true") + s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false) + s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false) + + // successfully apply for a team and verify activities + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + "N2": syncMLForTest("./Foo/Bar"), + }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID))) + s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true) + s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true) + s.lastActivityOfTypeMatches( + fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), + fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), + 0, + ) + s.lastActivityOfTypeMatches( + fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), + fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), + 0, + ) +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index ec9dd32da5..f65e55028e 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1029,8 +1029,8 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { {"duplicate profile name", 0, `duplicate`, true, "configuration profile with this name already exists."}, {"multiple Replace", 0, `ab`, true, ""}, {"Replace and non-Replace", 0, `ab`, true, "Only supported as a top level element."}, - {"BitLocker profile", 0, `./Device/Vendor/MSFT/BitLocker/AllowStandardUserEncryption`, true, "Custom configuration profiles can't include BitLocker settings."}, - {"Windows updates profile", 0, ` ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoRebootForFeatureUpdates `, true, "Custom configuration profiles can't include Windows updates settings."}, + {"BitLocker profile", 0, `./Device/Vendor/MSFT/BitLocker/AllowStandardUserEncryption`, true, "Custom configuration profiles can't include BitLocker settings."}, + {"Windows updates profile", 0, ` ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoRebootForFeatureUpdates `, true, "Custom configuration profiles can't include Windows updates settings."}, {"team empty profile", 1, "", true, "The file should include valid XML."}, {"team plist data", 1, string(mcBytesForTest("Foo", "Bar", "UUID")), true, "Only supported as a top level element."}, @@ -1040,8 +1040,8 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) { {"team duplicate profile name", 1, `duplicate`, true, "configuration profile with this name already exists."}, {"team multiple Replace", 1, `ab`, true, ""}, {"team Replace and non-Replace", 1, `ab`, true, "Only supported as a top level element."}, - {"team BitLocker profile", 1, `./Device/Vendor/MSFT/BitLocker/AllowStandardUserEncryption`, true, "Custom configuration profiles can't include BitLocker settings."}, - {"team Windows updates profile", 1, ` ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoRebootForFeatureUpdates `, true, "Custom configuration profiles can't include Windows updates settings."}, + {"team BitLocker profile", 1, `./Device/Vendor/MSFT/BitLocker/AllowStandardUserEncryption`, true, "Custom configuration profiles can't include BitLocker settings."}, + {"team Windows updates profile", 1, ` ./Device/Vendor/MSFT/Policy/Config/Update/ConfigureDeadlineNoAutoRebootForFeatureUpdates `, true, "Custom configuration profiles can't include Windows updates settings."}, {"invalid team", 2, ``, true, "not found"}, } @@ -1093,7 +1093,7 @@ func TestMDMBatchSetProfiles(t *testing.T) { ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { return &fleet.Team{ID: id, Name: "team"}, nil } - ds.BatchSetMDMAppleProfilesFunc = func(ctx context.Context, tmID *uint, profiles []*fleet.MDMAppleConfigProfile) error { + ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error { return nil } ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { @@ -1109,7 +1109,7 @@ func TestMDMBatchSetProfiles(t *testing.T) { premium bool teamID *uint teamName *string - profiles [][]byte + profiles map[string][]byte wantErr string }{ { @@ -1271,11 +1271,11 @@ func TestMDMBatchSetProfiles(t *testing.T) { true, ptr.Uint(1), nil, - [][]byte{ - mobileconfigForTest("N1", "I1"), - mobileconfigForTest("N1", "I2"), + map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + "N2": mobileconfigForTest("N1", "I2"), }, - `More than one configuration profile have the same name `, + `The name provided for the profile must match the profile PayloadDisplayName: "N1"`, }, { "duplicate macOS profile identifier", @@ -1283,12 +1283,12 @@ func TestMDMBatchSetProfiles(t *testing.T) { true, ptr.Uint(1), nil, - [][]byte{ - mobileconfigForTest("N1", "I1"), - mobileconfigForTest("N2", "I2"), - mobileconfigForTest("N3", "I1"), + map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + "N2": mobileconfigForTest("N2", "I2"), + "N3": mobileconfigForTest("N3", "I1"), }, - `More than one configuration profile have the same identifier `, + `More than one configuration profile have the same identifier (PayloadIdentifier): "I1"`, }, { "only macOS", @@ -1296,50 +1296,50 @@ func TestMDMBatchSetProfiles(t *testing.T) { false, nil, nil, - [][]byte{ - mobileconfigForTest("N1", "I1"), - mobileconfigForTest("N2", "I2"), - mobileconfigForTest("N3", "I3"), + map[string][]byte{ + "N1": mobileconfigForTest("N1", "I1"), + "N2": mobileconfigForTest("N2", "I2"), + "N3": mobileconfigForTest("N3", "I3"), + }, + ``, + }, + { + "mixed profiles", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + nil, + nil, + map[string][]byte{ + "N1": syncMLForTest("./foo/bar"), + "N2": syncMLForTest("./baz"), + "N3": syncMLForTest("./zab"), + "N4": mobileconfigForTest("N4", "I1"), + "N5": mobileconfigForTest("N5", "I2"), + "N6": mobileconfigForTest("N6", "I3"), + }, + ``, + }, + { + "only windows", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + nil, + nil, + map[string][]byte{ + "N1": syncMLForTest("./foo/bar"), + "N2": syncMLForTest("./baz"), + "N3": syncMLForTest("./zab"), }, ``, }, - // { - // "mixed profiles", - // &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, - // false, - // nil, - // nil, - // [][]byte{ - // syncMLForTest("./foo/bar"), - // syncMLForTest("./baz"), - // syncMLForTest("./zab"), - // mobileconfigForTest("N4", "I1"), - // mobileconfigForTest("N5", "I2"), - // mobileconfigForTest("N6", "I3"), - // }, - // ``, - // }, - // { - // "only windows", - // &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, - // false, - // nil, - // nil, - // [][]byte{ - // syncMLForTest("./foo/bar"), - // syncMLForTest("./baz"), - // syncMLForTest("./zab"), - // }, - // ``, - // }, { "unsupported payload type", &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, nil, nil, - [][]byte{ - []byte(` + map[string][]byte{ + "foo": []byte(` @@ -1379,7 +1379,7 @@ func TestMDMBatchSetProfiles(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - defer func() { ds.BatchSetMDMAppleProfilesFuncInvoked = false }() + defer func() { ds.BatchSetMDMProfilesFuncInvoked = false }() // prepare the context with the user and license ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) @@ -1389,15 +1389,15 @@ func TestMDMBatchSetProfiles(t *testing.T) { } ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: tier}) - err := svc.BatchSetMDMAppleProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false) + err := svc.BatchSetMDMProfiles(ctx, tt.teamID, tt.teamName, tt.profiles, false, false) if tt.wantErr == "" { require.NoError(t, err) - require.True(t, ds.BatchSetMDMAppleProfilesFuncInvoked) + require.True(t, ds.BatchSetMDMProfilesFuncInvoked) return } require.Error(t, err) require.ErrorContains(t, err, tt.wantErr) - require.False(t, ds.BatchSetMDMAppleProfilesFuncInvoked) + require.False(t, ds.BatchSetMDMProfilesFuncInvoked) }) } } diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index c85b924c8b..b522e4004e 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -2137,23 +2137,13 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki p, ok := profileContents[profID] if !ok { // this should never happen - level.Info(logger).Log("warn", "missing profile contents", "profile_id", profID) - continue + return ctxerr.Wrap(ctx, err, "inserting commands for hosts") } - // TODO(roberto): I think this should live separately in the - // Windows equivalent of Apple's Commander struct, but I'd like - // to keep it simpler for now until we understand more. - command := &fleet.MDMWindowsCommand{ - CommandUUID: target.cmdUUID, - RawCommand: []byte(fmt.Sprintf(` - - %s - %s - - `, target.cmdUUID, p)), - // Atomic commands don't have a Target element. - TargetLocURI: "", + command, err := buildCommandFromProfileBytes(p, target.cmdUUID) + if err != nil { + level.Info(logger).Log("err", err, "profile_id", profID) + continue } if err := ds.MDMWindowsInsertCommandForHosts(ctx, target.hostUUIDs, command); err != nil { return ctxerr.Wrap(ctx, err, "inserting commands for hosts") @@ -2173,3 +2163,34 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger ki return nil } + +// TODO(roberto): I think this should live separately in the +// Windows equivalent of Apple's Commander struct, but I'd like +// to keep it simpler for now until we understand more. +func buildCommandFromProfileBytes(profileBytes []byte, commandUUID string) (*fleet.MDMWindowsCommand, error) { + rawCommand := []byte(fmt.Sprintf(`%s`, profileBytes)) + cmd := new(mdm_types.SyncMLCmd) + if err := xml.Unmarshal(rawCommand, cmd); err != nil { + return nil, fmt.Errorf("unmarshalling profile: %w", err) + } + // set the CmdID for the command + cmd.CmdID = commandUUID + // generate a CmdID for any nested + for i := range cmd.ReplaceCommands { + cmd.ReplaceCommands[i].CmdID = uuid.NewString() + } + + rawCommand, err := xml.Marshal(cmd) + if err != nil { + return nil, fmt.Errorf("marshalling command: %w", err) + } + + command := &fleet.MDMWindowsCommand{ + CommandUUID: commandUUID, + RawCommand: rawCommand, + // Atomic commands don't have a Target element. + TargetLocURI: "", + } + + return command, nil +} diff --git a/server/service/microsoft_mdm_test.go b/server/service/microsoft_mdm_test.go index 89c603136a..dc07011117 100644 --- a/server/service/microsoft_mdm_test.go +++ b/server/service/microsoft_mdm_test.go @@ -359,11 +359,46 @@ func checkWrappedSyncMLCmd(tag string, data string) error { return nil } +func TestBuildCommandFromProfileBytes(t *testing.T) { + cmd, err := buildCommandFromProfileBytes([]byte(""), "") + require.Nil(t, cmd) + require.ErrorContains(t, err, "unmarshalling profile") + + rawSyncML := syncMLForTest("foo/bar") + + // build and generate a command + cmd, err = buildCommandFromProfileBytes(rawSyncML, "uuid-1") + require.Nil(t, err) + require.Equal(t, "uuid-1", cmd.CommandUUID) + require.Empty(t, cmd.TargetLocURI) + syncOne := new(mdm_types.SyncMLCmd) + err = xml.Unmarshal(cmd.RawCommand, syncOne) + require.NoError(t, err) + require.Len(t, syncOne.ReplaceCommands, 1) + require.NotEmpty(t, syncOne.ReplaceCommands[0].CmdID) + + // build and generate a second command with the same syncml + cmd, err = buildCommandFromProfileBytes(rawSyncML, "uuid-2") + require.Nil(t, err) + require.Equal(t, "uuid-2", cmd.CommandUUID) + require.Empty(t, cmd.TargetLocURI) + syncTwo := new(mdm_types.SyncMLCmd) + err = xml.Unmarshal(cmd.RawCommand, syncTwo) + require.NoError(t, err) + require.Len(t, syncTwo.ReplaceCommands, 1) + require.NotEmpty(t, syncTwo.ReplaceCommands[0].CmdID) + + // uuids of replaces are different + require.NotEqual(t, syncOne.ReplaceCommands[0].CmdID, syncTwo.ReplaceCommands[0].CmdID) +} + func syncMLForTest(locURI string) []byte { return []byte(fmt.Sprintf(` - - %s - + + + %s + + `, locURI)) } diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 3895bfe753..18eb013ba4 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -636,15 +636,14 @@ func mdmConfigurationRequiredEndpoints() []struct { {"GET", "/api/latest/fleet/mdm/commands", false, false}, {"POST", "/api/fleet/orbit/disk_encryption_key", false, false}, {"GET", "/api/latest/fleet/mdm/disk_encryption/summary", false, true}, - // FIXME: commenting out these endpoints to get the release out - // {"GET", "/api/latest/fleet/mdm/profiles/1", false, false}, - // {"DELETE", "/api/latest/fleet/mdm/profiles/1", false, false}, + {"GET", "/api/latest/fleet/mdm/profiles/1", false, false}, + {"DELETE", "/api/latest/fleet/mdm/profiles/1", false, false}, // TODO: this endpoint accepts multipart/form data that gets // parsed before the MDM check, we need to refactor this // function to return more information to the caller, or find a // better way to test these endpoints. //{"POST", "/api/latest/fleet/mdm/profiles", false, false}, - // {"GET", "/api/latest/fleet/mdm/profiles", false, false}, + {"GET", "/api/latest/fleet/mdm/profiles", false, false}, } }