feature: target profiles by labels (#16202)

for #14715

---------

Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com>
This commit is contained in:
Roberto Dip 2024-01-26 11:00:58 -05:00 committed by GitHub
parent 901004e149
commit 7d00d5a41e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 5716 additions and 1185 deletions

View File

@ -0,0 +1 @@
- Updated UI with ability to target MDM profiles by label.

View File

@ -0,0 +1 @@
* Added the profile's `labels` object to the response payload of `GET /mdm/profiles` (list configuration profiles) and `GET /mdm/profiles/{profile_uuid}` (get a configuration profile).

View File

@ -0,0 +1 @@
* Added support for label-based MDM profiles reconciliation.

View File

@ -0,0 +1 @@
- Adds ability for labeled profiles to be fetched for verification.

View File

@ -261,7 +261,7 @@ spec:
GracePeriodDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1),
}, },
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileCfgPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileCfgPath}},
}, },
} }
@ -302,7 +302,7 @@ spec:
GracePeriodDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1),
}, },
MacOSSettings: fleet.MacOSSettings{ // macos settings not provided, so not cleared MacOSSettings: fleet.MacOSSettings{ // macos settings not provided, so not cleared
CustomSettings: []string{mobileCfgPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileCfgPath}},
}, },
} }
newAgentOpts = json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`) newAgentOpts = json.RawMessage(`{"config":{"views":{"foo":"qux"}}}`)
@ -375,7 +375,7 @@ spec:
GracePeriodDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true},
}, },
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{}, CustomSettings: []fleet.MDMProfileSpec{},
}, },
} }
@ -1094,7 +1094,7 @@ spec:
GracePeriodDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(0),
}, },
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
}, },
WindowsEnabledAndConfigured: true, WindowsEnabledAndConfigured: true,
}, currentAppConfig.MDM) }, currentAppConfig.MDM)
@ -1136,7 +1136,7 @@ spec:
GracePeriodDays: optjson.SetInt(0), GracePeriodDays: optjson.SetInt(0),
}, },
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
}, },
WindowsEnabledAndConfigured: true, WindowsEnabledAndConfigured: true,
}, currentAppConfig.MDM) }, currentAppConfig.MDM)
@ -1176,7 +1176,7 @@ spec:
assert.Equal(t, fleet.TeamMDM{ assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false, EnableDiskEncryption: false,
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
}, },
MacOSUpdates: fleet.MacOSUpdates{ MacOSUpdates: fleet.MacOSUpdates{
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
@ -1215,7 +1215,7 @@ spec:
assert.Equal(t, fleet.TeamMDM{ assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false, EnableDiskEncryption: false,
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
}, },
MacOSUpdates: fleet.MacOSUpdates{ MacOSUpdates: fleet.MacOSUpdates{
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),
@ -1251,7 +1251,7 @@ spec:
assert.Equal(t, fleet.TeamMDM{ assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false, EnableDiskEncryption: false,
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath}, CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
}, },
MacOSUpdates: fleet.MacOSUpdates{ MacOSUpdates: fleet.MacOSUpdates{
MinimumVersion: optjson.SetString("10.10.10"), MinimumVersion: optjson.SetString("10.10.10"),

View File

@ -80,9 +80,9 @@ func TestHostsTransferByLabel(t *testing.T) {
return &fleet.Team{ID: 99, Name: "team1"}, nil return &fleet.Team{ID: 99, Name: "team1"}, nil
} }
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) ([]uint, error) { ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
require.Equal(t, []string{"label1"}, labels) require.Equal(t, []string{"label1"}, labels)
return []uint{uint(11)}, nil return map[string]uint{"label1": uint(11)}, nil
} }
ds.ListHostsInLabelFunc = func(ctx context.Context, filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) ([]*fleet.Host, error) { ds.ListHostsInLabelFunc = func(ctx context.Context, filter fleet.TeamFilter, lid uint, opt fleet.HostListOptions) ([]*fleet.Host, error) {
@ -136,9 +136,9 @@ func TestHostsTransferByStatus(t *testing.T) {
return &fleet.Team{ID: 99, Name: "team1"}, nil return &fleet.Team{ID: 99, Name: "team1"}, nil
} }
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) ([]uint, error) { ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
require.Equal(t, []string{"label1"}, labels) require.Equal(t, []string{"label1"}, labels)
return []uint{uint(11)}, nil return map[string]uint{"label1": uint(11)}, nil
} }
ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {
@ -192,9 +192,9 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) {
return &fleet.Team{ID: 99, Name: "team1"}, nil return &fleet.Team{ID: 99, Name: "team1"}, nil
} }
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) ([]uint, error) { ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
require.Equal(t, []string{"label1"}, labels) require.Equal(t, []string{"label1"}, labels)
return []uint{uint(11)}, nil return map[string]uint{"label1": uint(11)}, nil
} }
ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) { ds.ListHostsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.HostListOptions) ([]*fleet.Host, error) {

View File

@ -51,7 +51,7 @@ func TestSavedLiveQuery(t *testing.T) {
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) { ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
return []uint{1234}, nil return []uint{1234}, nil
} }
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) ([]uint, error) { ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
return nil, nil return nil, nil
} }
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
@ -195,7 +195,7 @@ func TestAdHocLiveQuery(t *testing.T) {
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) { ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
return []uint{1234}, nil return []uint{1234}, nil
} }
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) ([]uint, error) { ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
return nil, nil return nil, nil
} }
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {

View File

@ -1023,7 +1023,7 @@ func (svc *Service) editTeamFromSpec(
if spec.MDM.WindowsSettings.CustomSettings.Set { if spec.MDM.WindowsSettings.CustomSettings.Set {
if !appCfg.MDM.WindowsEnabledAndConfigured && if !appCfg.MDM.WindowsEnabledAndConfigured &&
len(spec.MDM.WindowsSettings.CustomSettings.Value) > 0 && len(spec.MDM.WindowsSettings.CustomSettings.Value) > 0 &&
!server.SliceStringsMatch(team.Config.MDM.WindowsSettings.CustomSettings.Value, spec.MDM.WindowsSettings.CustomSettings.Value) { !fleet.MDMProfileSpecsMatch(team.Config.MDM.WindowsSettings.CustomSettings.Value, spec.MDM.WindowsSettings.CustomSettings.Value) {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_settings.custom_settings", return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_settings.custom_settings",
`Couldnt edit windows_settings.custom_settings. Windows MDM isnt turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)) `Couldnt edit windows_settings.custom_settings. Windows MDM isnt turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`))
} }
@ -1126,7 +1126,7 @@ func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.Team
customSettingsChanged := setFields["custom_settings"] && customSettingsChanged := setFields["custom_settings"] &&
len(applyUpon.CustomSettings) > 0 && len(applyUpon.CustomSettings) > 0 &&
!server.SliceStringsMatch(applyUpon.CustomSettings, oldCustomSettings) !fleet.MDMProfileSpecsMatch(applyUpon.CustomSettings, oldCustomSettings)
if customSettingsChanged || (setFields["enable_disk_encryption"] && *applyUpon.DeprecatedEnableDiskEncryption) { if customSettingsChanged || (setFields["enable_disk_encryption"] && *applyUpon.DeprecatedEnableDiskEncryption) {
field := "custom_settings" field := "custom_settings"

View File

@ -0,0 +1,22 @@
import React from "react";
const Upload = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="17"
height="17"
viewBox="0 0 17 17"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.5 11C7.5 11.5523 7.94772 12 8.5 12C9.05228 12 9.5 11.5523 9.5 11V3.63504L10.8598 4.76822C11.2841 5.12179 11.9147 5.06446 12.2682 4.64018C12.6218 4.21591 12.5645 3.58534 12.1402 3.23178L9.14018 0.731779C8.76934 0.42274 8.23066 0.42274 7.85982 0.731779L4.85982 3.23178C4.43554 3.58534 4.37821 4.21591 4.73178 4.64018C5.08534 5.06446 5.71591 5.12179 6.14018 4.76822L7.5 3.63504L7.5 11ZM2.5 9.5C2.5 8.94771 2.05228 8.5 1.5 8.5C0.947715 8.5 0.5 8.94771 0.5 9.5L0.5 15.5C0.5 16.0523 0.947715 16.5 1.5 16.5H15.5C16.0523 16.5 16.5 16.0523 16.5 15.5V9.5C16.5 8.94771 16.0523 8.5 15.5 8.5C14.9477 8.5 14.5 8.94771 14.5 9.5V14.5H2.5L2.5 9.5Z"
fill="#6A67FE"
/>
</svg>
);
};
export default Upload;

View File

@ -51,6 +51,7 @@ import Transfer from "./Transfer";
import TrashCan from "./TrashCan"; import TrashCan from "./TrashCan";
import Profile from "./Profile"; import Profile from "./Profile";
import Download from "./Download"; import Download from "./Download";
import Upload from "./Upload";
import Refresh from "./Refresh"; import Refresh from "./Refresh";
// a mapping of the usable names of icons to the icon source. // a mapping of the usable names of icons to the icon source.
@ -107,6 +108,7 @@ export const ICON_MAP = {
"premium-feature": PremiumFeature, "premium-feature": PremiumFeature,
profile: Profile, profile: Profile,
download: Download, download: Download,
upload: Upload,
refresh: Refresh, refresh: Refresh,
}; };

View File

@ -57,6 +57,11 @@ export interface IMdmSummaryResponse {
type ProfilePlatform = "darwin" | "windows"; type ProfilePlatform = "darwin" | "windows";
export interface IProfileLabel {
name: string;
broken: boolean;
}
export interface IMdmProfile { export interface IMdmProfile {
profile_uuid: string; profile_uuid: string;
team_id: number; team_id: number;
@ -66,6 +71,7 @@ export interface IMdmProfile {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
checksum: string | null; // null for windows profiles checksum: string | null; // null for windows profiles
labels?: IProfileLabel[];
} }
export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed"; export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed";

View File

@ -14,4 +14,8 @@
top: 0; top: 0;
} }
} }
.side-nav__card-container > .custom-settings {
max-width: none;
}
} }

View File

@ -1,11 +1,14 @@
import React, { useCallback, useContext, useRef, useState } from "react"; import React, { useCallback, useContext, useRef, useState } from "react";
import { InjectedRouter } from "react-router"; import { InjectedRouter } from "react-router";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { IMdmProfile } from "interfaces/mdm"; import { IMdmProfile } from "interfaces/mdm";
import mdmAPI, { IMdmProfilesResponse } from "services/entities/mdm"; import mdmAPI, { IMdmProfilesResponse } from "services/entities/mdm";
import { NotificationContext } from "context/notification";
import PATHS from "router/paths";
import CustomLink from "components/CustomLink"; import CustomLink from "components/CustomLink";
import SectionHeader from "components/SectionHeader"; import SectionHeader from "components/SectionHeader";
@ -16,10 +19,12 @@ import Pagination from "pages/ManageControlsPage/components/Pagination";
import UploadList from "../../../components/UploadList"; import UploadList from "../../../components/UploadList";
import AddProfileCard from "./components/ProfileUploader/components/AddProfileCard";
import AddProfileModal from "./components/ProfileUploader/components/AddProfileModal";
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal"; import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
import ProfileLabelsModal from "./components/ProfileLabelsModal/ProfileLabelsModal";
import ProfileListItem from "./components/ProfileListItem"; import ProfileListItem from "./components/ProfileListItem";
import ProfileListHeading from "./components/ProfileListHeading"; import ProfileListHeading from "./components/ProfileListHeading";
import ProfileUploader from "./components/ProfileUploader";
const PROFILES_PER_PAGE = 10; const PROFILES_PER_PAGE = 10;
@ -41,7 +46,13 @@ const CustomSettings = ({
onMutation, onMutation,
}: ICustomSettingsProps) => { }: ICustomSettingsProps) => {
const { renderFlash } = useContext(NotificationContext); const { renderFlash } = useContext(NotificationContext);
const { isPremiumTier } = useContext(AppContext);
const [showAddProfileModal, setShowAddProfileModal] = useState(false);
const [
profileLabelsModalData,
setProfileLabelsModalData,
] = useState<IMdmProfile | null>(null);
const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false); const [showDeleteProfileModal, setShowDeleteProfileModal] = useState(false);
const selectedProfile = useRef<IMdmProfile | null>(null); const selectedProfile = useRef<IMdmProfile | null>(null);
@ -70,6 +81,8 @@ const CustomSettings = ({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
} }
); );
const profiles = profilesData?.profiles;
const meta = profilesData?.meta;
const onUploadProfile = () => { const onUploadProfile = () => {
refetchProfiles(); refetchProfiles();
@ -122,29 +135,33 @@ const CustomSettings = ({
return <DataError />; return <DataError />;
} }
if ( if (!profiles?.length) {
!profilesData ||
!profilesData.profiles ||
profilesData.profiles.length === 0
) {
return null; return null;
} }
const { profiles, meta } = profilesData;
return ( return (
<> <>
<UploadList <UploadList
keyAttribute="profile_uuid" keyAttribute="profile_uuid"
listItems={profiles} listItems={profiles}
HeadingComponent={ProfileListHeading} HeadingComponent={() =>
ProfileListHeading({
onClickAddProfile: () => setShowAddProfileModal(true),
})
}
ListItemComponent={({ listItem }) => ( ListItemComponent={({ listItem }) => (
<ProfileListItem profile={listItem} onDelete={onClickDelete} /> <ProfileListItem
isPremium={!!isPremiumTier}
profile={listItem}
setProfileLabelsModalData={setProfileLabelsModalData}
onDelete={onClickDelete}
/>
)} )}
/> />
<Pagination <Pagination
className={`${baseClass}__pagination-controls`} className={`${baseClass}__pagination-controls`}
disableNext={!meta.has_next_results} disableNext={!meta?.has_next_results}
disablePrev={!meta.has_previous_results} disablePrev={!meta?.has_previous_results}
onNextPage={onNextPage} onNextPage={onNextPage}
onPrevPage={onPrevPage} onPrevPage={onPrevPage}
/> />
@ -164,10 +181,21 @@ const CustomSettings = ({
/> />
</p> </p>
{renderProfileList()} {renderProfileList()}
<ProfileUploader {!isLoadingProfiles && !isErrorProfiles && !profiles?.length && (
currentTeamId={currentTeamId} <AddProfileCard
onUpload={onUploadProfile} baseClass="add-profile"
/> setShowModal={setShowAddProfileModal}
/>
)}
{showAddProfileModal && (
<AddProfileModal
baseClass="add-profile"
currentTeamId={currentTeamId}
isPremiumTier={!!isPremiumTier}
onUpload={onUploadProfile}
setShowModal={setShowAddProfileModal}
/>
)}
{showDeleteProfileModal && selectedProfile.current && ( {showDeleteProfileModal && selectedProfile.current && (
<DeleteProfileModal <DeleteProfileModal
profileName={selectedProfile.current?.name} profileName={selectedProfile.current?.name}
@ -176,6 +204,13 @@ const CustomSettings = ({
onDelete={onDeleteProfile} onDelete={onDeleteProfile}
/> />
)} )}
{!!isPremiumTier && !!profileLabelsModalData?.labels?.length && (
<ProfileLabelsModal
baseClass={baseClass}
profile={profileLabelsModalData}
setModalData={setProfileLabelsModalData}
/>
)}
</div> </div>
); );
}; };

View File

@ -1,7 +1,18 @@
.custom-settings { .custom-settings {
.section-header {
margin: 0;
padding: 0 0 12px 0;
h2 {
padding-bottom: 0;
border-bottom: none;
margin: 0;
}
}
&__description { &__description {
font-size: $x-small; font-size: $x-small;
margin: $pad-xxlarge 0; margin: $pad-large 0;
} }
&__profiles-header { &__profiles-header {
@ -28,4 +39,183 @@
&__file-uploader { &__file-uploader {
margin-top: $pad-xxlarge; margin-top: $pad-xxlarge;
} }
&__labels-list {
border-radius: 6px;
border: 1px solid $ui-fleet-black-10;
&--label {
display: flex;
height: 41px;
padding: 0 $pad-large;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid $ui-fleet-black-10;
.warning {
display: flex;
padding: 0;
gap: $pad-small;
}
&:last-of-type {
border-bottom: none;
}
}
}
.upload-list {
&__list {
.list-item__label-count {
align-self: center;
}
.list-item__actions {
display: none;
}
:hover {
background-color: $ui-fleet-blue-10;
.list-item__labels {
display: none;
}
.list-item__actions {
display: flex;
gap: $pad-xxlarge;
}
}
}
}
.add-profile {
&__card--content-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-medium;
padding: 28.5px 0;
}
&__modal-content-wrap {
margin-top: $pad-large;
.add-profile__file {
padding: $pad-medium $pad-large;
}
}
&__file-chooser {
display: flex;
flex-direction: column;
align-items: center;
padding-top: $pad-medium;
input {
display: none;
}
&--button-wrap {
display: flex;
justify-content: center;
gap: $pad-small;
}
}
&__selected-file {
display: flex;
gap: 16px;
&--details {
display: flex;
flex-direction: column;
&--name {
font-size: $x-small;
font-weight: $bold;
}
&--platform {
font-size: $xx-small;
color: $ui-fleet-black-75;
}
}
}
&__profile-graphic {
display: flex;
flex-direction: column;
align-items: center;
gap: $pad-small;
}
&__button-wrap {
display: flex;
justify-content: flex-end;
padding-top: $pad-medium;
}
&__target {
margin: $pad-large 0 $pad-small 0;
}
&__description {
margin: $pad-medium 0;
}
&__no-labels {
display: flex;
height: 187px;
flex-direction: column;
align-items: center;
gap: $pad-small;
justify-content: center;
span {
color: $ui-fleet-black-75;
}
}
&__checkboxes {
display: flex;
max-height: 187px;
flex-direction: column;
border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10;
overflow-y: scroll;
.loading-spinner {
margin: 69.5px auto;
}
}
&__label {
width: 100%;
padding: $pad-small $pad-medium;
box-sizing: border-box;
display: flex;
align-items: center;
&:not(:last-child) {
border-bottom: 1px solid $ui-fleet-black-10;
}
.form-field--checkbox {
width: auto;
}
}
&__label-name {
padding-left: $pad-large;
}
.fleet-checkbox {
height: 20px;
display: flex;
align-items: center;
&__label {
width: 490px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
} }

View File

@ -0,0 +1,91 @@
import React from "react";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import { IMdmProfile, IProfileLabel } from "interfaces/mdm";
import InfoBanner from "components/InfoBanner";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
const ModalDescription = ({
baseClass,
profileName,
}: {
baseClass: string;
profileName: string;
}) => (
<div className={`${baseClass}__description`}>
<b>{profileName}</b> will only be applied to hosts that have all these
labels:
</div>
);
const BrokenLabelWarning = () => (
<InfoBanner color="yellow">
<span>
The configuration profile is{" "}
<TooltipWrapper
tipContent={`It wont be applied to new hosts because one or more labels are deleted. To apply the profile to new hosts, please delete it and upload a new profile.`}
underline
>
broken
</TooltipWrapper>
.
</span>
</InfoBanner>
);
const LabelsList = ({
baseClass,
labels,
}: {
baseClass: string;
labels: IProfileLabel[];
}) => (
<div className={`${baseClass}__labels-list`}>
{labels.map((label) => (
<div key={label.name} className={`${baseClass}__labels-list--label`}>
{label.name}
{label.broken && (
<span className={`${baseClass}__labels-list--label warning`}>
<Icon name="warning" />
Label deleted
</span>
)}
</div>
))}
</div>
);
interface IProfileLabelsModalProps {
baseClass: string;
profile: IMdmProfile | null;
setModalData: React.Dispatch<React.SetStateAction<IMdmProfile | null>>;
}
const ProfileLabelsModal = ({
baseClass,
profile,
setModalData,
}: IProfileLabelsModalProps) => {
if (!profile?.labels?.length) {
// caller ensures this never happens
return null;
}
return (
<Modal title="Custom target" onExit={() => setModalData(null)}>
<div className={`${baseClass}__modal-content-wrap`}>
{profile.labels.some((label) => label.broken) && <BrokenLabelWarning />}
<ModalDescription baseClass={baseClass} profileName={profile.name} />
<LabelsList baseClass={baseClass} labels={profile.labels} />
<div className="modal-cta-wrap">
<Button variant="brand" onClick={() => setModalData(null)}>
Done
</Button>
</div>
</div>
</Modal>
);
};
export default ProfileLabelsModal;

View File

@ -1,12 +1,33 @@
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import React from "react"; import React from "react";
const baseClass = "profile-list-heading"; const baseClass = "profile-list-heading";
const ProfileListHeading = () => { interface IProfileListHeadingProps {
onClickAddProfile?: () => void;
}
const ProfileListHeading = ({
onClickAddProfile,
}: IProfileListHeadingProps) => {
return ( return (
<div className={baseClass}> <div className={baseClass}>
<span>Configuration profile</span> <span className={`${baseClass}__profile-name-heading`}>
<span className={`${baseClass}__actions-heading`}>Actions</span> Configuration profile
</span>
<span className={`${baseClass}__actions-heading`}>
<Button
variant="text-icon"
className={`${baseClass}__add-button`}
onClick={onClickAddProfile}
>
<span className={`${baseClass}__icon-wrap`}>
<Icon name="plus" />
Add profile
</span>
</Button>
</span>
</div> </div>
); );
}; };

View File

@ -1,11 +1,17 @@
.profile-list-heading { .profile-list-heading {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: $x-small; font-size: $x-small;
font-weight: $bold; font-weight: $bold;
&__actions-heading { &__profile-name-heading {
text-align: right; align-content: center;
margin-right: 40px; // align with left side of buttons below it }
&__icon-wrap {
display: flex;
align-items: center;
gap: $pad-small;
} }
} }

View File

@ -1,16 +1,31 @@
import React from "react"; import React from "react";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import FileSaver from "file-saver"; import FileSaver from "file-saver";
import classnames from "classnames";
import { IMdmProfile } from "interfaces/mdm"; import { IMdmProfile } from "interfaces/mdm";
import mdmAPI from "services/entities/mdm"; import mdmAPI from "services/entities/mdm";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
import Graphic from "components/Graphic";
import Icon from "components/Icon"; import Icon from "components/Icon";
import ListItem from "components/ListItem";
import { pluralize } from "utilities/helpers";
const baseClass = "profile-list-item"; const baseClass = "profile-list-item";
const LabelCount = ({
className,
count,
}: {
className: string;
count: number;
}) => (
<div className={`${className}__labels--count`}>
{`${count} ${pluralize(count, "label", "s", "")}`}
</div>
);
interface IProfileDetailsProps { interface IProfileDetailsProps {
platform: string; platform: string;
createdAt: string; createdAt: string;
@ -33,50 +48,77 @@ const ProfileDetails = ({ platform, createdAt }: IProfileDetailsProps) => {
}; };
interface IProfileListItemProps { interface IProfileListItemProps {
isPremium: boolean;
profile: IMdmProfile; profile: IMdmProfile;
onDelete: (profile: IMdmProfile) => void; onDelete: (profile: IMdmProfile) => void;
setProfileLabelsModalData: React.Dispatch<
React.SetStateAction<IMdmProfile | null>
>;
} }
const ProfileListItem = ({ profile, onDelete }: IProfileListItemProps) => { const ProfileListItem = ({
isPremium,
profile,
onDelete,
setProfileLabelsModalData,
}: IProfileListItemProps) => {
const { created_at, labels, name, platform, profile_uuid } = profile;
const subClass = "list-item";
const onClickDownload = async () => { const onClickDownload = async () => {
const fileContent = await mdmAPI.downloadProfile(profile.profile_uuid); const fileContent = await mdmAPI.downloadProfile(profile_uuid);
const formatDate = format(new Date(), "yyyy-MM-dd"); const formatDate = format(new Date(), "yyyy-MM-dd");
const extension = profile.platform === "darwin" ? "mobileconfig" : "xml"; const extension = platform === "darwin" ? "mobileconfig" : "xml";
const filename = `${formatDate}_${profile.name}.${extension}`; const filename = `${formatDate}_${name}.${extension}`;
const file = new File([fileContent], filename); const file = new File([fileContent], filename);
FileSaver.saveAs(file); FileSaver.saveAs(file);
}; };
return ( return (
<ListItem <div className={classnames(subClass, baseClass)}>
className={baseClass} <div className={`${subClass}__main-content`}>
graphic="file-configuration-profile" <Graphic name="file-configuration-profile" />
title={profile.name} <div className={`${subClass}__info`}>
details={ <span className={`${subClass}__title`}>{name}</span>
<ProfileDetails <div className={`${subClass}__details`}>
platform={profile.platform} <ProfileDetails platform={platform} createdAt={created_at} />
createdAt={profile.created_at} </div>
/> </div>
} </div>
actions={ <div className={`${subClass}__actions-wrap`}>
<> {isPremium && !!labels?.length && (
<div className={`${subClass}__labels`}>
{labels?.some((l) => l.broken) && <Icon name="warning" />}
<LabelCount className={subClass} count={labels.length} />
</div>
)}
<div className={`${subClass}__actions`}>
{isPremium && !!labels?.length && (
<Button
className={`${subClass}__action-button`}
variant="text-icon"
onClick={() => setProfileLabelsModalData({ ...profile })}
>
<Icon name="filter" />
</Button>
)}
<Button <Button
className={`${baseClass}__action-button`} className={`${subClass}__action-button`}
variant="text-icon" variant="text-icon"
onClick={onClickDownload} onClick={onClickDownload}
> >
<Icon name="download" /> <Icon name="download" />
</Button> </Button>
<Button <Button
className={`${baseClass}__action-button`} className={`${subClass}__action-button`}
variant="text-icon" variant="text-icon"
onClick={() => onDelete(profile)} onClick={() => onDelete(profile)}
> >
<Icon name="trash" color="ui-fleet-black-75" /> <Icon name="trash" color="ui-fleet-black-75" />
</Button> </Button>
</> </div>
} </div>
/> </div>
); );
}; };

View File

@ -16,4 +16,24 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.list-item__actions-wrap {
display: flex;
}
.list-item__labels {
display: flex;
gap: $pad-small;
&--count {
align-self: center;
color: $ui-fleet-black-75;
}
}
&:hover {
.list-item__labels {
display: none;
}
}
} }

View File

@ -1,62 +0,0 @@
import React, { useContext, useState } from "react";
import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors";
import { NotificationContext } from "context/notification";
import mdmAPI from "services/entities/mdm";
import FileUploader from "components/FileUploader";
import { getErrorMessage } from "./helpers";
const baseClass = "profile-uploader";
interface IProfileUploaderProps {
currentTeamId: number;
onUpload: () => void;
}
const ProfileUploader = ({
currentTeamId,
onUpload,
}: IProfileUploaderProps) => {
const [showLoading, setShowLoading] = useState(false);
const { renderFlash } = useContext(NotificationContext);
const onFileUpload = async (files: FileList | null) => {
setShowLoading(true);
if (!files || files.length === 0) {
setShowLoading(false);
return;
}
const file = files[0];
try {
await mdmAPI.uploadProfile(file, currentTeamId);
renderFlash("success", "Successfully uploaded!");
onUpload();
} catch (e) {
const error = e as AxiosResponse<IApiError>;
const errMessage = getErrorMessage(error);
renderFlash("error", errMessage);
} finally {
setShowLoading(false);
}
};
return (
<FileUploader
graphicName="file-configuration-profile"
message="Configuration profile (.mobileconfig for macOS or .xml for Windows)"
accept=".mobileconfig,application/x-apple-aspen-config,.xml"
isLoading={showLoading}
onFileUpload={onFileUpload}
className={`${baseClass}__file-uploader`}
/>
);
};
export default ProfileUploader;

View File

@ -0,0 +1,29 @@
import React from "react";
import Card from "components/Card";
import Button from "components/buttons/Button";
import ProfileGraphic from "./AddProfileGraphic";
const AddProfileCard = ({
baseClass,
setShowModal,
}: {
baseClass: string;
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
}) => (
<Card color="gray" className={`${baseClass}__card`}>
<div className={`${baseClass}__card--content-wrap`}>
<ProfileGraphic baseClass={baseClass} showMessage />
<Button
className={`${baseClass}__card--add-button`}
variant="brand"
type="button"
onClick={() => setShowModal(true)}
>
Add profile
</Button>
</div>
</Card>
);
export default AddProfileCard;

View File

@ -0,0 +1,29 @@
import React from "react";
import Graphic from "components/Graphic";
const ALLOWED_FILE_TYPES_MESSAGE =
"Configuration profile (.mobileconfig for macOS or .xml for Windows)";
const ProfileGraphic = ({
baseClass,
showMessage,
}: {
baseClass: string;
showMessage?: boolean;
}) => (
<div className={`${baseClass}__profile-graphic`}>
<Graphic
key={`file-configuration-profile-graphic`}
className={`${baseClass}__graphic`}
name={"file-configuration-profile"}
/>
{showMessage && (
<span className={`${baseClass}__profile-graphic--message`}>
{ALLOWED_FILE_TYPES_MESSAGE}
</span>
)}
</div>
);
export default ProfileGraphic;

View File

@ -0,0 +1,342 @@
import React, { useCallback, useContext, useRef, useState } from "react";
import { useQuery } from "react-query";
import { AxiosResponse } from "axios";
import { NotificationContext } from "context/notification";
import { IApiError } from "interfaces/errors";
import { ILabelSummary } from "interfaces/label";
import labelsAPI from "services/entities/labels";
import mdmAPI from "services/entities/mdm";
import Button from "components/buttons/Button";
import Card from "components/Card";
import Checkbox from "components/forms/fields/Checkbox";
import DataError from "components/DataError";
import Icon from "components/Icon";
import Modal from "components/Modal";
import Radio from "components/forms/fields/Radio";
import Spinner from "components/Spinner";
import ProfileGraphic from "./AddProfileGraphic";
import {
DEFAULT_ERROR_MESSAGE,
getErrorMessage,
parseFile,
listNamesFromSelectedLabels,
} from "../helpers";
const FileChooser = ({
baseClass,
isLoading,
onFileOpen,
}: {
baseClass: string;
isLoading: boolean;
onFileOpen: (files: FileList | null) => void;
}) => (
<div className={`${baseClass}__file-chooser`}>
<ProfileGraphic baseClass={baseClass} showMessage />
<Button
className={`${baseClass}__upload-button`}
variant="text-icon"
isLoading={isLoading}
>
<label htmlFor="upload-profile">
<span className={`${baseClass}__file-chooser--button-wrap`}>
<Icon name="upload" />
Choose file
</span>
</label>
</Button>
<input
accept={".mobileconfig,application/x-apple-aspen-config,.xml"}
id="upload-profile"
type="file"
onChange={(e) => {
onFileOpen(e.target.files);
}}
/>
</div>
);
const FileDetails = ({
baseClass,
details: { name, platform },
}: {
baseClass: string;
details: {
name: string;
platform: string;
};
}) => (
<div className={`${baseClass}__selected-file`}>
<ProfileGraphic baseClass={baseClass} />
<div className={`${baseClass}__selected-file--details`}>
<div className={`${baseClass}__selected-file--details--name`}>{name}</div>
<div className={`${baseClass}__selected-file--details--platform`}>
{platform}
</div>
</div>
</div>
);
const TargetChooser = ({
baseClass,
selectedTarget,
setSelectedTarget,
}: {
baseClass: string;
selectedTarget: string;
setSelectedTarget: React.Dispatch<React.SetStateAction<string>>;
}) => {
return (
<div className={`form-field`}>
<div className="form-field__label">Target</div>
<Radio
className={`${baseClass}__radio-input`}
label={"All hosts"}
id={"all-hosts-target-radio-btn"}
checked={selectedTarget === "All hosts"}
value={"All hosts"}
name={"all-hosts-target"}
onChange={setSelectedTarget}
/>
<Radio
className={`${baseClass}__radio-input`}
label={"Custom"}
id={"custom-target-radio-btn"}
checked={selectedTarget === "Custom"}
value={"Custom"}
name={"custom-target"}
onChange={setSelectedTarget}
/>
</div>
);
};
const LabelChooser = ({
baseClass,
isError,
isLoading,
labels,
selectedLabels,
setSelectedLabels,
}: {
baseClass: string;
isError: boolean;
isLoading: boolean;
labels: ILabelSummary[];
selectedLabels: Record<string, boolean>;
setSelectedLabels: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
}) => {
const updateSelectedLabels = useCallback(
({ name, value }: { name: string; value: boolean }) => {
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
},
[setSelectedLabels]
);
return (
<>
<div className={`${baseClass}__description`}>
Profile will only be applied to hosts that have all these labels:
</div>
<div className={`${baseClass}__checkboxes`}>
{isLoading && <Spinner centered={false} />}
{!isLoading && isError && <DataError />}
{!isLoading && !isError && !labels.length && (
<div className={`${baseClass}__no-labels`}>
<b>No labels exist in Fleet</b>
<span>Add labels to target specific hosts.</span>
</div>
)}
{!isLoading &&
!isError &&
!!labels.length &&
labels.map((label) => {
return (
<div className={`${baseClass}__label`} key={label.name}>
<Checkbox
className={`${baseClass}__checkbox`}
name={label.name}
value={!!selectedLabels[label.name]}
onChange={updateSelectedLabels}
parseTarget
/>
<div className={`${baseClass}__label-name`}>{label.name}</div>
</div>
);
})}
</div>
</>
);
};
interface IAddProfileModalProps {
baseClass: string;
currentTeamId: number;
isPremiumTier: boolean;
onUpload: () => void;
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
}
const AddProfileModal = ({
baseClass,
currentTeamId,
isPremiumTier,
onUpload,
setShowModal,
}: IAddProfileModalProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isLoading, setIsLoading] = useState(false);
const [fileDetails, setFileDetails] = useState<{
name: string;
platform: string;
} | null>(null);
const [selectedTarget, setSelectedTarget] = useState("All hosts"); // "All hosts" | "Custom"
const [selectedLabels, setSelectedLabels] = useState<Record<string, boolean>>(
{}
);
const fileRef = useRef<File | null>(null);
// NOTE: labels are not automatically refetched in the current implementation
const {
data: labels,
isLoading: isLoadingLabels,
isFetching: isFetchingLabels,
isError: isErrorLabels,
// refetch: refetchLabels,
} = useQuery<ILabelSummary[], Error>(
["custom_labels"], // NOTE: consider adding selectedTarget to the queryKey to refetch labels when target changes
() =>
labelsAPI
.summary()
.then((res) => res.labels.filter((l) => l.label_type !== "builtin")),
{
enabled: isPremiumTier,
refetchOnWindowFocus: false,
retry: false,
staleTime: 10000,
}
);
const onDone = useCallback(() => {
fileRef.current = null;
setFileDetails(null);
setSelectedLabels({});
setShowModal(false);
}, [fileRef, setShowModal]);
const onFileUpload = async () => {
if (!fileRef.current) {
renderFlash("error", DEFAULT_ERROR_MESSAGE);
return;
}
const file = fileRef.current;
setIsLoading(true);
try {
await mdmAPI.uploadProfile({
file,
teamId: currentTeamId,
labels: listNamesFromSelectedLabels(selectedLabels),
});
renderFlash("success", "Successfully uploaded!");
onUpload();
} catch (e) {
// TODO: cleanup this error handling
renderFlash("error", getErrorMessage(e as AxiosResponse<IApiError>));
} finally {
setIsLoading(false);
onDone();
}
};
const onFileOpen = async (files: FileList | null) => {
if (!files || files.length === 0) {
setIsLoading(false);
return;
}
setIsLoading(true);
const file = files[0];
fileRef.current = file;
try {
const [name, platform] = await parseFile(file);
setFileDetails({ name, platform });
} catch (e) {
renderFlash("error", "Invalid file type");
} finally {
setIsLoading(false);
}
};
return (
<Modal title="Add profile" onExit={onDone}>
<>
{isPremiumTier && isLoadingLabels && <Spinner />}
{isPremiumTier && !isLoadingLabels && isErrorLabels && <DataError />}
{(!isPremiumTier || (!isLoadingLabels && !isErrorLabels)) && (
<div className={`${baseClass}__modal-content-wrap`}>
<Card color="gray" className={`${baseClass}__file`}>
{!fileDetails ? (
<FileChooser
baseClass={baseClass}
isLoading={isLoading}
onFileOpen={onFileOpen}
/>
) : (
<FileDetails baseClass={baseClass} details={fileDetails} />
)}
</Card>
{isPremiumTier && (
<div className={`${baseClass}__target`}>
<TargetChooser
baseClass={baseClass}
selectedTarget={selectedTarget}
setSelectedTarget={setSelectedTarget}
/>
{selectedTarget === "Custom" && (
<LabelChooser
baseClass={baseClass}
isError={isErrorLabels}
isLoading={isFetchingLabels}
labels={labels || []}
selectedLabels={selectedLabels}
setSelectedLabels={setSelectedLabels}
/>
)}
</div>
)}
<div className={`${baseClass}__button-wrap`}>
<Button
className={`${baseClass}__add-profile-button`}
variant="brand"
onClick={onFileUpload}
isLoading={isLoading}
disabled={
// TODO: consider adding tooltip to explain why button is disabled
(selectedTarget === "Custom" &&
!listNamesFromSelectedLabels(selectedLabels).length) ||
!fileDetails
}
>
Add profile
</Button>
</div>
</div>
)}
</>
</Modal>
);
};
export default AddProfileModal;

View File

@ -2,13 +2,107 @@ import React from "react";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { IApiError } from "interfaces/errors"; import { IApiError } from "interfaces/errors";
// TODO: mobileconfig parser is a work in progress and not yet used in production
// https://developer.apple.com/documentation/devicemanagement/configuring_multiple_devices_using_profiles#3234127
const parseMobileconfig = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsText(file);
reader.onerror = (error) => {
reject(error);
};
reader.onabort = (error) => {
reject(error);
};
reader.onload = () => {
try {
// parse mobile as xml
const xmlDoc = new DOMParser().parseFromString(
reader.result as string,
"text/xml"
);
// check for any parser errors
const parserErrors = xmlDoc.getElementsByTagName("parsererror");
if (parserErrors.length > 0) {
console.warn("parserErrors", parserErrors);
throw new Error("Invalid file: parser error");
}
// get the top-level object, we assume it is the first `<dict>` element in the `<plist>`
// https://developer.apple.com/documentation/devicemanagement/toplevel
const tlo = xmlDoc.getElementsByTagName("dict")?.[0];
if (tlo?.parentElement?.tagName !== "plist") {
throw new Error("Invalid file: missing plist");
}
// get the payload display name from the top-level object, note that there may be other
// `<dict>` elements in the `<plist>`, some of which contain `<key>PayloadDisplayName</key>`
// elements, but we ignore those for now
const pdnKey = Array.from(tlo.children).find(
(child) =>
child.tagName === "key" &&
child.textContent === "PayloadDisplayName"
);
const pdnVal =
(pdnKey?.nextElementSibling?.tagName === "string" &&
pdnKey?.nextElementSibling?.textContent) ||
"";
// if the payload display name is empty, use the file name
const result = pdnVal || file.name;
console.log("parseMobileconfig result: ", result);
resolve(result);
} catch (error) {
console.error("error", error);
reject(error);
}
};
});
};
export const parseFile = async (file: File): Promise<[string, string]> => {
// get the file name and extension
const nameParts = file.name.split(".");
const name = nameParts.slice(0, -1).join(".");
const ext = nameParts.slice(-1)[0];
switch (ext) {
case "xml": {
return [name, "Windows"];
}
case "mobileconfig": {
// // TODO: enable this once mobileconfig parser is vetted
// try {
// const parsedName = await parseMobileConfig(file);
// return [parsedName, "macOS"];
// } catch (e) {
// console.log("error", e);
// return [name, "macOS"];
// }
return [name, "macOS"];
}
default: {
throw new Error(`Invalid file type: ${ext}`);
}
}
};
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
return Object.entries(dict).reduce((acc, [labelName, isSelected]) => {
if (isSelected) {
acc.push(labelName);
}
return acc;
}, [] as string[]);
};
export const DEFAULT_ERROR_MESSAGE =
"Couldnt add configuration profile. Please try again.";
/** We want to add some additional messageing to some of the error messages so /** We want to add some additional messageing to some of the error messages so
* we add them in this function. Otherwise, we'll just return the error message from the * we add them in this function. Otherwise, we'll just return the error message from the
* API. * API.
*/ */
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export const getErrorMessage = (err: AxiosResponse<IApiError>) => { export const getErrorMessage = (err: AxiosResponse<IApiError>) => {
const apiReason = err.data.errors[0].reason; const apiReason = err?.data?.errors?.[0]?.reason;
if ( if (
apiReason.includes( apiReason.includes(
@ -33,5 +127,5 @@ export const getErrorMessage = (err: AxiosResponse<IApiError>) => {
</span> </span>
); );
} }
return apiReason; return apiReason || DEFAULT_ERROR_MESSAGE;
}; };

View File

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

View File

@ -40,6 +40,12 @@ export interface IMdmProfilesResponse {
}; };
} }
export interface IUploadProfileApiParams {
file: File;
teamId?: number;
labels?: string[];
}
const mdmService = { const mdmService = {
downloadDeviceUserEnrollmentProfile: (token: string) => { downloadDeviceUserEnrollmentProfile: (token: string) => {
const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints; const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints;
@ -79,7 +85,7 @@ const mdmService = {
return sendRequest("GET", path); return sendRequest("GET", path);
}, },
uploadProfile: (file: File, teamId?: number) => { uploadProfile: ({ file, teamId, labels }: IUploadProfileApiParams) => {
const { MDM_PROFILES } = endpoints; const { MDM_PROFILES } = endpoints;
const formData = new FormData(); const formData = new FormData();
@ -89,6 +95,10 @@ const mdmService = {
formData.append("team_id", teamId.toString()); formData.append("team_id", teamId.toString());
} }
labels?.forEach((label) => {
formData.append("labels", label);
});
return sendRequest("POST", MDM_PROFILES, formData); return sendRequest("POST", MDM_PROFILES, formData);
}, },

View File

@ -53,6 +53,31 @@ import { IScheduledQueryStats } from "interfaces/scheduled_query_stats";
const ORG_INFO_ATTRS = ["org_name", "org_logo_url"]; const ORG_INFO_ATTRS = ["org_name", "org_logo_url"];
const ADMIN_ATTRS = ["email", "name", "password", "password_confirmation"]; const ADMIN_ATTRS = ["email", "name", "password", "password_confirmation"];
/**
*
* @param count The number of items.
* @param root The root of the word, omitting any suffixs.
* @param pluralSuffix The suffix to add to the root if the count is not 1.
* @param singularSuffix The suffix to add to the root if the count is 1.
* @returns A string with the root and the appropriate suffix.
*
* @example
* pluralize(1, "hero", "es", "") // "hero"
* pluralize(0, "hero", "es", "") // "heroes"
* pluralize(1, "fair", "ies", "y") // "fairy"
* pluralize(2, "fair", "ies", "y") // "fairies"
* pluralize(1, "dragon") // "dragon"
* pluralize(2, "dragon") // "dragons"
*/
export const pluralize = (
count: number,
root: string,
pluralSuffix: string,
singularSuffix: string
) => {
return `${root}${count !== 1 ? pluralSuffix : singularSuffix}`;
};
export const addGravatarUrlToResource = (resource: any): any => { export const addGravatarUrlToResource = (resource: any): any => {
const { email } = resource; const { email } = resource;
const gravatarAvailable = const gravatarAvailable =
@ -844,6 +869,7 @@ export const getUniqueColumnNamesFromRows = (rows: any[]) =>
); );
export default { export default {
pluralize,
addGravatarUrlToResource, addGravatarUrlToResource,
formatConfigDataForServer, formatConfigDataForServer,
formatLabelResponse, formatLabelResponse,

View File

@ -565,7 +565,7 @@ func TestCachedTeamMDMConfig(t *testing.T) {
Deadline: optjson.SetString("1992-03-01"), Deadline: optjson.SetString("1992-03-01"),
}, },
MacOSSettings: fleet.MacOSSettings{ MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{"a", "b"}, CustomSettings: []fleet.MDMProfileSpec{{Path: "a"}, {Path: "b"}},
DeprecatedEnableDiskEncryption: ptr.Bool(false), DeprecatedEnableDiskEncryption: ptr.Bool(false),
}, },
MacOSSetup: fleet.MacOSSetup{ MacOSSetup: fleet.MacOSSetup{
@ -614,7 +614,7 @@ func TestCachedTeamMDMConfig(t *testing.T) {
require.False(t, mockedDS.TeamMDMConfigFuncInvoked) require.False(t, mockedDS.TeamMDMConfigFuncInvoked)
// changing some deep value doesn't affect the stored value // changing some deep value doesn't affect the stored value
mdmConfig.MacOSSettings.CustomSettings[0] = "c" mdmConfig.MacOSSettings.CustomSettings[0] = fleet.MDMProfileSpec{Path: "c"}
require.NotEqual(t, testMDMConfig, *mdmConfig) require.NotEqual(t, testMDMConfig, *mdmConfig)
// saving a team updates config in cache // saving a team updates config in cache

View File

@ -38,31 +38,48 @@ INSERT INTO
teamID = *cp.TeamID teamID = *cp.TeamID
} }
res, err := ds.writer(ctx).ExecContext(ctx, stmt, var profileID int64
profUUID, teamID, cp.Identifier, cp.Name, cp.Mobileconfig, cp.Mobileconfig, cp.Name, teamID) err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, stmt,
profUUID, teamID, cp.Identifier, cp.Name, cp.Mobileconfig, cp.Mobileconfig, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):
return ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
default:
return ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
}
}
aff, _ := res.RowsAffected()
if aff == 0 {
return &existsError{
ResourceType: "MDMAppleConfigProfile.PayloadDisplayName",
Identifier: cp.Name,
TeamID: cp.TeamID,
}
}
// record the ID as we want to return a fleet.Profile instance with it
// filled in.
profileID, _ = res.LastInsertId()
for i := range cp.Labels {
cp.Labels[i].ProfileUUID = profUUID
}
if err := batchSetProfileLabelAssociationsDB(ctx, tx, cp.Labels, "darwin"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations")
}
return nil
})
if err != nil { if err != nil {
switch { return nil, ctxerr.Wrap(ctx, err, "inserting profile and label associations")
case isDuplicate(err):
return nil, ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
default:
return nil, ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
}
} }
aff, _ := res.RowsAffected()
if aff == 0 {
return nil, &existsError{
ResourceType: "MDMAppleConfigProfile.PayloadDisplayName",
Identifier: cp.Name,
TeamID: cp.TeamID,
}
}
id, _ := res.LastInsertId()
return &fleet.MDMAppleConfigProfile{ return &fleet.MDMAppleConfigProfile{
ProfileUUID: profUUID, ProfileUUID: profUUID,
ProfileID: uint(id), ProfileID: uint(profileID),
Identifier: cp.Identifier, Identifier: cp.Identifier,
Name: cp.Name, Name: cp.Name,
Mobileconfig: cp.Mobileconfig, Mobileconfig: cp.Mobileconfig,
@ -172,6 +189,19 @@ WHERE
return nil, ctxerr.Wrap(ctx, err, "get mdm apple config profile") return nil, ctxerr.Wrap(ctx, err, "get mdm apple config profile")
} }
// get the labels for that profile, except if the profile was loaded by the
// old (deprecated) endpoint.
if uuid != "" {
labels, err := ds.listProfileLabelsForProfiles(ctx, nil, []string{res.ProfileUUID})
if err != nil {
return nil, err
}
if len(labels) > 0 {
// ensure we leave Labels nil if there are none
res.Labels = labels
}
}
return &res, nil return &res, nil
} }
@ -1150,6 +1180,7 @@ func (ds *Datastore) batchSetMDMAppleProfilesDB(
const loadExistingProfiles = ` const loadExistingProfiles = `
SELECT SELECT
identifier, identifier,
profile_uuid,
mobileconfig mobileconfig
FROM FROM
mdm_apple_configuration_profiles mdm_apple_configuration_profiles
@ -1245,6 +1276,40 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier) return ctxerr.Wrapf(ctx, err, "insert new/edited profile with identifier %q", p.Identifier)
} }
} }
// build a list of labels so the associations can be batch-set all at once
// TODO: with minor changes this chunk of code could be shared
// between macOS and Windows, but at the time of this
// implementation we're under tight time constraints.
incomingLabels := []fleet.ConfigurationProfileLabel{}
if len(incomingIdents) > 0 {
var newlyInsertedProfs []*fleet.MDMAppleConfigProfile
// load current profiles (again) that match the incoming profiles by name to grab their uuids
stmt, args, err := sqlx.In(loadExistingProfiles, profTeamID, incomingIdents)
if err != nil {
return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles")
}
if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "load newly inserted profiles")
}
for _, newlyInsertedProf := range newlyInsertedProfs {
incomingProf, ok := incomingProfs[newlyInsertedProf.Identifier]
if !ok {
return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier)
}
for _, label := range incomingProf.Labels {
label.ProfileUUID = newlyInsertedProf.ProfileUUID
incomingLabels = append(incomingLabels, label)
}
}
}
// insert label associations
if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "darwin"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting apple profile label associations")
}
return nil return nil
}) })
} }
@ -1316,39 +1381,50 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
return nil return nil
} }
const desiredStateStmt = ` // TODO(mna): the conditions here (and in toRemoveStmt) are subtly different
SELECT // than the ones in ListMDMAppleProfilesToInstall/Remove, so I'm keeping
ds.profile_uuid as profile_uuid, // those statements distinct to avoid introducing a subtle bug, but we should
ds.host_uuid as host_uuid, // take the time to properly analyze this and try to reuse
ds.profile_identifier as profile_identifier, // ListMDMAppleProfilesToInstall/Remove as we do in the Windows equivalent
ds.profile_name as profile_name, // method.
ds.checksum as checksum //
FROM ( // I.e. for toInstallStmt, this is missing:
SELECT // -- profiles in A and B with operation type "install" and NULL status
macp.profile_uuid, // but I believe it would be a no-op and no harm in adding (status is
h.uuid as host_uuid, // already NULL).
macp.identifier as profile_identifier, //
macp.name as profile_name, // And for toRemoveStmt, this is different:
macp.checksum as checksum // -- except "remove" operations in any state
FROM mdm_apple_configuration_profiles macp // vs
JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) // -- except "remove" operations in a terminal state or already pending
JOIN nano_enrollments ne ON ne.device_id = h.uuid // but again I believe it would be a no-op and no harm in making them the
WHERE h.platform = 'darwin' AND ne.enabled = 1 AND ne.type = 'Device' AND h.uuid IN (?) // same (if I'm understanding correctly, the only difference is that it
) as ds // considers "remove" operations that have NULL status, which it would
// update to make its status to NULL).
toInstallStmt := fmt.Sprintf(`
SELECT
ds.profile_uuid as profile_uuid,
ds.host_uuid as host_uuid,
ds.profile_identifier as profile_identifier,
ds.profile_name as profile_name,
ds.checksum as checksum
FROM ( %s ) as ds
LEFT JOIN host_mdm_apple_profiles hmap LEFT JOIN host_mdm_apple_profiles hmap
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
WHERE WHERE
-- profile has been updated -- profile has been updated
( hmap.checksum != ds.checksum ) OR ( hmap.checksum != ds.checksum ) OR
-- profiles in A but not in B -- profiles in A but not in B
( hmap.profile_uuid IS NULL AND hmap.host_uuid IS NULL ) OR ( hmap.profile_uuid IS NULL AND hmap.host_uuid IS NULL ) OR
-- profiles in A and B but with operation type "remove" -- profiles in A and B but with operation type "remove"
( hmap.host_uuid IS NOT NULL AND ( hmap.operation_type = ? OR hmap.operation_type IS NULL ) )` ( hmap.host_uuid IS NOT NULL AND ( hmap.operation_type = ? OR hmap.operation_type IS NULL ) )
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "h.uuid IN (?)"))
// TODO: if a very large number (~65K) of host uuids was matched (via // TODO: if a very large number (~65K) of host uuids was matched (via
// uuids, teams or profile IDs), could result in too many placeholders (not // uuids, teams or profile IDs), could result in too many placeholders (not
// an immediate concern). // an immediate concern).
stmt, args, err := sqlx.In(desiredStateStmt, uuids, fleet.MDMOperationTypeRemove) stmt, args, err := sqlx.In(toInstallStmt, uuids, uuids, fleet.MDMOperationTypeRemove)
if err != nil { if err != nil {
return ctxerr.Wrap(ctx, err, "building profiles to install statement") return ctxerr.Wrap(ctx, err, "building profiles to install statement")
} }
@ -1359,39 +1435,40 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute") return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute")
} }
const currentStateStmt = ` toRemoveStmt := fmt.Sprintf(`
SELECT SELECT
hmap.profile_uuid as profile_uuid, hmap.profile_uuid as profile_uuid,
hmap.host_uuid as host_uuid, hmap.host_uuid as host_uuid,
hmap.profile_identifier as profile_identifier, hmap.profile_identifier as profile_identifier,
hmap.profile_name as profile_name, hmap.profile_name as profile_name,
hmap.checksum as checksum, hmap.checksum as checksum,
hmap.status as status, hmap.status as status,
hmap.operation_type as operation_type, hmap.operation_type as operation_type,
COALESCE(hmap.detail, '') as detail, COALESCE(hmap.detail, '') as detail,
hmap.command_uuid as command_uuid hmap.command_uuid as command_uuid
FROM ( FROM ( %s ) as ds
SELECT
h.uuid, macp.profile_uuid
FROM mdm_apple_configuration_profiles macp
JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0)
JOIN nano_enrollments ne ON ne.device_id = h.uuid
WHERE h.platform = 'darwin' AND ne.enabled = 1 AND ne.type = 'Device' AND h.uuid IN (?)
) as ds
RIGHT JOIN host_mdm_apple_profiles hmap RIGHT JOIN host_mdm_apple_profiles hmap
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.uuid ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
WHERE WHERE
hmap.host_uuid IN (?) hmap.host_uuid IN (?) AND
-- profiles that are in B but not in A -- profiles that are in B but not in A
AND ds.profile_uuid IS NULL AND ds.uuid IS NULL ds.profile_uuid IS NULL AND ds.host_uuid IS NULL AND
-- except "remove" operations in any state -- except "remove" operations in any state
AND ( hmap.operation_type IS NULL OR hmap.operation_type != ? ) ( hmap.operation_type IS NULL OR hmap.operation_type != ? ) AND
` -- except "would be removed" profiles if they are a broken label-based profile
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
WHERE
mcpl.apple_profile_uuid = hmap.profile_uuid AND
mcpl.label_id IS NULL
)
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "h.uuid IN (?)"))
// TODO: if a very large number (~65K) of host uuids was matched (via // TODO: if a very large number (~65K) of host uuids was matched (via
// uuids, teams or profile IDs), could result in too many placeholders (not // uuids, teams or profile IDs), could result in too many placeholders (not
// an immediate concern). Note that uuids are provided twice. // an immediate concern). Note that uuids are provided twice.
stmt, args, err = sqlx.In(currentStateStmt, uuids, uuids, fleet.MDMOperationTypeRemove) stmt, args, err = sqlx.In(toRemoveStmt, uuids, uuids, uuids, fleet.MDMOperationTypeRemove)
if err != nil { if err != nil {
return ctxerr.Wrap(ctx, err, "building profiles to remove statement") return ctxerr.Wrap(ctx, err, "building profiles to remove statement")
} }
@ -1534,19 +1611,79 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
return nil return nil
} }
const appleMDMProfilesDesiredStateQuery = `
-- non label-based profiles
SELECT
macp.profile_uuid,
h.uuid as host_uuid,
macp.identifier as profile_identifier,
macp.name as profile_name,
macp.checksum as checksum,
0 as count_profile_labels,
0 as count_host_labels
FROM
mdm_apple_configuration_profiles macp
JOIN hosts h
ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0)
JOIN nano_enrollments ne
ON ne.device_id = h.uuid
WHERE
h.platform = 'darwin' AND
ne.enabled = 1 AND
ne.type = 'Device' AND
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
WHERE mcpl.apple_profile_uuid = macp.profile_uuid
) AND
( %s )
UNION
-- label-based profiles where the host is a member of all the labels
SELECT
macp.profile_uuid,
h.uuid as host_uuid,
macp.identifier as profile_identifier,
macp.name as profile_name,
macp.checksum as checksum,
COUNT(*) as count_profile_labels,
COUNT(lm.label_id) as count_host_labels
FROM
mdm_apple_configuration_profiles macp
JOIN hosts h
ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0)
JOIN nano_enrollments ne
ON ne.device_id = h.uuid
JOIN mdm_configuration_profile_labels mcpl
ON mcpl.apple_profile_uuid = macp.profile_uuid
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mcpl.label_id AND lm.host_id = h.id
WHERE
h.platform = 'darwin' AND
ne.enabled = 1 AND
ne.type = 'Device' AND
( %s )
GROUP BY
macp.profile_uuid, h.uuid, macp.identifier, macp.name, macp.checksum
HAVING
count_profile_labels > 0 AND count_host_labels = count_profile_labels
`
func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) { func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) {
// The query below is a set difference between: // The query below is a set difference between:
// //
// - Set A (ds), the desired state, can be obtained from a JOIN between // - Set A (ds), the "desired state", can be obtained from a JOIN between
// mdm_apple_configuration_profiles and hosts. // mdm_apple_configuration_profiles and hosts.
// - Set B, the current state given by host_mdm_apple_profiles. //
// - Set B, the "current state" given by host_mdm_apple_profiles.
// //
// A - B gives us the profiles that need to be installed: // A - B gives us the profiles that need to be installed:
// //
// - profiles that are in A but not in B // - profiles that are in A but not in B
// //
// - profiles which contents have changed, but their identifier are // - profiles which contents have changed, but their identifier are
// the same (by matching checksums) // the same (by checking the checksums)
// //
// - profiles that are in A and in B, but with an operation type of // - profiles that are in A and in B, but with an operation type of
// "remove", regardless of the status. (technically, if status is NULL then // "remove", regardless of the status. (technically, if status is NULL then
@ -1562,39 +1699,35 @@ func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*flee
// and a NULL status. Other statuses mean that the operation is already in // and a NULL status. Other statuses mean that the operation is already in
// flight (pending), the operation has been completed but is still subject // flight (pending), the operation has been completed but is still subject
// to independent verification by Fleet (verifying), or has reached a terminal // to independent verification by Fleet (verifying), or has reached a terminal
// state (failed or verified). If the profile's content is edited, all relevant hosts will // state (failed or verified). If the profile's content is edited, all
// be marked as status NULL so that it gets re-installed. // relevant hosts will be marked as status NULL so that it gets
query := ` // re-installed.
SELECT //
ds.profile_uuid, // Note that for label-based profiles, only fully-satisfied profiles are
ds.host_uuid, // considered for installation. This means that a broken label-based profile,
ds.profile_identifier, // where one of the labels does not exist anymore, will not be considered for
ds.profile_name, // installation.
ds.checksum
FROM ( query := fmt.Sprintf(`
SELECT SELECT
macp.profile_uuid, ds.profile_uuid,
h.uuid as host_uuid, ds.host_uuid,
macp.identifier as profile_identifier, ds.profile_identifier,
macp.name as profile_name, ds.profile_name,
macp.checksum as checksum ds.checksum
FROM mdm_apple_configuration_profiles macp FROM ( %s ) as ds
JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) LEFT JOIN host_mdm_apple_profiles hmap
JOIN nano_enrollments ne ON ne.device_id = h.uuid ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
WHERE h.platform = 'darwin' AND ne.enabled = 1 AND ne.type = 'Device' WHERE
) as ds -- profile has been updated
LEFT JOIN host_mdm_apple_profiles hmap ( hmap.checksum != ds.checksum ) OR
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid -- profiles in A but not in B
WHERE ( hmap.profile_uuid IS NULL AND hmap.host_uuid IS NULL ) OR
-- profile has been updated -- profiles in A and B but with operation type "remove"
( hmap.checksum != ds.checksum ) OR ( hmap.host_uuid IS NOT NULL AND ( hmap.operation_type = ? OR hmap.operation_type IS NULL ) ) OR
-- profiles in A but not in B -- profiles in A and B with operation type "install" and NULL status
( hmap.profile_uuid IS NULL AND hmap.host_uuid IS NULL ) OR ( hmap.host_uuid IS NOT NULL AND hmap.operation_type = ? AND hmap.status IS NULL )
-- profiles in A and B but with operation type "remove" `, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "TRUE", "TRUE"))
( hmap.host_uuid IS NOT NULL AND ( hmap.operation_type = ? OR hmap.operation_type IS NULL ) ) OR
-- profiles in A and B with operation type "install" and NULL status
( hmap.host_uuid IS NOT NULL AND hmap.operation_type = ? AND hmap.status IS NULL )
`
var profiles []*fleet.MDMAppleProfilePayload var profiles []*fleet.MDMAppleProfilePayload
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, query, fleet.MDMOperationTypeRemove, fleet.MDMOperationTypeInstall) err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, query, fleet.MDMOperationTypeRemove, fleet.MDMOperationTypeInstall)
@ -1604,9 +1737,10 @@ func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*flee
func (ds *Datastore) ListMDMAppleProfilesToRemove(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) { func (ds *Datastore) ListMDMAppleProfilesToRemove(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) {
// The query below is a set difference between: // The query below is a set difference between:
// //
// - Set A (ds), the desired state, can be obtained from a JOIN between // - Set A (ds), the "desired state", can be obtained from a JOIN between
// mdm_apple_configuration_profiles and hosts. // mdm_apple_configuration_profiles and hosts.
// - Set B, the current state given by host_mdm_apple_profiles. //
// - Set B, the "current state" given by host_mdm_apple_profiles.
// //
// B - A gives us the profiles that need to be removed: // B - A gives us the profiles that need to be removed:
// //
@ -1619,31 +1753,43 @@ func (ds *Datastore) ListMDMAppleProfilesToRemove(ctx context.Context) ([]*fleet
// Any other case are profiles that are in both B and A, and as such are // Any other case are profiles that are in both B and A, and as such are
// processed by the ListMDMAppleProfilesToInstall method (since they are in // processed by the ListMDMAppleProfilesToInstall method (since they are in
// both, their desired state is necessarily to be installed). // both, their desired state is necessarily to be installed).
query := ` //
SELECT // Note that for label-based profiles, only those that are fully-sastisfied
hmap.profile_uuid, // by the host are considered for install (are part of the desired state used
hmap.profile_identifier, // to compute the ones to remove). However, as a special case, a broken
hmap.profile_name, // label-based profile will NOT be removed from a host where it was
hmap.host_uuid, // previously installed. However, if a host used to satisfy a label-based
hmap.checksum, // profile but no longer does (and that label-based profile is not "broken"),
hmap.operation_type, // the profile will be removed from the host.
COALESCE(hmap.detail, '') as detail,
hmap.status, query := fmt.Sprintf(`
hmap.command_uuid SELECT
FROM ( hmap.profile_uuid,
SELECT h.uuid, macp.profile_uuid hmap.profile_identifier,
FROM mdm_apple_configuration_profiles macp hmap.profile_name,
JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) hmap.host_uuid,
JOIN nano_enrollments ne ON ne.device_id = h.uuid hmap.checksum,
WHERE h.platform = 'darwin' AND ne.enabled = 1 AND ne.type = 'Device' hmap.operation_type,
) as ds COALESCE(hmap.detail, '') as detail,
RIGHT JOIN host_mdm_apple_profiles hmap hmap.status,
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.uuid hmap.command_uuid
-- profiles that are in B but not in A FROM ( %s ) as ds
WHERE ds.profile_uuid IS NULL AND ds.uuid IS NULL RIGHT JOIN host_mdm_apple_profiles hmap
-- except "remove" operations in a terminal state or already pending ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
AND ( hmap.operation_type IS NULL OR hmap.operation_type != ? OR hmap.status IS NULL ) WHERE
` -- profiles that are in B but not in A
ds.profile_uuid IS NULL AND ds.host_uuid IS NULL AND
-- except "remove" operations in a terminal state or already pending
( hmap.operation_type IS NULL OR hmap.operation_type != ? OR hmap.status IS NULL ) AND
-- except "would be removed" profiles if they are a broken label-based profile
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
WHERE
mcpl.apple_profile_uuid = hmap.profile_uuid AND
mcpl.label_id IS NULL
)
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "TRUE", "TRUE"))
var profiles []*fleet.MDMAppleProfilePayload var profiles []*fleet.MDMAppleProfilePayload
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, query, fleet.MDMOperationTypeRemove) err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, query, fleet.MDMOperationTypeRemove)

View File

@ -39,6 +39,7 @@ func TestMDMApple(t *testing.T) {
fn func(t *testing.T, ds *Datastore) fn func(t *testing.T, ds *Datastore)
}{ }{
{"TestNewMDMAppleConfigProfileDuplicateName", testNewMDMAppleConfigProfileDuplicateName}, {"TestNewMDMAppleConfigProfileDuplicateName", testNewMDMAppleConfigProfileDuplicateName},
{"TestNewMDMAppleConfigProfileLabels", testNewMDMAppleConfigProfileLabels},
{"TestNewMDMAppleConfigProfileDuplicateIdentifier", testNewMDMAppleConfigProfileDuplicateIdentifier}, {"TestNewMDMAppleConfigProfileDuplicateIdentifier", testNewMDMAppleConfigProfileDuplicateIdentifier},
{"TestDeleteMDMAppleConfigProfile", testDeleteMDMAppleConfigProfile}, {"TestDeleteMDMAppleConfigProfile", testDeleteMDMAppleConfigProfile},
{"TestDeleteMDMAppleConfigProfileByTeamAndIdentifier", testDeleteMDMAppleConfigProfileByTeamAndIdentifier}, {"TestDeleteMDMAppleConfigProfileByTeamAndIdentifier", testDeleteMDMAppleConfigProfileByTeamAndIdentifier},
@ -138,6 +139,38 @@ func testNewMDMAppleConfigProfileDuplicateName(t *testing.T, ds *Datastore) {
require.ErrorAs(t, err, &existsErr) require.ErrorAs(t, err, &existsErr)
} }
func testNewMDMAppleConfigProfileLabels(t *testing.T, ds *Datastore) {
ctx := context.Background()
dummyMC := mobileconfig.Mobileconfig([]byte("DummyTestMobileconfigBytes"))
cp := fleet.MDMAppleConfigProfile{
Name: "DummyTestName",
Identifier: "DummyTestIdentifier",
Mobileconfig: dummyMC,
TeamID: nil,
Labels: []fleet.ConfigurationProfileLabel{
{LabelName: "foo", LabelID: 1},
},
}
_, err := ds.NewMDMAppleConfigProfile(ctx, cp)
require.NotNil(t, err)
require.True(t, fleet.IsForeignKey(err))
label := &fleet.Label{
Name: "my label",
Description: "a label",
Query: "select 1 from processes;",
Platform: "darwin",
}
label, err = ds.NewLabel(ctx, label)
require.NoError(t, err)
cp.Labels = []fleet.ConfigurationProfileLabel{
{LabelName: label.Name, LabelID: label.ID},
}
prof, err := ds.NewMDMAppleConfigProfile(ctx, cp)
require.NoError(t, err)
require.NotEmpty(t, prof.ProfileUUID)
}
func testNewMDMAppleConfigProfileDuplicateIdentifier(t *testing.T, ds *Datastore) { func testNewMDMAppleConfigProfileDuplicateIdentifier(t *testing.T, ds *Datastore) {
ctx := context.Background() ctx := context.Background()
initialCP := storeDummyConfigProfileForTest(t, ds) initialCP := storeDummyConfigProfileForTest(t, ds)
@ -163,9 +196,46 @@ func testNewMDMAppleConfigProfileDuplicateIdentifier(t *testing.T, ds *Datastore
storedCP, err := ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, newCP.ProfileID) storedCP, err := ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, newCP.ProfileID)
require.NoError(t, err) require.NoError(t, err)
checkConfigProfile(t, *newCP, *storedCP) checkConfigProfile(t, *newCP, *storedCP)
require.Nil(t, storedCP.Labels)
storedCP, err = ds.GetMDMAppleConfigProfile(ctx, newCP.ProfileUUID) storedCP, err = ds.GetMDMAppleConfigProfile(ctx, newCP.ProfileUUID)
require.NoError(t, err) require.NoError(t, err)
checkConfigProfile(t, *newCP, *storedCP) checkConfigProfile(t, *newCP, *storedCP)
require.Nil(t, storedCP.Labels)
// create a label-based profile
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "lbl", Query: "select 1"})
require.NoError(t, err)
labelCP := fleet.MDMAppleConfigProfile{
Name: "label-based",
Identifier: "label-based",
Mobileconfig: mobileconfig.Mobileconfig([]byte("LabelTestMobileconfigBytes")),
Labels: []fleet.ConfigurationProfileLabel{
{LabelName: lbl.Name, LabelID: lbl.ID},
},
}
labelProf, err := ds.NewMDMAppleConfigProfile(ctx, labelCP)
require.NoError(t, err)
// get it back from both the deprecated ID and the uuid methods, labels are
// only included in the uuid one
prof, err := ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, labelProf.ProfileID)
require.NoError(t, err)
require.Nil(t, prof.Labels)
prof, err = ds.GetMDMAppleConfigProfile(ctx, labelProf.ProfileUUID)
require.NoError(t, err)
require.Len(t, prof.Labels, 1)
require.Equal(t, lbl.Name, prof.Labels[0].LabelName)
require.False(t, prof.Labels[0].Broken)
// break the profile by deleting the label
require.NoError(t, ds.DeleteLabel(ctx, lbl.Name))
prof, err = ds.GetMDMAppleConfigProfile(ctx, labelProf.ProfileUUID)
require.NoError(t, err)
require.Len(t, prof.Labels, 1)
require.Equal(t, lbl.Name, prof.Labels[0].LabelName)
require.True(t, prof.Labels[0].Broken)
} }
func generateCP(name string, identifier string, teamID uint) *fleet.MDMAppleConfigProfile { func generateCP(name string, identifier string, teamID uint) *fleet.MDMAppleConfigProfile {
@ -1027,12 +1097,17 @@ func configProfileBytesForTest(name, identifier, uuid string) []byte {
`, name, identifier, uuid)) `, name, identifier, uuid))
} }
func configProfileForTest(t *testing.T, name, identifier, uuid string) *fleet.MDMAppleConfigProfile { func configProfileForTest(t *testing.T, name, identifier, uuid string, labels ...*fleet.Label) *fleet.MDMAppleConfigProfile {
prof := configProfileBytesForTest(name, identifier, uuid) prof := configProfileBytesForTest(name, identifier, uuid)
cp, err := fleet.NewMDMAppleConfigProfile(configProfileBytesForTest(name, identifier, uuid), nil) cp, err := fleet.NewMDMAppleConfigProfile(configProfileBytesForTest(name, identifier, uuid), nil)
require.NoError(t, err) require.NoError(t, err)
sum := md5.Sum(prof) // nolint:gosec // used only to hash for efficient comparisons sum := md5.Sum(prof) // nolint:gosec // used only to hash for efficient comparisons
cp.Checksum = sum[:] cp.Checksum = sum[:]
for _, lbl := range labels {
cp.Labels = append(cp.Labels, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
}
return cp return cp
} }

View File

@ -861,27 +861,32 @@ func (ds *Datastore) SearchLabels(ctx context.Context, filter fleet.TeamFilter,
return matches, nil return matches, nil
} }
func (ds *Datastore) LabelIDsByName(ctx context.Context, labels []string) ([]uint, error) { func (ds *Datastore) LabelIDsByName(ctx context.Context, names []string) (map[string]uint, error) {
if len(labels) == 0 { if len(names) == 0 {
return []uint{}, nil return map[string]uint{}, nil
} }
sqlStatement := ` sqlStatement := `
SELECT id FROM labels SELECT id, name FROM labels
WHERE name IN (?) WHERE name IN (?)
` `
sql, args, err := sqlx.In(sqlStatement, labels) sql, args, err := sqlx.In(sqlStatement, names)
if err != nil { if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building query to get label IDs") return nil, ctxerr.Wrap(ctx, err, "building query to get label ids by name")
} }
var labelIDs []uint var labels []fleet.Label
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labelIDs, sql, args...); err != nil { if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, sql, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get label IDs") return nil, ctxerr.Wrap(ctx, err, "get label ids by name")
} }
return labelIDs, nil result := make(map[string]uint, len(labels))
for _, label := range labels {
result[label.Name] = label.ID
}
return result, nil
} }
// AsyncBatchInsertLabelMembership inserts into the label_membership table the // AsyncBatchInsertLabelMembership inserts into the label_membership table the

View File

@ -3,7 +3,6 @@ package mysql
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"strconv" "strconv"
"testing" "testing"
"time" "time"
@ -747,8 +746,7 @@ func testLabelsIDsByName(t *testing.T, ds *Datastore) {
labels, err := ds.LabelIDsByName(context.Background(), []string{"foo", "bar", "bing"}) labels, err := ds.LabelIDsByName(context.Background(), []string{"foo", "bar", "bing"})
require.Nil(t, err) require.Nil(t, err)
sort.Slice(labels, func(i, j int) bool { return labels[i] < labels[j] }) assert.Equal(t, map[string]uint{"foo": 1, "bar": 2, "bing": 3}, labels)
assert.Equal(t, []uint{1, 2, 3}, labels)
} }
func testLabelsSave(t *testing.T, db *Datastore) { func testLabelsSave(t *testing.T, db *Datastore) {

View File

@ -188,9 +188,73 @@ FROM (
profs = profs[:len(profs)-1] profs = profs[:len(profs)-1]
} }
} }
// load the labels associated with those profiles
var winProfUUIDs, macProfUUIDs []string
for _, prof := range profs {
if prof.Platform == "windows" {
winProfUUIDs = append(winProfUUIDs, prof.ProfileUUID)
} else {
macProfUUIDs = append(macProfUUIDs, prof.ProfileUUID)
}
}
labels, err := ds.listProfileLabelsForProfiles(ctx, winProfUUIDs, macProfUUIDs)
if err != nil {
return nil, nil, err
}
// match the labels with their profiles
profMap := make(map[string]*fleet.MDMConfigProfilePayload, len(profs))
for _, prof := range profs {
profMap[prof.ProfileUUID] = prof
}
for _, label := range labels {
if prof, ok := profMap[label.ProfileUUID]; ok {
prof.Labels = append(prof.Labels, label)
}
}
return profs, metaData, nil return profs, metaData, nil
} }
func (ds *Datastore) listProfileLabelsForProfiles(ctx context.Context, winProfUUIDs, macProfUUIDs []string) ([]fleet.ConfigurationProfileLabel, error) {
// load the labels associated with those profiles
const labelsStmt = `
SELECT
COALESCE(apple_profile_uuid, windows_profile_uuid) as profile_uuid,
label_name,
COALESCE(label_id, 0) as label_id,
IF(label_id IS NULL, 1, 0) as broken
FROM
mdm_configuration_profile_labels mcpl
WHERE
mcpl.apple_profile_uuid IN (?) OR
mcpl.windows_profile_uuid IN (?)
ORDER BY
profile_uuid, label_name
`
// ensure there's at least one (non-matching) value in the slice so the IN
// clause is valid
if len(winProfUUIDs) == 0 {
winProfUUIDs = []string{"-"}
}
if len(macProfUUIDs) == 0 {
macProfUUIDs = []string{"-"}
}
stmt, args, err := sqlx.In(labelsStmt, macProfUUIDs, winProfUUIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "sqlx.In to list labels for profiles")
}
var labels []fleet.ConfigurationProfileLabel
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "select profiles labels")
}
return labels, nil
}
// Note that team ID 0 is used for profiles that apply to hosts in no team // Note that team ID 0 is used for profiles that apply to hosts in no team
// (i.e. pass 0 in that case as part of the teamIDs slice). Only one of the // (i.e. pass 0 in that case as part of the teamIDs slice). Only one of the
// slice arguments can have values. // slice arguments can have values.
@ -512,23 +576,58 @@ func (ds *Datastore) GetHostMDMProfilesExpectedForVerification(ctx context.Conte
switch host.Platform { switch host.Platform {
case "darwin": case "darwin":
return ds.getHostMDMAppleProfilesExpectedForVerification(ctx, teamID) return ds.getHostMDMAppleProfilesExpectedForVerification(ctx, teamID, host.ID)
case "windows": case "windows":
return ds.getHostMDMWindowsProfilesExpectedForVerification(ctx, teamID) return ds.getHostMDMWindowsProfilesExpectedForVerification(ctx, teamID, host.ID)
default: default:
return nil, fmt.Errorf("unsupported platform: %s", host.Platform) return nil, fmt.Errorf("unsupported platform: %s", host.Platform)
} }
} }
func (ds *Datastore) getHostMDMWindowsProfilesExpectedForVerification(ctx context.Context, teamID uint) (map[string]*fleet.ExpectedMDMProfile, error) { func (ds *Datastore) getHostMDMWindowsProfilesExpectedForVerification(ctx context.Context, teamID, hostID uint) (map[string]*fleet.ExpectedMDMProfile, error) {
stmt := ` stmt := `
SELECT name, syncml as raw_profile, updated_at as earliest_install_date SELECT
FROM mdm_windows_configuration_profiles mwcp name,
WHERE mwcp.team_id = ? syncml AS raw_profile,
mwcp.updated_at AS earliest_install_date,
0 AS count_profile_labels,
0 AS count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
WHERE
mwcp.team_id = ?
AND NOT EXISTS (
SELECT
1
FROM
mdm_configuration_profile_labels mcpl
WHERE
mcpl.apple_profile_uuid = mwcp.profile_uuid)
UNION
SELECT
name,
syncml AS raw_profile,
mwcp.updated_at AS earliest_install_date,
COUNT(*) AS count_profile_labels,
COUNT(lm.label_id) AS count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
JOIN mdm_configuration_profile_labels mcpl ON mcpl.windows_profile_uuid = mwcp.profile_uuid
LEFT OUTER JOIN label_membership lm ON lm.label_id = mcpl.label_id
AND lm.host_id = ?
WHERE
mwcp.team_id = ?
GROUP BY
name
HAVING
count_profile_labels > 0
AND count_host_labels = count_profile_labels
` `
var profiles []*fleet.ExpectedMDMProfile var profiles []*fleet.ExpectedMDMProfile
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, stmt, teamID) // Note: teamID provided twice
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, stmt, teamID, hostID, teamID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -541,10 +640,12 @@ func (ds *Datastore) getHostMDMWindowsProfilesExpectedForVerification(ctx contex
return byName, nil return byName, nil
} }
func (ds *Datastore) getHostMDMAppleProfilesExpectedForVerification(ctx context.Context, teamID uint) (map[string]*fleet.ExpectedMDMProfile, error) { func (ds *Datastore) getHostMDMAppleProfilesExpectedForVerification(ctx context.Context, teamID, hostID uint) (map[string]*fleet.ExpectedMDMProfile, error) {
stmt := ` stmt := `
SELECT SELECT
identifier, macp.identifier AS identifier,
0 AS count_profile_labels,
0 AS count_host_labels,
earliest_install_date earliest_install_date
FROM FROM
mdm_apple_configuration_profiles macp mdm_apple_configuration_profiles macp
@ -555,13 +656,48 @@ FROM
FROM FROM
mdm_apple_configuration_profiles mdm_apple_configuration_profiles
GROUP BY GROUP BY
checksum) cs checksum) cs ON macp.checksum = cs.checksum
ON macp.checksum = cs.checksum
WHERE WHERE
macp.team_id = ?` macp.team_id = ?
AND NOT EXISTS (
SELECT
1
FROM
mdm_configuration_profile_labels mcpl
WHERE
mcpl.apple_profile_uuid = macp.profile_uuid)
UNION
-- label-based profiles where the host is a member of all the labels
SELECT
macp.identifier AS identifier,
COUNT(*) AS count_profile_labels,
COUNT(lm.label_id) AS count_host_labels,
earliest_install_date
FROM
mdm_apple_configuration_profiles macp
JOIN (
SELECT
checksum,
min(updated_at) AS earliest_install_date
FROM
mdm_apple_configuration_profiles
GROUP BY
checksum) cs ON macp.checksum = cs.checksum
JOIN mdm_configuration_profile_labels mcpl ON mcpl.apple_profile_uuid = macp.profile_uuid
LEFT OUTER JOIN label_membership lm ON lm.label_id = mcpl.label_id
AND lm.host_id = ?
WHERE
macp.team_id = ?
GROUP BY
identifier
HAVING
count_profile_labels > 0
AND count_host_labels = count_profile_labels
`
var rows []*fleet.ExpectedMDMProfile var rows []*fleet.ExpectedMDMProfile
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, teamID); err != nil { // Note: teamID provided twice
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, teamID, hostID, teamID); err != nil {
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("getting expected profiles for host in team %d", teamID)) return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("getting expected profiles for host in team %d", teamID))
} }
@ -649,3 +785,96 @@ WHERE
return dest, nil return dest, nil
} }
func batchSetProfileLabelAssociationsDB(
ctx context.Context,
tx sqlx.ExtContext,
profileLabels []fleet.ConfigurationProfileLabel,
platform string,
) error {
if len(profileLabels) == 0 {
return nil
}
var platformPrefix string
switch platform {
case "darwin":
// map "darwin" to "apple" to be consistent with other
// "platform-agnostic" datastore methods. We initially used "darwin"
// because that's what hosts use (as the data is reported by osquery)
// and sometimes we want to dynamically select a table based on host
// data.
platformPrefix = "apple"
case "windows":
platformPrefix = "windows"
default:
return fmt.Errorf("unsupported platform %s", platform)
}
// delete any profile+label tuple that is NOT in the list of provided tuples
// but are associated with the provided profiles (so we don't delete
// unrelated profile+label tuples)
deleteStmt := `
DELETE FROM mdm_configuration_profile_labels
WHERE (%s_profile_uuid, label_id) NOT IN (%s) AND
%s_profile_uuid IN (?)
`
upsertStmt := `
INSERT INTO mdm_configuration_profile_labels
(%s_profile_uuid, label_id, label_name)
VALUES
%s
ON DUPLICATE KEY UPDATE
label_id = VALUES(label_id)
`
var (
insertBuilder strings.Builder
deleteBuilder strings.Builder
insertParams []any
deleteParams []any
setProfileUUIDs = make(map[string]struct{})
)
for i, pl := range profileLabels {
if i > 0 {
insertBuilder.WriteString(",")
deleteBuilder.WriteString(",")
}
insertBuilder.WriteString("(?, ?, ?)")
deleteBuilder.WriteString("(?, ?)")
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName)
deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID)
setProfileUUIDs[pl.ProfileUUID] = struct{}{}
}
_, err := tx.ExecContext(ctx, fmt.Sprintf(upsertStmt, platformPrefix, insertBuilder.String()), insertParams...)
if err != nil {
if isChildForeignKeyError(err) {
// one of the provided labels doesn't exist
return foreignKey("mdm_configuration_profile_labels", fmt.Sprintf("(profile, label)=(%v)", insertParams))
}
return ctxerr.Wrap(ctx, err, "setting label associations for profile")
}
deleteStmt = fmt.Sprintf(deleteStmt, platformPrefix, deleteBuilder.String(), platformPrefix)
profUUIDs := make([]string, 0, len(setProfileUUIDs))
for k := range setProfileUUIDs {
profUUIDs = append(profUUIDs, k)
}
deleteArgs := append(deleteParams, profUUIDs)
deleteStmt, args, err := sqlx.In(deleteStmt, deleteArgs...)
if err != nil {
return ctxerr.Wrap(ctx, err, "sqlx.In delete labels for profiles")
}
if _, err := tx.ExecContext(ctx, deleteStmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "deleting labels for profiles")
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@ -701,6 +701,15 @@ WHERE
return nil, ctxerr.Wrap(ctx, err, "get mdm windows config profile") return nil, ctxerr.Wrap(ctx, err, "get mdm windows config profile")
} }
labels, err := ds.listProfileLabelsForProfiles(ctx, []string{res.ProfileUUID}, nil)
if err != nil {
return nil, err
}
if len(labels) > 0 {
// ensure we leave Labels nil if there are none
res.Labels = labels
}
return &res, nil return &res, nil
} }
@ -1074,8 +1083,63 @@ GROUP BY
return counts, nil return counts, nil
} }
const windowsMDMProfilesDesiredStateQuery = `
-- non label-based profiles
SELECT
mwcp.profile_uuid,
mwcp.name,
h.uuid as host_uuid,
0 as count_profile_labels,
0 as count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
JOIN hosts h
ON h.team_id = mwcp.team_id OR (h.team_id IS NULL AND mwcp.team_id = 0)
JOIN mdm_windows_enrollments mwe
ON mwe.host_uuid = h.uuid
WHERE
h.platform = 'windows' AND
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
WHERE mcpl.windows_profile_uuid = mwcp.profile_uuid
) AND
( %s )
UNION
-- label-based profiles
SELECT
mwcp.profile_uuid,
mwcp.name,
h.uuid as host_uuid,
COUNT(*) as count_profile_labels,
COUNT(lm.label_id) as count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
JOIN hosts h
ON h.team_id = mwcp.team_id OR (h.team_id IS NULL AND mwcp.team_id = 0)
JOIN mdm_windows_enrollments mwe
ON mwe.host_uuid = h.uuid
JOIN mdm_configuration_profile_labels mcpl
ON mcpl.windows_profile_uuid = mwcp.profile_uuid
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mcpl.label_id AND lm.host_id = h.id
WHERE
h.platform = 'windows' AND
( %s )
GROUP BY
mwcp.profile_uuid, mwcp.name, h.uuid
HAVING
count_profile_labels > 0 AND count_host_labels = count_profile_labels
`
func (ds *Datastore) ListMDMWindowsProfilesToInstall(ctx context.Context) ([]*fleet.MDMWindowsProfilePayload, error) { func (ds *Datastore) ListMDMWindowsProfilesToInstall(ctx context.Context) ([]*fleet.MDMWindowsProfilePayload, error) {
var result []*fleet.MDMWindowsProfilePayload var result []*fleet.MDMWindowsProfilePayload
// TODO(mna): why is this in a transaction/reading from the primary, but not
// Apple's implementation? I see that the called private method is sometimes
// called inside a transaction, but when called from here it could (should?)
// be without and use the reader replica?
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
var err error var err error
result, err = listMDMWindowsProfilesToInstallDB(ctx, tx, nil) result, err = listMDMWindowsProfilesToInstallDB(ctx, tx, nil)
@ -1091,9 +1155,10 @@ func listMDMWindowsProfilesToInstallDB(
) ([]*fleet.MDMWindowsProfilePayload, error) { ) ([]*fleet.MDMWindowsProfilePayload, error) {
// The query below is a set difference between: // The query below is a set difference between:
// //
// - Set A (ds), the desired state, can be obtained from a JOIN between // - Set A (ds), the "desired state", can be obtained from a JOIN between
// mdm_windows_configuration_profiles and hosts. // mdm_windows_configuration_profiles and hosts.
// - Set B, the current state given by host_mdm_windows_profiles. //
// - Set B, the "current state" given by host_mdm_windows_profiles.
// //
// A - B gives us the profiles that need to be installed: // A - B gives us the profiles that need to be installed:
// //
@ -1105,26 +1170,26 @@ func listMDMWindowsProfilesToInstallDB(
// to independent verification by Fleet (verifying), or has reached a terminal // to independent verification by Fleet (verifying), or has reached a terminal
// state (failed or verified). If the profile's content is edited, all relevant hosts will // state (failed or verified). If the profile's content is edited, all relevant hosts will
// be marked as status NULL so that it gets re-installed. // be marked as status NULL so that it gets re-installed.
query := ` //
SELECT // Note that for label-based profiles, only fully-satisfied profiles are
ds.profile_uuid, // considered for installation. This means that a broken label-based profile,
ds.host_uuid, // where one of the labels does not exist anymore, will not be considered for
ds.name as profile_name // installation.
FROM (
SELECT mwcp.profile_uuid, mwcp.name, h.uuid as host_uuid query := fmt.Sprintf(`
FROM mdm_windows_configuration_profiles mwcp SELECT
JOIN hosts h ON h.team_id = mwcp.team_id OR (h.team_id IS NULL AND mwcp.team_id = 0) ds.profile_uuid,
JOIN mdm_windows_enrollments mwe ON mwe.host_uuid = h.uuid ds.host_uuid,
WHERE h.platform = 'windows' AND (%s) ds.name as profile_name
) as ds FROM ( %s ) as ds
LEFT JOIN host_mdm_windows_profiles hmwp LEFT JOIN host_mdm_windows_profiles hmwp
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid
WHERE WHERE
-- profiles in A but not in B -- profiles in A but not in B
( hmwp.profile_uuid IS NULL AND hmwp.host_uuid IS NULL ) OR ( hmwp.profile_uuid IS NULL AND hmwp.host_uuid IS NULL ) OR
-- profiles in A and B with operation type "install" and NULL status -- profiles in A and B with operation type "install" and NULL status
( hmwp.host_uuid IS NOT NULL AND hmwp.operation_type = ? AND hmwp.status IS NULL ) ( hmwp.host_uuid IS NOT NULL AND hmwp.operation_type = ? AND hmwp.status IS NULL )
` `, windowsMDMProfilesDesiredStateQuery)
hostFilter := "TRUE" hostFilter := "TRUE"
if len(hostUUIDs) > 0 { if len(hostUUIDs) > 0 {
@ -1133,9 +1198,9 @@ func listMDMWindowsProfilesToInstallDB(
var err error var err error
args := []any{fleet.MDMOperationTypeInstall} args := []any{fleet.MDMOperationTypeInstall}
query = fmt.Sprintf(query, hostFilter) query = fmt.Sprintf(query, hostFilter, hostFilter)
if len(hostUUIDs) > 0 { if len(hostUUIDs) > 0 {
query, args, err = sqlx.In(query, hostUUIDs, args) query, args, err = sqlx.In(query, hostUUIDs, hostUUIDs, fleet.MDMOperationTypeInstall)
if err != nil { if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In") return nil, ctxerr.Wrap(ctx, err, "building sqlx.In")
} }
@ -1148,6 +1213,7 @@ func listMDMWindowsProfilesToInstallDB(
func (ds *Datastore) ListMDMWindowsProfilesToRemove(ctx context.Context) ([]*fleet.MDMWindowsProfilePayload, error) { func (ds *Datastore) ListMDMWindowsProfilesToRemove(ctx context.Context) ([]*fleet.MDMWindowsProfilePayload, error) {
var result []*fleet.MDMWindowsProfilePayload var result []*fleet.MDMWindowsProfilePayload
// TODO(mna): same question here
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
var err error var err error
result, err = listMDMWindowsProfilesToRemoveDB(ctx, tx, nil) result, err = listMDMWindowsProfilesToRemoveDB(ctx, tx, nil)
@ -1171,39 +1237,51 @@ func listMDMWindowsProfilesToRemoveDB(
// B - A gives us the profiles that need to be removed // B - A gives us the profiles that need to be removed
// //
// Any other case are profiles that are in both B and A, and as such are // Any other case are profiles that are in both B and A, and as such are
// processed by the ListMDMWindowsProfilesToInstall method (since they are in // processed by the ListMDMWindowsProfilesToInstall method (since they are
// both, their desired state is necessarily to be installed). // in both, their desired state is necessarily to be installed).
query := ` //
SELECT // Note that for label-based profiles, only those that are fully-sastisfied
hmwp.profile_uuid, // by the host are considered for install (are part of the desired state used
hmwp.host_uuid, // to compute the ones to remove). However, as a special case, a broken
hmwp.operation_type, // label-based profile will NOT be removed from a host where it was
COALESCE(hmwp.detail, '') as detail, // previously installed. However, if a host used to satisfy a label-based
hmwp.status, // profile but no longer does (and that label-based profile is not "broken"),
hmwp.command_uuid // the profile will be removed from the host.
FROM (
SELECT h.uuid, mwcp.profile_uuid
FROM mdm_windows_configuration_profiles mwcp
JOIN hosts h ON h.team_id = mwcp.team_id OR (h.team_id IS NULL AND mwcp.team_id = 0)
JOIN mdm_windows_enrollments mwe ON mwe.host_uuid = h.uuid
WHERE h.platform = 'windows'
) as ds
RIGHT JOIN host_mdm_windows_profiles hmwp
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.uuid
-- profiles that are in B but not in A
WHERE ds.profile_uuid IS NULL
AND ds.uuid IS NULL
AND (%s)
`
hostFilter := "TRUE" hostFilter := "TRUE"
if len(hostUUIDs) > 0 { if len(hostUUIDs) > 0 {
hostFilter = "hmwp.host_uuid IN (?)" hostFilter = "hmwp.host_uuid IN (?)"
} }
query := fmt.Sprintf(`
SELECT
hmwp.profile_uuid,
hmwp.host_uuid,
hmwp.operation_type,
COALESCE(hmwp.detail, '') as detail,
hmwp.status,
hmwp.command_uuid
FROM ( %s ) as ds
RIGHT JOIN host_mdm_windows_profiles hmwp
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid
WHERE
-- profiles that are in B but not in A
ds.profile_uuid IS NULL AND ds.host_uuid IS NULL AND
-- TODO(mna): why don't we have the same exception for "remove" operations as for Apple
-- except "would be removed" profiles if they are a broken label-based profile
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
WHERE
mcpl.windows_profile_uuid = hmwp.profile_uuid AND
mcpl.label_id IS NULL
) AND
(%s)
`, fmt.Sprintf(windowsMDMProfilesDesiredStateQuery, "TRUE", "TRUE"), hostFilter)
var err error var err error
var args []any var args []any
query = fmt.Sprintf(query, hostFilter)
if len(hostUUIDs) > 0 { if len(hostUUIDs) > 0 {
query, args, err = sqlx.In(query, hostUUIDs) query, args, err = sqlx.In(query, hostUUIDs)
if err != nil { if err != nil {
@ -1379,7 +1457,7 @@ func (ds *Datastore) bulkDeleteMDMWindowsHostsConfigProfilesDB(
func (ds *Datastore) NewMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDMWindowsConfigProfile) (*fleet.MDMWindowsConfigProfile, error) { func (ds *Datastore) NewMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDMWindowsConfigProfile) (*fleet.MDMWindowsConfigProfile, error) {
profileUUID := "w" + uuid.New().String() profileUUID := "w" + uuid.New().String()
stmt := ` insertProfileStmt := `
INSERT INTO INSERT INTO
mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml)
(SELECT ?, ?, ?, ? FROM DUAL WHERE (SELECT ?, ?, ?, ? FROM DUAL WHERE
@ -1393,27 +1471,41 @@ INSERT INTO
teamID = *cp.TeamID teamID = *cp.TeamID
} }
res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID) err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
if err != nil { res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID)
switch { if err != nil {
case isDuplicate(err): switch {
return nil, &existsError{ case isDuplicate(err):
return &existsError{
ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name,
TeamID: cp.TeamID,
}
default:
return ctxerr.Wrap(ctx, err, "creating new windows mdm config profile")
}
}
aff, _ := res.RowsAffected()
if aff == 0 {
return &existsError{
ResourceType: "MDMWindowsConfigProfile.Name", ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name, Identifier: cp.Name,
TeamID: cp.TeamID, TeamID: cp.TeamID,
} }
default:
return nil, ctxerr.Wrap(ctx, err, "creating new windows mdm config profile")
} }
}
aff, _ := res.RowsAffected() for i := range cp.Labels {
if aff == 0 { cp.Labels[i].ProfileUUID = profileUUID
return nil, &existsError{
ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name,
TeamID: cp.TeamID,
} }
if err := batchSetProfileLabelAssociationsDB(ctx, tx, cp.Labels, "windows"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting windows profile label associations")
}
return nil
})
if err != nil {
return nil, err
} }
return &fleet.MDMWindowsConfigProfile{ return &fleet.MDMWindowsConfigProfile{
@ -1478,6 +1570,7 @@ func (ds *Datastore) batchSetMDMWindowsProfilesDB(
const loadExistingProfiles = ` const loadExistingProfiles = `
SELECT SELECT
name, name,
profile_uuid,
syncml syncml
FROM FROM
mdm_windows_configuration_profiles mdm_windows_configuration_profiles
@ -1579,6 +1672,41 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name) return ctxerr.Wrapf(ctx, err, "insert new/edited profile with name %q", p.Name)
} }
} }
// build a list of labels so the associations can be batch-set all at once
// TODO: with minor changes this chunk of code could be shared
// between macOS and Windows, but at the time of this
// implementation we're under tight time constraints.
incomingLabels := []fleet.ConfigurationProfileLabel{}
if len(incomingNames) > 0 {
var newlyInsertedProfs []*fleet.MDMWindowsConfigProfile
// load current profiles (again) that match the incoming profiles by name to grab their uuids
stmt, args, err := sqlx.In(loadExistingProfiles, profTeamID, incomingNames)
if err != nil {
return ctxerr.Wrap(ctx, err, "build query to load newly inserted profiles")
}
if err := sqlx.SelectContext(ctx, tx, &newlyInsertedProfs, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "load newly inserted profiles")
}
for _, newlyInsertedProf := range newlyInsertedProfs {
incomingProf, ok := incomingProfs[newlyInsertedProf.Name]
if !ok {
return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name)
}
for _, label := range incomingProf.Labels {
label.ProfileUUID = newlyInsertedProf.ProfileUUID
incomingLabels = append(incomingLabels, label)
}
}
}
// insert/delete the label associations
if err := batchSetProfileLabelAssociationsDB(ctx, tx, incomingLabels, "windows"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting windows profile label associations")
}
return nil return nil
}) })
} }

View File

@ -1781,11 +1781,61 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
require.Error(t, err) require.Error(t, err)
require.ErrorAs(t, err, &existsErr) require.ErrorAs(t, err, &existsErr)
// create a profile with labels that don't exist
_, err = ds.NewMDMWindowsConfigProfile(
ctx,
fleet.MDMWindowsConfigProfile{
Name: "fake-labels",
TeamID: nil,
SyncML: []byte("<Replace></Replace>"),
Labels: []fleet.ConfigurationProfileLabel{{LabelName: "foo", LabelID: 1}},
})
require.NotNil(t, err)
require.True(t, fleet.IsForeignKey(err))
label := &fleet.Label{
Name: "my label",
Description: "a label",
Query: "select 1 from processes;",
}
label, err = ds.NewLabel(ctx, label)
require.NoError(t, err)
// create a profile with a label that exists
profWithLabel, err := ds.NewMDMWindowsConfigProfile(
ctx,
fleet.MDMWindowsConfigProfile{
Name: "with-labels",
TeamID: nil,
SyncML: []byte("<Replace></Replace>"),
Labels: []fleet.ConfigurationProfileLabel{{LabelName: label.Name, LabelID: label.ID}},
})
require.NoError(t, err)
require.NotEmpty(t, profWithLabel.ProfileUUID)
// get that profile with label
prof, err := ds.GetMDMWindowsConfigProfile(ctx, profWithLabel.ProfileUUID)
require.NoError(t, err)
require.Len(t, prof.Labels, 1)
require.Equal(t, label.Name, prof.Labels[0].LabelName)
require.Equal(t, label.ID, prof.Labels[0].LabelID)
require.False(t, prof.Labels[0].Broken)
// break that profile by deleting the label
require.NoError(t, ds.DeleteLabel(ctx, label.Name))
prof, err = ds.GetMDMWindowsConfigProfile(ctx, profWithLabel.ProfileUUID)
require.NoError(t, err)
require.Len(t, prof.Labels, 1)
require.Equal(t, label.Name, prof.Labels[0].LabelName)
require.Zero(t, prof.Labels[0].LabelID)
require.True(t, prof.Labels[0].Broken)
_, err = ds.GetMDMWindowsConfigProfile(ctx, "not-valid") _, err = ds.GetMDMWindowsConfigProfile(ctx, "not-valid")
require.Error(t, err) require.Error(t, err)
require.True(t, fleet.IsNotFound(err)) require.True(t, fleet.IsNotFound(err))
prof, err := ds.GetMDMWindowsConfigProfile(ctx, profA.ProfileUUID) prof, err = ds.GetMDMWindowsConfigProfile(ctx, profA.ProfileUUID)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, profA.ProfileUUID, prof.ProfileUUID) require.Equal(t, profA.ProfileUUID, prof.ProfileUUID)
require.NotNil(t, prof.TeamID) require.NotNil(t, prof.TeamID)
@ -1794,6 +1844,7 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
require.Equal(t, "<Replace></Replace>", string(prof.SyncML)) require.Equal(t, "<Replace></Replace>", string(prof.SyncML))
require.NotZero(t, prof.CreatedAt) require.NotZero(t, prof.CreatedAt)
require.NotZero(t, prof.UpdatedAt) require.NotZero(t, prof.UpdatedAt)
require.Nil(t, prof.Labels)
err = ds.DeleteMDMWindowsConfigProfile(ctx, "not-valid") err = ds.DeleteMDMWindowsConfigProfile(ctx, "not-valid")
require.Error(t, err) require.Error(t, err)
@ -1976,8 +2027,8 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
applyAndExpect(nil, ptr.Uint(1), nil) applyAndExpect(nil, ptr.Uint(1), nil)
} }
func windowsConfigProfileForTest(t *testing.T, name, locURI string) *fleet.MDMWindowsConfigProfile { func windowsConfigProfileForTest(t *testing.T, name, locURI string, labels ...*fleet.Label) *fleet.MDMWindowsConfigProfile {
return &fleet.MDMWindowsConfigProfile{ prof := &fleet.MDMWindowsConfigProfile{
Name: name, Name: name,
SyncML: []byte(fmt.Sprintf(` SyncML: []byte(fmt.Sprintf(`
<Replace> <Replace>
@ -1989,4 +2040,10 @@ func windowsConfigProfileForTest(t *testing.T, name, locURI string) *fleet.MDMWi
</Replace> </Replace>
`, locURI)), `, locURI)),
} }
for _, lbl := range labels {
prof.Labels = append(prof.Labels, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
}
return prof
} }

View File

@ -0,0 +1,63 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20240126020642, Down_20240126020642)
}
func Up_20240126020642(tx *sql.Tx) error {
createStmt := `
CREATE TABLE IF NOT EXISTS mdm_configuration_profile_labels (
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- using distinct fields for the profile uuid so that proper foreign keys
-- can be created to the apple and windows tables.
apple_profile_uuid VARCHAR(37) NULL,
windows_profile_uuid VARCHAR(37) NULL,
-- label name is stored here because we need to list the labels in the UI
-- even if it has been deleted from the labels table.
label_name VARCHAR(255) NOT NULL,
-- label id is nullable in case it gets deleted from the labels table.
-- A row in this table with label_id = null indicates the "broken" state
-- in the UI.
label_id INT(10) UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- cannot have a single unique key with apple+windows+label name because
-- NULLs are not considered equal in unique keys (so "W1+null+L1" is not
-- a duplicate of itself). Using two distinct unique keys instead, and there's
-- a check constraint to ensure that only one of the apple or windows
-- profile uuid can be set.
UNIQUE KEY idx_mdm_configuration_profile_labels_apple_label_name (apple_profile_uuid, label_name),
UNIQUE KEY idx_mdm_configuration_profile_labels_windows_label_name (windows_profile_uuid, label_name),
FOREIGN KEY (apple_profile_uuid) REFERENCES mdm_apple_configuration_profiles(profile_uuid) ON DELETE CASCADE,
FOREIGN KEY (windows_profile_uuid) REFERENCES mdm_windows_configuration_profiles(profile_uuid) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE SET NULL,
-- TODO(mna): CHECK constraint is parsed but ignored on mysql 5.7, will have to do without.
-- exactly one of apple or windows profile uuid must be set
CONSTRAINT ck_mdm_configuration_profile_labels_apple_or_windows
CHECK (ISNULL(apple_profile_uuid) <> ISNULL(windows_profile_uuid))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`
if _, err := tx.Exec(createStmt); err != nil {
return errors.Wrap(err, "create mdm_configuration_profile_labels table")
}
return nil
}
func Down_20240126020642(tx *sql.Tx) error {
return nil
}

View File

@ -0,0 +1,74 @@
package tables
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestUp_20240126020642(t *testing.T) {
db := applyUpToPrev(t)
// Apply current migration.
applyNext(t, db)
// create some Windows profiles
idwA, idwB, idwC := "w"+uuid.New().String(), "w"+uuid.New().String(), "w"+uuid.New().String()
execNoErr(t, db, `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) VALUES (?, 0, 'A', '<Replace>A</Replace>')`, idwA)
execNoErr(t, db, `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) VALUES (?, 1, 'B', '<Replace>B</Replace>')`, idwB)
execNoErr(t, db, `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) VALUES (?, 0, 'C', '<Replace>C</Replace>')`, idwC)
nonExistingWID := "w" + uuid.New().String()
// create some Apple profiles
idaA, idaB, idaC := "a"+uuid.New().String(), "a"+uuid.New().String(), "a"+uuid.New().String()
execNoErr(t, db, `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, identifier, name, mobileconfig, checksum) VALUES (?, 0, 'IA', 'NA', '<plist></plist>', '')`, idaA)
execNoErr(t, db, `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, identifier, name, mobileconfig, checksum) VALUES (?, 1, 'IB', 'NB', '<plist></plist>', '')`, idaB)
execNoErr(t, db, `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, identifier, name, mobileconfig, checksum) VALUES (?, 0, 'IC', 'NC', '<plist></plist>', '')`, idaC)
nonExistingAID := "a" + uuid.New().String()
// create some labels
idlA := execNoErrLastID(t, db, `INSERT INTO labels (name, query) VALUES ('LA', 'select 1')`)
idlB := execNoErrLastID(t, db, `INSERT INTO labels (name, query) VALUES ('LB', 'select 1')`)
idlC := execNoErrLastID(t, db, `INSERT INTO labels (name, query) VALUES ('LC', 'select 1')`)
nonExistingLID := idlC + 1
// apply labels A and B to Windows profile A
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idwA, "LA", idlA)
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idwA, "LB", idlB)
// apply labels B and C to Windows profile B (team 1)
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idwB, "LB", idlB)
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idwB, "LC", idlC)
// apply labels A and C to Apple profile A
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idaA, "LA", idlA)
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idaA, "LC", idlC)
// apply label B to Apple profile B (team 1)
execNoErr(t, db, `INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idaB, "LB", idlB)
// apply label A to non-existing Windows profile
_, err := db.Exec(`INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, nonExistingWID, "LA", idlA)
require.ErrorContains(t, err, "foreign key constraint fails")
// apply label A to non-existing Apple profile
_, err = db.Exec(`INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, nonExistingAID, "LA", idlA)
require.ErrorContains(t, err, "foreign key constraint fails")
// apply non-existing label to Windows profile A
_, err = db.Exec(`INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idwA, "Lnone", nonExistingLID)
require.ErrorContains(t, err, "foreign key constraint fails")
// apply non-existing label to Apple profile A
_, err = db.Exec(`INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idaA, "Lnone", nonExistingLID)
require.ErrorContains(t, err, "foreign key constraint fails")
// apply duplicate (label A to Windows profile A)
_, err = db.Exec(`INSERT INTO mdm_configuration_profile_labels (windows_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idwA, "LA", idlA)
require.ErrorContains(t, err, "Duplicate entry")
// apply duplicate (label A to Apple profile A)
_, err = db.Exec(`INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`, idaA, "LA", idlA)
require.ErrorContains(t, err, "Duplicate entry")
}

File diff suppressed because one or more lines are too long

View File

@ -590,7 +590,7 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
MacOSSetupAssistant: optjson.SetString("assistant"), MacOSSetupAssistant: optjson.SetString("assistant"),
}, },
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.SetSlice([]string{"foo", "bar"}), CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}),
}, },
}, },
}, },
@ -613,7 +613,7 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) {
MacOSSetupAssistant: optjson.SetString("assistant"), MacOSSetupAssistant: optjson.SetString("assistant"),
}, },
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.SetSlice([]string{"foo", "bar"}), CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}),
}, },
}, mdm) }, mdm)
}) })

View File

@ -298,8 +298,8 @@ type MacOSSettings struct {
// //
// NOTE: These are only present here for informational purposes. // NOTE: These are only present here for informational purposes.
// (The source of truth for profiles is in MySQL.) // (The source of truth for profiles is in MySQL.)
CustomSettings []string `json:"custom_settings"` CustomSettings []MDMProfileSpec `json:"custom_settings"`
DeprecatedEnableDiskEncryption *bool `json:"enable_disk_encryption,omitempty"` DeprecatedEnableDiskEncryption *bool `json:"enable_disk_encryption,omitempty"`
// NOTE: make sure to update the ToMap/FromMap methods when adding/updating fields. // NOTE: make sure to update the ToMap/FromMap methods when adding/updating fields.
} }
@ -324,20 +324,37 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro
vals, ok := v.([]interface{}) vals, ok := v.([]interface{})
if v == nil || ok { if v == nil || ok {
strs := make([]string, 0, len(vals)) csSpecs := make([]MDMProfileSpec, 0, len(vals))
for _, v := range vals { for _, v := range vals {
str, ok := v.(string) if m, ok := v.(map[string]interface{}); ok {
if !ok { var spec MDMProfileSpec
// error, must be a []string // extract the Path field
if path, ok := m["path"].(string); ok {
spec.Path = path
}
// extract the Labels field (if they are not provided, labels are
// cleared for that profile)
if labels, ok := m["labels"].([]interface{}); ok {
for _, label := range labels {
if strLabel, ok := label.(string); ok {
spec.Labels = append(spec.Labels, strLabel)
}
}
}
csSpecs = append(csSpecs, spec)
} else if m, ok := v.(string); ok { // for backwards compatibility with the old way to define profiles
csSpecs = append(csSpecs, MDMProfileSpec{Path: m})
} else {
return nil, &json.UnmarshalTypeError{ return nil, &json.UnmarshalTypeError{
Value: fmt.Sprintf("%T", v), Value: fmt.Sprintf("%T", v),
Type: reflect.TypeOf(s.CustomSettings), Type: reflect.TypeOf(s.CustomSettings),
Field: "macos_settings.custom_settings", Field: "macos_settings.custom_settings",
} }
} }
strs = append(strs, str)
} }
s.CustomSettings = strs s.CustomSettings = csSpecs
} }
} }
@ -553,8 +570,14 @@ func (c *AppConfig) Copy() *AppConfig {
} }
if c.MDM.MacOSSettings.CustomSettings != nil { if c.MDM.MacOSSettings.CustomSettings != nil {
clone.MDM.MacOSSettings.CustomSettings = make([]string, len(c.MDM.MacOSSettings.CustomSettings)) clone.MDM.MacOSSettings.CustomSettings = make([]MDMProfileSpec, len(c.MDM.MacOSSettings.CustomSettings))
copy(clone.MDM.MacOSSettings.CustomSettings, c.MDM.MacOSSettings.CustomSettings) for i, mps := range c.MDM.MacOSSettings.CustomSettings {
clone.MDM.MacOSSettings.CustomSettings[i] = *mps.Copy()
}
}
if c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption != nil {
b := *c.MDM.MacOSSettings.DeprecatedEnableDiskEncryption
clone.MDM.MacOSSettings.DeprecatedEnableDiskEncryption = &b
} }
if c.Scripts.Set { if c.Scripts.Set {
@ -564,8 +587,10 @@ func (c *AppConfig) Copy() *AppConfig {
} }
if c.MDM.WindowsSettings.CustomSettings.Set { if c.MDM.WindowsSettings.CustomSettings.Set {
windowsSettings := make([]string, len(c.MDM.WindowsSettings.CustomSettings.Value)) windowsSettings := make([]MDMProfileSpec, len(c.MDM.WindowsSettings.CustomSettings.Value))
copy(windowsSettings, c.MDM.WindowsSettings.CustomSettings.Value) for i, mps := range c.MDM.WindowsSettings.CustomSettings.Value {
windowsSettings[i] = *mps.Copy()
}
clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) clone.MDM.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings)
} }
@ -1205,5 +1230,5 @@ func (v *Version) AuthzType() string {
type WindowsSettings struct { type WindowsSettings struct {
// NOTE: These are only present here for informational purposes. // NOTE: These are only present here for informational purposes.
// (The source of truth for profiles is in MySQL.) // (The source of truth for profiles is in MySQL.)
CustomSettings optjson.Slice[string] `json:"custom_settings"` CustomSettings optjson.Slice[MDMProfileSpec] `json:"custom_settings"`
} }

View File

@ -195,9 +195,23 @@ type MDMAppleConfigProfile struct {
// representation of the configuration profile. It must be XML or PKCS7 parseable. // representation of the configuration profile. It must be XML or PKCS7 parseable.
Mobileconfig mobileconfig.Mobileconfig `db:"mobileconfig" json:"-"` Mobileconfig mobileconfig.Mobileconfig `db:"mobileconfig" json:"-"`
// Checksum is an MD5 hash of the Mobileconfig bytes // Checksum is an MD5 hash of the Mobileconfig bytes
Checksum []byte `db:"checksum" json:"checksum,omitempty"` Checksum []byte `db:"checksum" json:"checksum,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"` // Labels are the associated labels for this profile
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` Labels []ConfigurationProfileLabel `db:"labels" json:"labels,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ConfigurationProfileLabel represents the many-to-many relationship between
// profiles and labels.
//
// NOTE: json representation of the fields is a bit awkward to match the
// required API response, as this struct is returned within profile responses.
type ConfigurationProfileLabel struct {
ProfileUUID string `db:"profile_uuid" json:"-"`
LabelName string `db:"label_name" json:"name"`
LabelID uint `db:"label_id" json:"id,omitempty"` // omitted if 0 (which is impossible if the label is not broken)
Broken bool `db:"broken" json:"broken,omitempty"` // omitted (not rendered to JSON) if false
} }
func NewMDMAppleConfigProfile(raw []byte, teamID *uint) (*MDMAppleConfigProfile, error) { func NewMDMAppleConfigProfile(raw []byte, teamID *uint) (*MDMAppleConfigProfile, error) {

View File

@ -189,8 +189,8 @@ type Datastore interface {
SearchLabels(ctx context.Context, filter TeamFilter, query string, omit ...uint) ([]*Label, error) SearchLabels(ctx context.Context, filter TeamFilter, query string, omit ...uint) ([]*Label, error)
// LabelIDsByName Retrieve the IDs associated with the given labels // LabelIDsByName retrieves the IDs associated with the given label names
LabelIDsByName(ctx context.Context, labels []string) ([]uint, error) LabelIDsByName(ctx context.Context, labels []string) (map[string]uint, error)
// Methods used for async processing of host label query results. // Methods used for async processing of host label query results.
AsyncBatchInsertLabelMembership(ctx context.Context, batch [][2]uint) error AsyncBatchInsertLabelMembership(ctx context.Context, batch [][2]uint) error

View File

@ -1,7 +1,9 @@
package fleet package fleet
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"time" "time"
@ -117,6 +119,10 @@ type ExpectedMDMProfile struct {
EarliestInstallDate time.Time `db:"earliest_install_date"` EarliestInstallDate time.Time `db:"earliest_install_date"`
// RawProfile contains the raw profile contents // RawProfile contains the raw profile contents
RawProfile []byte `db:"raw_profile"` RawProfile []byte `db:"raw_profile"`
// CountProfileLabels is used to enable queries that filter based on profile <-> label mappings.
CountProfileLabels uint `db:"count_profile_labels"`
// CountHostLabels is used to enable queries that filter based on profile <-> label mappings.
CountHostLabels uint `db:"count_host_labels"`
} }
// IsWithinGracePeriod returns true if the host is within the grace period for the profile. // IsWithinGracePeriod returns true if the host is within the grace period for the profile.
@ -351,14 +357,23 @@ func (m MDMConfigProfileAuthz) AuthzType() string {
// MDMConfigProfilePayload is the platform-agnostic struct returned by // MDMConfigProfilePayload is the platform-agnostic struct returned by
// endpoints that return MDM configuration profiles (get/list profiles). // endpoints that return MDM configuration profiles (get/list profiles).
type MDMConfigProfilePayload struct { type MDMConfigProfilePayload struct {
ProfileUUID string `json:"profile_uuid" db:"profile_uuid"` ProfileUUID string `json:"profile_uuid" db:"profile_uuid"`
TeamID *uint `json:"team_id" db:"team_id"` // null for no-team TeamID *uint `json:"team_id" db:"team_id"` // null for no-team
Name string `json:"name" db:"name"` Name string `json:"name" db:"name"`
Platform string `json:"platform" db:"platform"` // "windows" or "darwin" Platform string `json:"platform" db:"platform"` // "windows" or "darwin"
Identifier string `json:"identifier,omitempty" db:"identifier"` // only set for macOS Identifier string `json:"identifier,omitempty" db:"identifier"` // only set for macOS
Checksum []byte `json:"checksum,omitempty" db:"checksum"` // only set for macOS Checksum []byte `json:"checksum,omitempty" db:"checksum"` // only set for macOS
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
Labels []ConfigurationProfileLabel `json:"labels,omitempty" db:"-"`
}
// MDMProfileBatchPayload represents the payload to batch-set the profiles for
// a team or no-team.
type MDMProfileBatchPayload struct {
Name string `json:"name,omitempty"`
Contents []byte `json:"contents,omitempty"`
Labels []string `json:"labels,omitempty"`
} }
func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConfigProfilePayload { func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConfigProfilePayload {
@ -373,6 +388,7 @@ func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConf
Platform: "windows", Platform: "windows",
CreatedAt: cp.CreatedAt, CreatedAt: cp.CreatedAt,
UpdatedAt: cp.UpdatedAt, UpdatedAt: cp.UpdatedAt,
Labels: cp.Labels,
} }
} }
@ -390,5 +406,108 @@ func NewMDMConfigProfilePayloadFromApple(cp *MDMAppleConfigProfile) *MDMConfigPr
Checksum: cp.Checksum, Checksum: cp.Checksum,
CreatedAt: cp.CreatedAt, CreatedAt: cp.CreatedAt,
UpdatedAt: cp.UpdatedAt, UpdatedAt: cp.UpdatedAt,
Labels: cp.Labels,
} }
} }
// MDMProfileSpec represents the spec used to define configuration
// profiles via yaml files.
type MDMProfileSpec struct {
Path string `json:"path,omitempty"`
Labels []string `json:"labels,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface to add backwards
// compatibility to previous ways to define profile specs.
func (p *MDMProfileSpec) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
if lookAhead := bytes.TrimSpace(data); len(lookAhead) > 0 && lookAhead[0] == '"' {
var backwardsCompat string
if err := json.Unmarshal(data, &backwardsCompat); err != nil {
return fmt.Errorf("unmarshal profile spec. Error using old format: %w", err)
}
p.Path = backwardsCompat
return nil
}
// use an alias type to avoid recursively calling this function forever.
type Alias MDMProfileSpec
aliasData := struct {
*Alias
}{
Alias: (*Alias)(p),
}
if err := json.Unmarshal(data, &aliasData); err != nil {
return fmt.Errorf("unmarshal profile spec. Error using new format: %w", err)
}
return nil
}
func (p *MDMProfileSpec) Clone() (Cloner, error) {
return p.Copy(), nil
}
func (p *MDMProfileSpec) Copy() *MDMProfileSpec {
if p == nil {
return nil
}
var clone MDMProfileSpec
clone = *p
if len(p.Labels) > 0 {
clone.Labels = make([]string, len(p.Labels))
copy(clone.Labels, p.Labels)
}
return &clone
}
func labelCountMap(labels []string) map[string]int {
counts := make(map[string]int)
for _, label := range labels {
counts[label]++
}
return counts
}
// MDMProfileSpecsMatch match checks if two slices contain the same spec
// elements, regardless of order.
func MDMProfileSpecsMatch(a, b []MDMProfileSpec) bool {
if len(a) != len(b) {
return false
}
pathLabelCounts := make(map[string]map[string]int)
for _, v := range a {
pathLabelCounts[v.Path] = labelCountMap(v.Labels)
}
for _, v := range b {
labels, ok := pathLabelCounts[v.Path]
if !ok {
return false
}
bLabelCounts := labelCountMap(v.Labels)
for label, count := range bLabelCounts {
if labels[label] != count {
return false
}
labels[label] -= count
}
for _, count := range labels {
if count != 0 {
return false
}
}
delete(pathLabelCounts, v.Path)
}
return len(pathLabelCounts) == 0
}

View File

@ -179,3 +179,148 @@ func TestMDMAppleBootstrapPackage(t *testing.T) {
require.Empty(t, url) require.Empty(t, url)
require.Error(t, err) require.Error(t, err)
} }
func TestMDMProfileSpecUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input []byte
expectPath string
expectLabels []string
expectError bool
}{
{
name: "empty input",
input: []byte(""),
expectPath: "",
expectLabels: nil,
expectError: false,
},
{
name: "new format",
input: []byte(`{"path": "testpath", "labels": ["label1", "label2"]}`),
expectPath: "testpath",
expectLabels: []string{"label1", "label2"},
expectError: false,
},
{
name: "old format",
input: []byte(`"oldpath"`),
expectPath: "oldpath",
expectLabels: nil,
expectError: false,
},
{
name: "invalid JSON",
input: []byte(`{invalid json}`),
expectPath: "",
expectLabels: nil,
expectError: true,
},
{
name: "valid JSON with extra fields",
input: []byte(`{"path": "testpath", "labels": ["label1"], "extra": "field"}`),
expectPath: "testpath",
expectLabels: []string{"label1"},
expectError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var p fleet.MDMProfileSpec
err := p.UnmarshalJSON(tc.input)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectPath, p.Path)
require.Equal(t, tc.expectLabels, p.Labels)
}
})
}
t.Run("complex scenario", func(t *testing.T) {
var p fleet.MDMProfileSpec
// test new format
data := []byte(`{"path": "newpath", "labels": ["label1", "label2"]}`)
err := p.UnmarshalJSON(data)
require.NoError(t, err)
require.Equal(t, "newpath", p.Path)
require.Equal(t, []string{"label1", "label2"}, p.Labels)
// test old format
p = fleet.MDMProfileSpec{}
data = []byte(`"oldpath"`)
err = p.UnmarshalJSON(data)
require.NoError(t, err)
require.Equal(t, "oldpath", p.Path)
require.Empty(t, p.Labels)
})
}
func TestMDMProfileSpecsMatch(t *testing.T) {
tests := []struct {
name string
a []fleet.MDMProfileSpec
b []fleet.MDMProfileSpec
expected bool
}{
{
name: "Empty Slices",
a: []fleet.MDMProfileSpec{},
b: []fleet.MDMProfileSpec{},
expected: true,
},
{
name: "Single Element Match",
a: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label1"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label1"}},
},
expected: true,
},
{
name: "Single Element Mismatch",
a: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label1"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path2", Labels: []string{"label1"}},
},
expected: false,
},
{
name: "Multiple Elements Match",
a: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label1", "label2"}},
{Path: "path2", Labels: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path2", Labels: []string{"label3"}},
{Path: "path1", Labels: []string{"label1", "label2"}},
},
expected: true,
},
{
name: "Multiple Elements Mismatch",
a: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label1"}},
{Path: "path2", Labels: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label2"}},
{Path: "path2", Labels: []string{"label3"}},
},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := fleet.MDMProfileSpecsMatch(tc.a, tc.b)
require.Equal(t, tc.expected, result)
})
}
}

View File

@ -618,7 +618,7 @@ type Service interface {
GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error) GetHostDEPAssignment(ctx context.Context, host *Host) (*HostDEPAssignment, error)
// NewMDMAppleConfigProfile creates a new configuration profile for the specified team. // NewMDMAppleConfigProfile creates a new configuration profile for the specified team.
NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader) (*MDMAppleConfigProfile, error) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader, labels []string) (*MDMAppleConfigProfile, error)
// GetMDMAppleConfigProfileByDeprecatedID retrieves the specified Apple // GetMDMAppleConfigProfileByDeprecatedID retrieves the specified Apple
// configuration profile via its numeric ID. This method is deprecated and // configuration profile via its numeric ID. This method is deprecated and
// should not be used for new endpoints. // should not be used for new endpoints.
@ -856,7 +856,7 @@ type Service interface {
// NewMDMWindowsConfigProfile creates a new Windows configuration profile for // NewMDMWindowsConfigProfile creates a new Windows configuration profile for
// the specified team. // the specified team.
NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader) (*MDMWindowsConfigProfile, error) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader, labels []string) (*MDMWindowsConfigProfile, error)
// NewMDMUnsupportedConfigProfile is called when a profile with an // NewMDMUnsupportedConfigProfile is called when a profile with an
// unsupported extension is uploaded. // unsupported extension is uploaded.
@ -867,7 +867,7 @@ type Service interface {
// BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified // BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified
// team or for hosts with no team. // team or for hosts with no team.
BatchSetMDMProfiles(ctx context.Context, teamID *uint, teamName *string, profiles map[string][]byte, dryRun bool, skipBulkPending bool) error BatchSetMDMProfiles(ctx context.Context, teamID *uint, teamName *string, profiles []MDMProfileBatchPayload, dryRun bool, skipBulkPending bool) error
/////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////
// Common MDM // Common MDM

View File

@ -186,15 +186,19 @@ func (t *TeamMDM) Copy() *TeamMDM {
// pointers/slices/maps). // pointers/slices/maps).
if t.MacOSSettings.CustomSettings != nil { if t.MacOSSettings.CustomSettings != nil {
clone.MacOSSettings.CustomSettings = make([]string, len(t.MacOSSettings.CustomSettings)) clone.MacOSSettings.CustomSettings = make([]MDMProfileSpec, len(t.MacOSSettings.CustomSettings))
copy(clone.MacOSSettings.CustomSettings, t.MacOSSettings.CustomSettings) for i, mps := range t.MacOSSettings.CustomSettings {
clone.MacOSSettings.CustomSettings[i] = *mps.Copy()
}
} }
if t.MacOSSettings.DeprecatedEnableDiskEncryption != nil { if t.MacOSSettings.DeprecatedEnableDiskEncryption != nil {
clone.MacOSSettings.DeprecatedEnableDiskEncryption = ptr.Bool(*t.MacOSSettings.DeprecatedEnableDiskEncryption) clone.MacOSSettings.DeprecatedEnableDiskEncryption = ptr.Bool(*t.MacOSSettings.DeprecatedEnableDiskEncryption)
} }
if t.WindowsSettings.CustomSettings.Set { if t.WindowsSettings.CustomSettings.Set {
windowsSettings := make([]string, len(t.WindowsSettings.CustomSettings.Value)) windowsSettings := make([]MDMProfileSpec, len(t.WindowsSettings.CustomSettings.Value))
copy(windowsSettings, t.WindowsSettings.CustomSettings.Value) for i, mps := range t.WindowsSettings.CustomSettings.Value {
windowsSettings[i] = *mps.Copy()
}
clone.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings) clone.WindowsSettings.CustomSettings = optjson.SetSlice(windowsSettings)
} }
return &clone return &clone

View File

@ -288,7 +288,7 @@ func TestTeamMDMCopy(t *testing.T) {
t.Run("copy MacOSSettings", func(t *testing.T) { t.Run("copy MacOSSettings", func(t *testing.T) {
tm := &TeamMDM{ tm := &TeamMDM{
MacOSSettings: MacOSSettings{ MacOSSettings: MacOSSettings{
CustomSettings: []string{"a", "b"}, CustomSettings: []MDMProfileSpec{{Path: "a"}, {Path: "b"}},
DeprecatedEnableDiskEncryption: ptr.Bool(false), DeprecatedEnableDiskEncryption: ptr.Bool(false),
}, },
} }

View File

@ -31,12 +31,13 @@ type MDMWindowsBitLockerSummary struct {
type MDMWindowsConfigProfile struct { type MDMWindowsConfigProfile struct {
// ProfileUUID is the unique identifier of the configuration profile in // ProfileUUID is the unique identifier of the configuration profile in
// Fleet. For Windows profiles, it is the letter "w" followed by a uuid. // Fleet. For Windows profiles, it is the letter "w" followed by a uuid.
ProfileUUID string `db:"profile_uuid" json:"profile_uuid"` ProfileUUID string `db:"profile_uuid" json:"profile_uuid"`
TeamID *uint `db:"team_id" json:"team_id"` TeamID *uint `db:"team_id" json:"team_id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
SyncML []byte `db:"syncml" json:"-"` SyncML []byte `db:"syncml" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"` Labels []ConfigurationProfileLabel `db:"labels" json:"labels,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
} }
// ValidateUserProvided ensures that the SyncML content in the profile is valid // ValidateUserProvided ensures that the SyncML content in the profile is valid

View File

@ -148,7 +148,7 @@ type ListUniqueHostsInLabelsFunc func(ctx context.Context, filter fleet.TeamFilt
type SearchLabelsFunc func(ctx context.Context, filter fleet.TeamFilter, query string, omit ...uint) ([]*fleet.Label, error) type SearchLabelsFunc func(ctx context.Context, filter fleet.TeamFilter, query string, omit ...uint) ([]*fleet.Label, error)
type LabelIDsByNameFunc func(ctx context.Context, labels []string) ([]uint, error) type LabelIDsByNameFunc func(ctx context.Context, labels []string) (map[string]uint, error)
type AsyncBatchInsertLabelMembershipFunc func(ctx context.Context, batch [][2]uint) error type AsyncBatchInsertLabelMembershipFunc func(ctx context.Context, batch [][2]uint) error
@ -2422,7 +2422,7 @@ func (s *DataStore) SearchLabels(ctx context.Context, filter fleet.TeamFilter, q
return s.SearchLabelsFunc(ctx, filter, query, omit...) return s.SearchLabelsFunc(ctx, filter, query, omit...)
} }
func (s *DataStore) LabelIDsByName(ctx context.Context, labels []string) ([]uint, error) { func (s *DataStore) LabelIDsByName(ctx context.Context, labels []string) (map[string]uint, error) {
s.mu.Lock() s.mu.Lock()
s.LabelIDsByNameFuncInvoked = true s.LabelIDsByNameFuncInvoked = true
s.mu.Unlock() s.mu.Unlock()

View File

@ -15,7 +15,6 @@ import (
"net/url" "net/url"
"github.com/fleetdm/fleet/v4/pkg/rawjson" "github.com/fleetdm/fleet/v4/pkg/rawjson"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz" authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
@ -676,7 +675,7 @@ func (svc *Service) validateMDM(
// we want to use `oldMdm` here as this boolean is set by the fleet // we want to use `oldMdm` here as this boolean is set by the fleet
// server at startup and can't be modified by the user // server at startup and can't be modified by the user
if !oldMdm.EnabledAndConfigured { if !oldMdm.EnabledAndConfigured {
if len(mdm.MacOSSettings.CustomSettings) > 0 && !server.SliceStringsMatch(mdm.MacOSSettings.CustomSettings, oldMdm.MacOSSettings.CustomSettings) { if len(mdm.MacOSSettings.CustomSettings) > 0 && !fleet.MDMProfileSpecsMatch(mdm.MacOSSettings.CustomSettings, oldMdm.MacOSSettings.CustomSettings) {
invalid.Append("macos_settings.custom_settings", invalid.Append("macos_settings.custom_settings",
`Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`) `Couldn't update macos_settings because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
} }
@ -699,7 +698,7 @@ func (svc *Service) validateMDM(
if !mdm.WindowsEnabledAndConfigured { if !mdm.WindowsEnabledAndConfigured {
if mdm.WindowsSettings.CustomSettings.Set && if mdm.WindowsSettings.CustomSettings.Set &&
len(mdm.WindowsSettings.CustomSettings.Value) > 0 && len(mdm.WindowsSettings.CustomSettings.Value) > 0 &&
!server.SliceStringsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) { !fleet.MDMProfileSpecsMatch(mdm.WindowsSettings.CustomSettings.Value, oldMdm.WindowsSettings.CustomSettings.Value) {
invalid.Append("windows_settings.custom_settings", invalid.Append("windows_settings.custom_settings",
`Couldnt edit windows_settings.custom_settings. Windows MDM isnt turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`) `Couldnt edit windows_settings.custom_settings. Windows MDM isnt turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)
} }

View File

@ -814,7 +814,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, },
}, { }, {
name: "newDefaultTeamNoLicense", name: "newDefaultTeamNoLicense",
@ -843,7 +845,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, },
}, { }, {
name: "foundEdit", name: "foundEdit",
@ -857,7 +861,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, },
}, { }, {
name: "ssoFree", name: "ssoFree",
@ -877,7 +883,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, },
}, { }, {
name: "ssoAllFields", name: "ssoAllFields",
@ -900,7 +908,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false}, EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}}, WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, },
}, { }, {
name: "ssoShortEntityID", name: "ssoShortEntityID",

View File

@ -298,7 +298,8 @@ func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{},
return &newMDMAppleConfigProfileResponse{Err: err}, nil return &newMDMAppleConfigProfileResponse{Err: err}, nil
} }
defer ff.Close() defer ff.Close()
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff) // providing an empty set of labels since this endpoint is only maintained for backwards compat
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, nil)
if err != nil { if err != nil {
return &newMDMAppleConfigProfileResponse{Err: err}, nil return &newMDMAppleConfigProfileResponse{Err: err}, nil
} }
@ -307,7 +308,7 @@ func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{},
}, nil }, nil
} }
func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader) (*fleet.MDMAppleConfigProfile, error) { func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader, labels []string) (*fleet.MDMAppleConfigProfile, error) {
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil { if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err) return nil, ctxerr.Wrap(ctx, err)
} }
@ -347,6 +348,12 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()}) return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
} }
labelMap, err := svc.validateProfileLabels(ctx, labels)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating labels")
}
cp.Labels = labelMap
newCP, err := svc.ds.NewMDMAppleConfigProfile(ctx, *cp) newCP, err := svc.ds.NewMDMAppleConfigProfile(ctx, *cp)
if err != nil { if err != nil {
var existsErr existsErrorInterface var existsErr existsErrorInterface

View File

@ -593,11 +593,11 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// test authz create new profile (no team) // test authz create new profile (no team)
_, err := svc.NewMDMAppleConfigProfile(ctx, 0, bytes.NewReader(mcBytes)) _, err := svc.NewMDMAppleConfigProfile(ctx, 0, bytes.NewReader(mcBytes), nil)
checkShouldFail(err, tt.shouldFailGlobal) checkShouldFail(err, tt.shouldFailGlobal)
// test authz create new profile (team 1) // test authz create new profile (team 1)
_, err = svc.NewMDMAppleConfigProfile(ctx, 1, bytes.NewReader(mcBytes)) _, err = svc.NewMDMAppleConfigProfile(ctx, 1, bytes.NewReader(mcBytes), nil)
checkShouldFail(err, tt.shouldFailTeam) checkShouldFail(err, tt.shouldFailTeam)
// test authz list profiles (no team) // test authz list profiles (no team)
@ -659,7 +659,7 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
return nil return nil
} }
cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r) cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "Foo", cp.Name) require.Equal(t, "Foo", cp.Name)
require.Equal(t, "Bar", cp.Identifier) require.Equal(t, "Bar", cp.Identifier)

View File

@ -206,11 +206,16 @@ func (svc *Service) NewDistributedQueryCampaignByNames(ctx context.Context, quer
return nil, ctxerr.Wrap(ctx, err, "finding host IDs") return nil, ctxerr.Wrap(ctx, err, "finding host IDs")
} }
labelIDs, err := svc.ds.LabelIDsByName(ctx, labels) labelMap, err := svc.ds.LabelIDsByName(ctx, labels)
if err != nil { if err != nil {
return nil, ctxerr.Wrap(ctx, err, "finding label IDs") return nil, ctxerr.Wrap(ctx, err, "finding label IDs")
} }
var labelIDs []uint
for _, labelID := range labelMap {
labelIDs = append(labelIDs, labelID)
}
targets := fleet.HostTargets{HostIDs: hostIDs, LabelIDs: labelIDs} targets := fleet.HostTargets{HostIDs: hostIDs, LabelIDs: labelIDs}
return svc.NewDistributedQueryCampaign(ctx, queryString, queryID, targets) return svc.NewDistributedQueryCampaign(ctx, queryString, queryID, targets)
} }

View File

@ -77,7 +77,7 @@ func TestLiveQueryAuth(t *testing.T) {
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, names []string) ([]uint, error) { ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, names []string) ([]uint, error) {
return nil, nil return nil, nil
} }
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) ([]uint, error) { ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
return nil, nil return nil, nil
} }
ds.CountHostsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets, now time.Time) (fleet.TargetMetrics, error) { ds.CountHostsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets, now time.Time) (fleet.TargetMetrics, error) {

View File

@ -279,34 +279,40 @@ func (c *Client) runAppConfigChecks(fn func(ac *fleet.EnrichedAppConfig) error)
return fn(appCfg) return fn(appCfg)
} }
// getProfilesContents takes file paths and creates a map of profile contents // getProfilesContents takes file paths and creates a slice of profile payloads
// keyed by the name of the profile (the file name on Windows, // ready to batch-apply.
// PayloadDisplayName on macOS) func getProfilesContents(baseDir string, profiles []fleet.MDMProfileSpec) ([]fleet.MDMProfileBatchPayload, error) {
func getProfilesContents(baseDir string, paths []string) (map[string][]byte, error) { fileNameMap := make(map[string]struct{}, len(profiles))
files := resolveApplyRelativePaths(baseDir, paths) result := make([]fleet.MDMProfileBatchPayload, 0, len(profiles))
fileContents := make(map[string][]byte, len(files))
for _, f := range files { for _, profile := range profiles {
b, err := os.ReadFile(f) filePath := resolveApplyRelativePath(baseDir, profile.Path)
fileContents, err := os.ReadFile(filePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("applying fleet config: %w", err) return nil, fmt.Errorf("applying fleet config: %w", err)
} }
// by default, use the file name. macOS profiles use their PayloadDisplayName // by default, use the file name. macOS profiles use their PayloadDisplayName
name := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)) name := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if mdm.GetRawProfilePlatform(b) == "darwin" { if mdm.GetRawProfilePlatform(fileContents) == "darwin" {
mc, err := fleet.NewMDMAppleConfigProfile(b, nil) mc, err := fleet.NewMDMAppleConfigProfile(fileContents, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("applying fleet config: %w", err) return nil, fmt.Errorf("applying fleet config: %w", err)
} }
name = strings.TrimSpace(mc.Name) name = strings.TrimSpace(mc.Name)
} }
if _, isDuplicate := fileContents[name]; isDuplicate { if _, isDuplicate := fileNameMap[name]; isDuplicate {
return nil, errors.New("Couldn't edit windows_settings.custom_settings. More than one configuration profile have the same name (Windows .xml file name or macOS PayloadDisplayName).") return nil, errors.New("Couldn't edit windows_settings.custom_settings. More than one configuration profile have the same name (Windows .xml file name or macOS PayloadDisplayName).")
} }
fileContents[name] = b fileNameMap[name] = struct{}{}
} result = append(result, fleet.MDMProfileBatchPayload{
Name: name,
Contents: fileContents,
Labels: profile.Labels,
})
return fileContents, nil }
return result, nil
} }
// ApplyGroup applies the given spec group to Fleet. // ApplyGroup applies the given spec group to Fleet.
@ -384,7 +390,14 @@ func (c *Client) ApplyGroup(
macosCustomSettings := extractAppCfgMacOSCustomSettings(specs.AppConfig) macosCustomSettings := extractAppCfgMacOSCustomSettings(specs.AppConfig)
allCustomSettings := append(macosCustomSettings, windowsCustomSettings...) allCustomSettings := append(macosCustomSettings, windowsCustomSettings...)
if len(allCustomSettings) > 0 { // if there is no custom setting but the windows and mac settings are
// non-nil, this means that we want to clear the existing custom settings,
// so we still go on with calling the batch-apply endpoint.
//
// TODO(mna): shouldn't that be an || instead of && ? I.e. if there are no
// custom settings but windows is present and empty (but mac is absent),
// shouldn't that clear the windows ones?
if (windowsCustomSettings != nil && macosCustomSettings != nil) || len(allCustomSettings) > 0 {
fileContents, err := getProfilesContents(baseDir, allCustomSettings) fileContents, err := getProfilesContents(baseDir, allCustomSettings)
if err != nil { if err != nil {
return err return err
@ -461,7 +474,7 @@ func (c *Client) ApplyGroup(
// that any non-existing file error is found before applying the specs. // that any non-existing file error is found before applying the specs.
tmMDMSettings := extractTmSpecsMDMCustomSettings(specs.Teams) tmMDMSettings := extractTmSpecsMDMCustomSettings(specs.Teams)
tmFileContents := make(map[string]map[string][]byte, len(tmMDMSettings)) tmFileContents := make(map[string][]fleet.MDMProfileBatchPayload, len(tmMDMSettings))
for k, paths := range tmMDMSettings { for k, paths := range tmMDMSettings {
fileContents, err := getProfilesContents(baseDir, paths) fileContents, err := getProfilesContents(baseDir, paths)
if err != nil { if err != nil {
@ -606,7 +619,7 @@ func resolveApplyRelativePaths(baseDir string, paths []string) []string {
return resolved return resolved
} }
func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string { func extractAppCfgCustomSettings(appCfg interface{}, platformKey string) []fleet.MDMProfileSpec {
asMap, ok := appCfg.(map[string]interface{}) asMap, ok := appCfg.(map[string]interface{})
if !ok { if !ok {
return nil return nil
@ -615,7 +628,7 @@ func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string {
if !ok { if !ok {
return nil return nil
} }
mos, ok := mmdm["macos_settings"].(map[string]interface{}) mos, ok := mmdm[platformKey].(map[string]interface{})
if !ok || mos == nil { if !ok || mos == nil {
return nil return nil
} }
@ -630,54 +643,46 @@ func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string {
if !ok || csAny == nil { if !ok || csAny == nil {
// return a non-nil, empty slice instead, so the caller knows that the // return a non-nil, empty slice instead, so the caller knows that the
// custom_settings key was actually provided. // custom_settings key was actually provided.
return []string{} return []fleet.MDMProfileSpec{}
} }
csStrings := make([]string, 0, len(csAny)) csSpecs := make([]fleet.MDMProfileSpec, 0, len(csAny))
for _, v := range csAny { for _, v := range csAny {
s, _ := v.(string) if m, ok := v.(map[string]interface{}); ok {
if s != "" { var profSpec fleet.MDMProfileSpec
csStrings = append(csStrings, s)
// extract the Path field
if path, ok := m["path"].(string); ok {
profSpec.Path = path
}
// extract the Labels field, labels are cleared if not provided
if labels, ok := m["labels"].([]interface{}); ok {
for _, label := range labels {
if strLabel, ok := label.(string); ok {
profSpec.Labels = append(profSpec.Labels, strLabel)
}
}
}
if profSpec.Path != "" {
csSpecs = append(csSpecs, profSpec)
}
} else if m, ok := v.(string); ok { // for backwards compatibility with the old way to define profiles
if m != "" {
csSpecs = append(csSpecs, fleet.MDMProfileSpec{Path: m})
}
} }
} }
return csStrings return csSpecs
} }
func extractAppCfgWindowsCustomSettings(appCfg interface{}) []string { func extractAppCfgMacOSCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
asMap, ok := appCfg.(map[string]interface{}) return extractAppCfgCustomSettings(appCfg, "macos_settings")
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"] func extractAppCfgWindowsCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
if !ok { return extractAppCfgCustomSettings(appCfg, "windows_settings")
// 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 { func extractAppCfgScripts(appCfg interface{}) []string {
@ -710,8 +715,8 @@ func extractAppCfgScripts(appCfg interface{}) []string {
} }
// returns the custom macOS and Windows settings keyed by team name. // returns the custom macOS and Windows settings keyed by team name.
func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]string { func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]fleet.MDMProfileSpec {
var m map[string][]string var m map[string][]fleet.MDMProfileSpec
for _, tm := range tmSpecs { for _, tm := range tmSpecs {
var spec struct { var spec struct {
Name string `json:"name"` Name string `json:"name"`
@ -729,15 +734,15 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]str
continue continue
} }
if spec.Name != "" { if spec.Name != "" {
var macOSSettings []string var macOSSettings []fleet.MDMProfileSpec
var windowsSettings []string var windowsSettings []fleet.MDMProfileSpec
// to keep existing bahavior, if any of the custom // to keep existing bahavior, if any of the custom
// settings is provided, make the map a non-nil map // settings is provided, make the map a non-nil map
if len(spec.MDM.MacOSSettings.CustomSettings) > 0 || if len(spec.MDM.MacOSSettings.CustomSettings) > 0 ||
len(spec.MDM.WindowsSettings.CustomSettings) > 0 { len(spec.MDM.WindowsSettings.CustomSettings) > 0 {
if m == nil { if m == nil {
m = make(map[string][]string) m = make(map[string][]fleet.MDMProfileSpec)
} }
} }
@ -749,7 +754,7 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]str
if macOSSettings == nil { if macOSSettings == nil {
// to be consistent with the AppConfig custom settings, set it to an // to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty. // empty slice if the provided custom settings are present but empty.
macOSSettings = []string{} macOSSettings = []fleet.MDMProfileSpec{}
} }
} }
if len(spec.MDM.WindowsSettings.CustomSettings) > 0 { if len(spec.MDM.WindowsSettings.CustomSettings) > 0 {
@ -760,10 +765,11 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]str
if windowsSettings == nil { if windowsSettings == nil {
// to be consistent with the AppConfig custom settings, set it to an // to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty. // empty slice if the provided custom settings are present but empty.
windowsSettings = []string{} windowsSettings = []fleet.MDMProfileSpec{}
} }
} }
// TODO: validate equal names here and API?
if macOSSettings != nil || windowsSettings != nil { if macOSSettings != nil || windowsSettings != nil {
m[spec.Name] = append(macOSSettings, windowsSettings...) m[spec.Name] = append(macOSSettings, windowsSettings...)
} }

View File

@ -14,7 +14,7 @@ func (c *Client) ApplyAppConfig(payload interface{}, opts fleet.ApplySpecOptions
// ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts // ApplyNoTeamProfiles sends the list of profiles to be applied for the hosts
// in no team. // in no team.
func (c *Client) ApplyNoTeamProfiles(profiles map[string][]byte, opts fleet.ApplySpecOptions) error { func (c *Client) ApplyNoTeamProfiles(profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions) error {
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch" verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery()) return c.authenticatedRequestWithQuery(map[string]interface{}{"profiles": profiles}, verb, path, nil, opts.RawQuery())
} }

View File

@ -64,7 +64,7 @@ func (c *Client) ApplyTeams(specs []json.RawMessage, opts fleet.ApplySpecOptions
// ApplyTeamProfiles sends the list of profiles to be applied for the specified // ApplyTeamProfiles sends the list of profiles to be applied for the specified
// team. // team.
func (c *Client) ApplyTeamProfiles(tmName string, profiles map[string][]byte, opts fleet.ApplySpecOptions) error { func (c *Client) ApplyTeamProfiles(tmName string, profiles []fleet.MDMProfileBatchPayload, opts fleet.ApplySpecOptions) error {
verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch" verb, path := "POST", "/api/latest/fleet/mdm/profiles/batch"
query, err := url.ParseQuery(opts.RawQuery()) query, err := url.ParseQuery(opts.RawQuery())
if err != nil { if err != nil {

View File

@ -1,12 +1,12 @@
package service package service
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/fleetdm/fleet/v4/pkg/spec" "github.com/fleetdm/fleet/v4/pkg/spec"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -14,7 +14,7 @@ func TestExtractAppConfigMacOSCustomSettings(t *testing.T) {
cases := []struct { cases := []struct {
desc string desc string
yaml string yaml string
want []string want []fleet.MDMProfileSpec
}{ }{
{ {
"no settings", "no settings",
@ -50,7 +50,7 @@ spec:
macos_settings: macos_settings:
custom_settings: custom_settings:
`, `,
[]string{}, []fleet.MDMProfileSpec{},
}, },
{ {
"custom settings specified", "custom settings specified",
@ -63,16 +63,61 @@ spec:
mdm: mdm:
macos_settings: macos_settings:
custom_settings: custom_settings:
- "a" - path: "a"
- "b" labels:
- "foo"
- bar
- path: "b"
`, `,
[]string{"a", "b"}, []fleet.MDMProfileSpec{{Path: "a", Labels: []string{"foo", "bar"}}, {Path: "b"}},
}, },
{ {
"empty and invalid custom settings", "empty and invalid custom settings",
` `
apiVersion: v1 apiVersion: v1
kind: config kind: config
spec:
org_info:
org_name: "Fleet"
mdm:
macos_settings:
custom_settings:
- path: "a"
labels:
- path: ""
labels:
- "foo"
- path: 4
labels:
- "foo"
- "bar"
- path: "c"
labels:
- baz
`,
[]fleet.MDMProfileSpec{{Path: "a"}, {Path: "c", Labels: []string{"baz"}}},
},
{
"old custom settings specified",
`
apiVersion: v1
kind: config
spec:
org_info:
org_name: "Fleet"
mdm:
macos_settings:
custom_settings:
- "a"
- "b"
`,
[]fleet.MDMProfileSpec{{Path: "a"}, {Path: "b"}},
},
{
"old empty and invalid custom settings",
`
apiVersion: v1
kind: config
spec: spec:
org_info: org_info:
org_name: "Fleet" org_name: "Fleet"
@ -84,7 +129,7 @@ spec:
- 4 - 4
- "c" - "c"
`, `,
[]string{"a", "c"}, []fleet.MDMProfileSpec{{Path: "a"}, {Path: "c"}},
}, },
} }
for _, c := range cases { for _, c := range cases {
@ -103,7 +148,7 @@ func TestExtractAppConfigWindowsCustomSettings(t *testing.T) {
cases := []struct { cases := []struct {
desc string desc string
yaml string yaml string
want []string want []fleet.MDMProfileSpec
}{ }{
{ {
"no settings", "no settings",
@ -139,7 +184,7 @@ spec:
windows_settings: windows_settings:
custom_settings: custom_settings:
`, `,
[]string{}, []fleet.MDMProfileSpec{},
}, },
{ {
"custom settings specified", "custom settings specified",
@ -152,16 +197,61 @@ spec:
mdm: mdm:
windows_settings: windows_settings:
custom_settings: custom_settings:
- "a" - path: "a"
- "b" labels:
- "foo"
- bar
- path: "b"
`, `,
[]string{"a", "b"}, []fleet.MDMProfileSpec{{Path: "a", Labels: []string{"foo", "bar"}}, {Path: "b"}},
}, },
{ {
"empty and invalid custom settings", "empty and invalid custom settings",
` `
apiVersion: v1 apiVersion: v1
kind: config kind: config
spec:
org_info:
org_name: "Fleet"
mdm:
windows_settings:
custom_settings:
- path: "a"
labels:
- path: ""
labels:
- "foo"
- path: 4
labels:
- "foo"
- "bar"
- path: "c"
labels:
- baz
`,
[]fleet.MDMProfileSpec{{Path: "a"}, {Path: "c", Labels: []string{"baz"}}},
},
{
"old custom settings specified",
`
apiVersion: v1
kind: config
spec:
org_info:
org_name: "Fleet"
mdm:
windows_settings:
custom_settings:
- "a"
- "b"
`,
[]fleet.MDMProfileSpec{{Path: "a"}, {Path: "b"}},
},
{
"old empty and invalid custom settings",
`
apiVersion: v1
kind: config
spec: spec:
org_info: org_info:
org_name: "Fleet" org_name: "Fleet"
@ -173,7 +263,7 @@ spec:
- 4 - 4
- "c" - "c"
`, `,
[]string{"a", "c"}, []fleet.MDMProfileSpec{{Path: "a"}, {Path: "c"}},
}, },
} }
for _, c := range cases { for _, c := range cases {
@ -192,7 +282,7 @@ func TestExtractTeamSpecsMDMCustomSettings(t *testing.T) {
cases := []struct { cases := []struct {
desc string desc string
yaml string yaml string
want map[string][]string want map[string][]fleet.MDMProfileSpec
}{ }{
{ {
"no settings", "no settings",
@ -252,13 +342,44 @@ spec:
windows_settings: windows_settings:
custom_settings: custom_settings:
`, `,
map[string][]string{"Fleet": {}, "Fleet2": {}}, map[string][]fleet.MDMProfileSpec{"Fleet": {}, "Fleet2": {}},
}, },
{ {
"custom settings specified", "custom settings specified",
` `
apiVersion: v1 apiVersion: v1
kind: team kind: team
spec:
team:
name: "Fleet"
mdm:
macos_settings:
custom_settings:
- path: "a"
labels:
- "foo"
- bar
- path: "b"
windows_settings:
custom_settings:
- path: "c"
- path: "d"
labels:
- "foo"
- baz
`,
map[string][]fleet.MDMProfileSpec{"Fleet": {
{Path: "a", Labels: []string{"foo", "bar"}},
{Path: "b"},
{Path: "c"},
{Path: "d", Labels: []string{"foo", "baz"}},
}},
},
{
"old custom settings specified",
`
apiVersion: v1
kind: team
spec: spec:
team: team:
name: "Fleet" name: "Fleet"
@ -272,13 +393,43 @@ spec:
- "c" - "c"
- "d" - "d"
`, `,
map[string][]string{"Fleet": {"a", "b", "c", "d"}}, map[string][]fleet.MDMProfileSpec{"Fleet": {{Path: "a"}, {Path: "b"}, {Path: "c"}, {Path: "d"}}},
}, },
{ {
"invalid custom settings", "invalid custom settings",
` `
apiVersion: v1 apiVersion: v1
kind: team kind: team
spec:
team:
name: "Fleet"
mdm:
macos_settings:
custom_settings:
- path: "a"
labels:
- "y"
- path: ""
- path: 42
labels:
- "x"
- path: "c"
windows_settings:
custom_settings:
- path: "x"
- path: ""
labels:
- "x"
- path: 24
- path: "y"
`,
map[string][]fleet.MDMProfileSpec{},
},
{
"old invalid custom settings",
`
apiVersion: v1
kind: team
spec: spec:
team: team:
name: "Fleet" name: "Fleet"
@ -296,7 +447,7 @@ spec:
- 24 - 24
- "y" - "y"
`, `,
map[string][]string{}, map[string][]fleet.MDMProfileSpec{},
}, },
} }
for _, c := range cases { for _, c := range cases {
@ -336,13 +487,16 @@ func TestExtractFilenameFromPath(t *testing.T) {
func TestGetProfilesContents(t *testing.T) { func TestGetProfilesContents(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
darwinProfile := mobileconfigForTest("bar", "I")
windowsProfile := syncMLForTest("./some/path")
tests := []struct { tests := []struct {
name string name string
baseDir string baseDir string
setupFiles [][2]string setupFiles [][2]string
expectError bool labels []string
expectedKeys []string expectError bool
want []fleet.MDMProfileBatchPayload
}{ }{
{ {
name: "invalid darwin xml", name: "invalid darwin xml",
@ -350,34 +504,54 @@ func TestGetProfilesContents(t *testing.T) {
setupFiles: [][2]string{ setupFiles: [][2]string{
{"foo.mobileconfig", `<?xml version="1.0" encoding="UTF-8"?>`}, {"foo.mobileconfig", `<?xml version="1.0" encoding="UTF-8"?>`},
}, },
expectError: true, expectError: true,
expectedKeys: []string{"foo"}, want: []fleet.MDMProfileBatchPayload{{Name: "foo"}},
}, },
{ {
name: "windows and darwin files", name: "windows and darwin files",
baseDir: tempDir, baseDir: tempDir,
setupFiles: [][2]string{ setupFiles: [][2]string{
{"foo.xml", string(syncMLForTest("./some/path"))}, {"foo.xml", string(windowsProfile)},
{"bar.mobileconfig", string(mobileconfigForTest("bar", "I"))}, {"bar.mobileconfig", string(darwinProfile)},
},
expectError: false,
want: []fleet.MDMProfileBatchPayload{
{Name: "foo", Contents: windowsProfile},
{Name: "bar", Contents: darwinProfile},
},
},
{
name: "windows and darwin files with labels",
baseDir: tempDir,
setupFiles: [][2]string{
{"foo.xml", string(windowsProfile)},
{"bar.mobileconfig", string(darwinProfile)},
},
labels: []string{"foo", "bar"},
expectError: false,
want: []fleet.MDMProfileBatchPayload{
{Name: "foo", Contents: windowsProfile, Labels: []string{"foo", "bar"}},
{Name: "bar", Contents: darwinProfile, Labels: []string{"foo", "bar"}},
}, },
expectError: false,
expectedKeys: []string{"foo", "bar"},
}, },
{ {
name: "darwin files with file name != PayloadDisplayName", name: "darwin files with file name != PayloadDisplayName",
baseDir: tempDir, baseDir: tempDir,
setupFiles: [][2]string{ setupFiles: [][2]string{
{"foo.xml", string(syncMLForTest("./some/path"))}, {"foo.xml", string(windowsProfile)},
{"bar.mobileconfig", string(mobileconfigForTest("fizz", "I"))}, {"bar.mobileconfig", string(darwinProfile)},
},
expectError: false,
want: []fleet.MDMProfileBatchPayload{
{Name: "foo", Contents: windowsProfile},
{Name: "bar", Contents: darwinProfile},
}, },
expectError: false,
expectedKeys: []string{"foo", "fizz"},
}, },
{ {
name: "duplicate names across windows and darwin", name: "duplicate names across windows and darwin",
baseDir: tempDir, baseDir: tempDir,
setupFiles: [][2]string{ setupFiles: [][2]string{
{"baz.xml", string(syncMLForTest("./some/path"))}, {"baz.xml", string(windowsProfile)},
{"bar.mobileconfig", string(mobileconfigForTest("baz", "I"))}, {"bar.mobileconfig", string(mobileconfigForTest("baz", "I"))},
}, },
expectError: true, expectError: true,
@ -386,8 +560,8 @@ func TestGetProfilesContents(t *testing.T) {
name: "duplicate file names", name: "duplicate file names",
baseDir: tempDir, baseDir: tempDir,
setupFiles: [][2]string{ setupFiles: [][2]string{
{"baz.xml", string(syncMLForTest("./some/path"))}, {"baz.xml", string(windowsProfile)},
{"baz.xml", string(syncMLForTest("./some/path"))}, {"baz.xml", string(windowsProfile)},
}, },
expectError: true, expectError: true,
}, },
@ -395,11 +569,11 @@ func TestGetProfilesContents(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
paths := []string{} paths := []fleet.MDMProfileSpec{}
for _, fileSpec := range tt.setupFiles { for _, fileSpec := range tt.setupFiles {
filePath := filepath.Join(tempDir, fileSpec[0]) filePath := filepath.Join(tempDir, fileSpec[0])
require.NoError(t, os.WriteFile(filePath, []byte(fileSpec[1]), 0644)) require.NoError(t, os.WriteFile(filePath, []byte(fileSpec[1]), 0644))
paths = append(paths, filePath) paths = append(paths, fleet.MDMProfileSpec{Path: filePath, Labels: tt.labels})
} }
profileContents, err := getProfilesContents(tt.baseDir, paths) profileContents, err := getProfilesContents(tt.baseDir, paths)
@ -409,11 +583,8 @@ func TestGetProfilesContents(t *testing.T) {
} else { } else {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, profileContents) require.NotNil(t, profileContents)
require.Len(t, profileContents, len(tt.expectedKeys)) require.Len(t, profileContents, len(tt.want))
for _, key := range tt.expectedKeys { require.ElementsMatch(t, tt.want, profileContents)
_, exists := profileContents[key]
require.True(t, exists, fmt.Sprintf("Expected key %s not found", key))
}
} }
}) })
} }

View File

@ -3713,7 +3713,7 @@ func (s *integrationTestSuite) TestListHostsByLabel() {
lblIDs, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"}) lblIDs, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, lblIDs, 1) require.Len(t, lblIDs, 1)
labelID := lblIDs[0] labelID := lblIDs["All Hosts"]
hosts := s.createHosts(t, "darwin") hosts := s.createHosts(t, "darwin")
host := hosts[0] host := hosts[0]
@ -5344,8 +5344,8 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusPaymentRequired, &scriptResultResp) s.DoJSON("GET", "/api/latest/fleet/scripts/results/test-id", nil, http.StatusPaymentRequired, &scriptResultResp)
// create a saved script // create a saved script
body, headers := generateNewScriptMultipartRequest(t, nil, body, headers := generateNewScriptMultipartRequest(t,
"myscript.sh", []byte(`echo "hello"`), s.token) "myscript.sh", []byte(`echo "hello"`), s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusPaymentRequired, headers) s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusPaymentRequired, headers)
// delete a saved script // delete a saved script
@ -6160,9 +6160,9 @@ func (s *integrationTestSuite) TestSearchTargets() {
hosts := s.createHosts(t) hosts := s.createHosts(t)
lblIDs, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"}) lblMap, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, lblIDs, 1) require.Len(t, lblMap, 1)
// no search criteria // no search criteria
var searchResp searchTargetsResponse var searchResp searchTargetsResponse
@ -6172,6 +6172,11 @@ func (s *integrationTestSuite) TestSearchTargets() {
require.Len(t, searchResp.Targets.Labels, 1) require.Len(t, searchResp.Targets.Labels, 1)
require.Len(t, searchResp.Targets.Teams, 0) require.Len(t, searchResp.Targets.Teams, 0)
var lblIDs []uint
for _, labelID := range lblMap {
lblIDs = append(lblIDs, labelID)
}
searchResp = searchTargetsResponse{} searchResp = searchTargetsResponse{}
s.DoJSON("POST", "/api/latest/fleet/targets", searchTargetsRequest{Selected: fleet.HostTargets{LabelIDs: lblIDs}}, http.StatusOK, &searchResp) s.DoJSON("POST", "/api/latest/fleet/targets", searchTargetsRequest{Selected: fleet.HostTargets{LabelIDs: lblIDs}}, http.StatusOK, &searchResp)
require.Equal(t, uint(0), searchResp.TargetsCount) require.Equal(t, uint(0), searchResp.TargetsCount)
@ -6272,12 +6277,12 @@ func (s *integrationTestSuite) TestCountTargets() {
hosts := s.createHosts(t) hosts := s.createHosts(t)
lblIDs, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"}) lblMap, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, lblIDs, 1) require.Len(t, lblMap, 1)
for i := range hosts { for i := range hosts {
err = s.ds.RecordLabelQueryExecutions(context.Background(), hosts[i], map[uint]*bool{lblIDs[0]: ptr.Bool(true)}, time.Now(), false) err = s.ds.RecordLabelQueryExecutions(context.Background(), hosts[i], map[uint]*bool{lblMap["All Hosts"]: ptr.Bool(true)}, time.Now(), false)
require.NoError(t, err) require.NoError(t, err)
} }
@ -6299,6 +6304,10 @@ func (s *integrationTestSuite) TestCountTargets() {
require.Equal(t, uint(0), countResp.TargetsOnline) require.Equal(t, uint(0), countResp.TargetsOnline)
require.Equal(t, uint(0), countResp.TargetsOffline) require.Equal(t, uint(0), countResp.TargetsOffline)
var lblIDs []uint
for _, labelID := range lblMap {
lblIDs = append(lblIDs, labelID)
}
// all hosts label selected // all hosts label selected
countResp = countTargetsResponse{} countResp = countTargetsResponse{}
s.DoJSON("POST", "/api/latest/fleet/targets/count", countTargetsRequest{Selected: fleet.HostTargets{LabelIDs: lblIDs}}, http.StatusOK, &countResp) s.DoJSON("POST", "/api/latest/fleet/targets/count", countTargetsRequest{Selected: fleet.HostTargets{LabelIDs: lblIDs}}, http.StatusOK, &countResp)
@ -6933,7 +6942,7 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
lids, err := s.ds.LabelIDsByName(context.Background(), []string{t.Name()}) lids, err := s.ds.LabelIDsByName(context.Background(), []string{t.Name()})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, lids, 1) require.Len(t, lids, 1)
customLabelID := lids[0] customLabelID := lids[t.Name()]
// create a policy and make host[1] fail that policy // create a policy and make host[1] fail that policy
pol, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{Name: t.Name(), Query: "SELECT 1"}) pol, err := s.ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{Name: t.Name(), Query: "SELECT 1"})

View File

@ -148,7 +148,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
// it did get marshalled, and then when unmarshalled it was set (but // it did get marshalled, and then when unmarshalled it was set (but
// empty). // empty).
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
}, },
}, team.Config.MDM) }, team.Config.MDM)
@ -206,7 +206,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
BootstrapPackage: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true},
}, },
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
}, },
}, team.Config.MDM) }, team.Config.MDM)
@ -227,7 +227,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
BootstrapPackage: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true},
}, },
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
}, },
}, getTmResp.Team.Config.MDM) }, getTmResp.Team.Config.MDM)
@ -250,7 +250,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
BootstrapPackage: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true},
}, },
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
}, },
}, listTmResp.Teams[0].Config.MDM) }, listTmResp.Teams[0].Config.MDM)
@ -1911,7 +1911,7 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() {
BootstrapPackage: optjson.String{Set: true}, BootstrapPackage: optjson.String{Set: true},
}, },
WindowsSettings: fleet.WindowsSettings{ WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}, CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
}, },
}, getTmResp.Team.Config.MDM) }, getTmResp.Team.Config.MDM)
@ -4998,8 +4998,8 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
// create a saved script for no team // create a saved script for no team
var newScriptResp createScriptResponse var newScriptResp createScriptResponse
body, headers := generateNewScriptMultipartRequest(t, nil, body, headers := generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token) "script1.sh", []byte(`echo "hello"`), s.token, nil)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) res := s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err := json.NewDecoder(res.Body).Decode(&newScriptResp) err := json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err) require.NoError(t, err)
@ -5031,50 +5031,50 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID+999), nil, http.StatusNotFound, &getScriptResp, "alt", "media") s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/scripts/%d", noTeamScriptID+999), nil, http.StatusNotFound, &getScriptResp, "alt", "media")
// file name is empty // file name is empty
body, headers = generateNewScriptMultipartRequest(t, nil, body, headers = generateNewScriptMultipartRequest(t,
"", []byte(`echo "hello"`), s.token) "", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusBadRequest, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusBadRequest, headers)
errMsg := extractServerErrorText(res.Body) errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "no file headers for script") require.Contains(t, errMsg, "no file headers for script")
// file name is not .sh // file name is not .sh
body, headers = generateNewScriptMultipartRequest(t, nil, body, headers = generateNewScriptMultipartRequest(t,
"not_sh.txt", []byte(`echo "hello"`), s.token) "not_sh.txt", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh and .ps1 file type is allowed.") require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh and .ps1 file type is allowed.")
// file content is empty // file content is empty
body, headers = generateNewScriptMultipartRequest(t, nil, body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(``), s.token) "script2.sh", []byte(``), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script contents must not be empty") require.Contains(t, errMsg, "Script contents must not be empty")
// file content is too large // file content is too large
body, headers = generateNewScriptMultipartRequest(t, nil, body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(strings.Repeat("a", 10001)), s.token) "script2.sh", []byte(strings.Repeat("a", 10001)), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script is too large. It's limited to 10,000 characters") require.Contains(t, errMsg, "Script is too large. It's limited to 10,000 characters")
// invalid hashbang // invalid hashbang
body, headers = generateNewScriptMultipartRequest(t, nil, body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(`#!/bin/python`), s.token) "script2.sh", []byte(`#!/bin/python`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Interpreter not supported.") require.Contains(t, errMsg, "Interpreter not supported.")
// script already exists with this name for this no-team // script already exists with this name for this no-team
body, headers = generateNewScriptMultipartRequest(t, nil, body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token) "script1.sh", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A script with this name already exists") require.Contains(t, errMsg, "A script with this name already exists")
// team id does not exist // team id does not exist
body, headers = generateNewScriptMultipartRequest(t, ptr.Uint(123), body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token) "script1.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {"123"}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusNotFound, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusNotFound, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "The team does not exist.") require.Contains(t, errMsg, "The team does not exist.")
@ -5084,8 +5084,8 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
require.NoError(t, err) require.NoError(t, err)
// create with existing name for this time for a team // create with existing name for this time for a team
body, headers = generateNewScriptMultipartRequest(t, &tm.ID, body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "team"`), s.token) "script1.sh", []byte(`echo "team"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp) err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err) require.NoError(t, err)
@ -5095,8 +5095,9 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0) s.lastActivityMatches("added_script", fmt.Sprintf(`{"script_name": %q, "team_name": %q, "team_id": %d}`, "script1.sh", tm.Name, tm.ID), 0)
// create a windows script // create a windows script
body, headers = generateNewScriptMultipartRequest(t, &tm.ID, body, headers = generateNewScriptMultipartRequest(t,
"script2.ps1", []byte(`Write-Host "Hello, World!"`), s.token) "script2.ps1", []byte(`Write-Host "Hello, World!"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp) err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err) require.NoError(t, err)
@ -5124,15 +5125,17 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition")) require.Equal(t, fmt.Sprintf("attachment;filename=\"%s %s\"", time.Now().Format(time.DateOnly), "script1.sh"), res.Header.Get("Content-Disposition"))
// script already exists with this name for this team // script already exists with this name for this team
body, headers = generateNewScriptMultipartRequest(t, &tm.ID, body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token) "script1.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers)
errMsg = extractServerErrorText(res.Body) errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A script with this name already exists") require.Contains(t, errMsg, "A script with this name already exists")
// create with a different name for this team // create with a different name for this team
body, headers = generateNewScriptMultipartRequest(t, &tm.ID, body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(`echo "hello"`), s.token) "script2.sh", []byte(`echo "hello"`), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm.ID)}})
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&newScriptResp) err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err) require.NoError(t, err)
@ -5657,10 +5660,10 @@ VALUES
// generates the body and headers part of a multipart request ready to be // generates the body and headers part of a multipart request ready to be
// used via s.DoRawWithHeaders to POST /api/_version_/fleet/scripts. // used via s.DoRawWithHeaders to POST /api/_version_/fleet/scripts.
func generateNewScriptMultipartRequest(t *testing.T, tmID *uint, func generateNewScriptMultipartRequest(t *testing.T,
fileName string, fileContent []byte, token string, fileName string, fileContent []byte, token string, extraFields map[string][]string,
) (*bytes.Buffer, map[string]string) { ) (*bytes.Buffer, map[string]string) {
return generateMultipartRequest(t, tmID, "script", fileName, fileContent, token) return generateMultipartRequest(t, "script", fileName, fileContent, token, extraFields)
} }
func (s *integrationEnterpriseTestSuite) TestAppConfigScripts() { func (s *integrationEnterpriseTestSuite) TestAppConfigScripts() {

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
package service package service
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -1152,10 +1154,7 @@ func (svc *Service) DeleteMDMWindowsConfigProfile(ctx context.Context, profileUU
// returns the numeric Apple profile ID and true if it is an Apple identifier, // returns the numeric Apple profile ID and true if it is an Apple identifier,
// or 0 and false otherwise. // or 0 and false otherwise.
func isAppleProfileUUID(profileUUID string) bool { func isAppleProfileUUID(profileUUID string) bool {
if strings.HasPrefix(profileUUID, "a") { return strings.HasPrefix(profileUUID, "a")
return true
}
return false
} }
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
@ -1165,6 +1164,7 @@ func isAppleProfileUUID(profileUUID string) bool {
type newMDMConfigProfileRequest struct { type newMDMConfigProfileRequest struct {
TeamID uint TeamID uint
Profile *multipart.FileHeader Profile *multipart.FileHeader
Labels []string
} }
func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
@ -1178,6 +1178,7 @@ func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Req
} }
} }
// add team_id
val, ok := r.MultipartForm.Value["team_id"] val, ok := r.MultipartForm.Value["team_id"]
if !ok || len(val) < 1 { if !ok || len(val) < 1 {
// default is no team // default is no team
@ -1190,12 +1191,16 @@ func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Req
decoded.TeamID = uint(teamID) decoded.TeamID = uint(teamID)
} }
// add profile
fhs, ok := r.MultipartForm.File["profile"] fhs, ok := r.MultipartForm.File["profile"]
if !ok || len(fhs) < 1 { if !ok || len(fhs) < 1 {
return nil, &fleet.BadRequestError{Message: "no file headers for profile"} return nil, &fleet.BadRequestError{Message: "no file headers for profile"}
} }
decoded.Profile = fhs[0] decoded.Profile = fhs[0]
// add labels
decoded.Labels = r.MultipartForm.Value["labels"]
return &decoded, nil return &decoded, nil
} }
@ -1217,7 +1222,7 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
fileExt := filepath.Ext(req.Profile.Filename) fileExt := filepath.Ext(req.Profile.Filename)
if isApple := strings.EqualFold(fileExt, ".mobileconfig"); isApple { if isApple := strings.EqualFold(fileExt, ".mobileconfig"); isApple {
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff) cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, req.Labels)
if err != nil { if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil return &newMDMConfigProfileResponse{Err: err}, nil
} }
@ -1228,7 +1233,7 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
if isWindows := strings.EqualFold(fileExt, ".xml"); isWindows { if isWindows := strings.EqualFold(fileExt, ".xml"); isWindows {
profileName := strings.TrimSuffix(filepath.Base(req.Profile.Filename), fileExt) profileName := strings.TrimSuffix(filepath.Base(req.Profile.Filename), fileExt)
cp, err := svc.NewMDMWindowsConfigProfile(ctx, req.TeamID, profileName, ff) cp, err := svc.NewMDMWindowsConfigProfile(ctx, req.TeamID, profileName, ff, req.Labels)
if err != nil { if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil return &newMDMConfigProfileResponse{Err: err}, nil
} }
@ -1252,7 +1257,7 @@ func (svc *Service) NewMDMUnsupportedConfigProfile(ctx context.Context, teamID u
return &fleet.BadRequestError{Message: "Couldn't upload. The file should be a .mobileconfig or .xml file."} return &fleet.BadRequestError{Message: "Couldn't upload. The file should be a .mobileconfig or .xml file."}
} }
func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader) (*fleet.MDMWindowsConfigProfile, error) { func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader, labels []string) (*fleet.MDMWindowsConfigProfile, error) {
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil { if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err) return nil, ctxerr.Wrap(ctx, err)
} }
@ -1297,6 +1302,12 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint,
return nil, ctxerr.Wrap(ctx, err, "validate profile") return nil, ctxerr.Wrap(ctx, err, "validate profile")
} }
labelMap, err := svc.validateProfileLabels(ctx, labels)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating labels")
}
cp.Labels = labelMap
newCP, err := svc.ds.NewMDMWindowsConfigProfile(ctx, cp) newCP, err := svc.ds.NewMDMWindowsConfigProfile(ctx, cp)
if err != nil { if err != nil {
var existsErr existsErrorInterface var existsErr existsErrorInterface
@ -1330,15 +1341,92 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint,
return newCP, nil return newCP, nil
} }
func (svc *Service) batchValidateProfileLabels(ctx context.Context, labelNames []string) (map[string]fleet.ConfigurationProfileLabel, error) {
if len(labelNames) == 0 {
return nil, nil
}
labels, err := svc.ds.LabelIDsByName(ctx, labelNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
}
uniqueNames := make(map[string]bool)
for _, entry := range labelNames {
if _, value := uniqueNames[entry]; !value {
uniqueNames[entry] = true
}
}
if len(labels) != len(uniqueNames) {
return nil, &fleet.BadRequestError{
Message: "some or all the labels provided don't exist",
InternalErr: fmt.Errorf("names provided: %v", labelNames),
}
}
profLabels := make(map[string]fleet.ConfigurationProfileLabel)
for labelName, labelID := range labels {
profLabels[labelName] = fleet.ConfigurationProfileLabel{
LabelName: labelName,
LabelID: labelID,
}
}
return profLabels, nil
}
func (svc *Service) validateProfileLabels(ctx context.Context, labelNames []string) ([]fleet.ConfigurationProfileLabel, error) {
labelMap, err := svc.batchValidateProfileLabels(ctx, labelNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating profile labels")
}
var profLabels []fleet.ConfigurationProfileLabel
for _, label := range labelMap {
profLabels = append(profLabels, label)
}
return profLabels, nil
}
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// Batch Replace MDM Profiles // Batch Replace MDM Profiles
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
type batchSetMDMProfilesRequest struct { type batchSetMDMProfilesRequest struct {
TeamID *uint `json:"-" query:"team_id,optional"` TeamID *uint `json:"-" query:"team_id,optional"`
TeamName *string `json:"-" query:"team_name,optional"` TeamName *string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
Profiles map[string][]byte `json:"profiles"` Profiles backwardsCompatProfilesParam `json:"profiles"`
}
type backwardsCompatProfilesParam []fleet.MDMProfileBatchPayload
func (bcp *backwardsCompatProfilesParam) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
if lookAhead := bytes.TrimSpace(data); len(lookAhead) > 0 && lookAhead[0] == '[' {
// use []fleet.MDMProfileBatchPayload to prevent infinite recursion if we
// use `backwardsCompatProfileSlice`
var profs []fleet.MDMProfileBatchPayload
if err := json.Unmarshal(data, &profs); err != nil {
return fmt.Errorf("unmarshal profile spec. Error using new format: %w", err)
}
*bcp = profs
return nil
}
var backwardsCompat map[string][]byte
if err := json.Unmarshal(data, &backwardsCompat); err != nil {
return fmt.Errorf("unmarshal profile spec. Error using old format: %w", err)
}
*bcp = make(backwardsCompatProfilesParam, 0, len(backwardsCompat))
for name, contents := range backwardsCompat {
*bcp = append(*bcp, fleet.MDMProfileBatchPayload{Name: name, Contents: contents})
}
return nil
} }
type batchSetMDMProfilesResponse struct { type batchSetMDMProfilesResponse struct {
@ -1357,7 +1445,7 @@ func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc f
return batchSetMDMProfilesResponse{}, nil return batchSetMDMProfilesResponse{}, nil
} }
func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName *string, profiles map[string][]byte, dryRun, skipBulkPending bool) error { func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName *string, profiles []fleet.MDMProfileBatchPayload, dryRun, skipBulkPending bool) error {
var err error var err error
if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil { if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil {
return err return err
@ -1372,12 +1460,21 @@ func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName
return ctxerr.Wrap(ctx, err, "validating profiles") return ctxerr.Wrap(ctx, err, "validating profiles")
} }
appleProfiles, err := getAppleProfiles(ctx, tmID, appCfg, profiles) labels := []string{}
for _, prof := range profiles {
labels = append(labels, prof.Labels...)
}
labelMap, err := svc.batchValidateProfileLabels(ctx, labels)
if err != nil {
return ctxerr.Wrap(ctx, err, "validating labels")
}
appleProfiles, err := getAppleProfiles(ctx, tmID, appCfg, profiles, labelMap)
if err != nil { if err != nil {
return ctxerr.Wrap(ctx, err, "validating macOS profiles") return ctxerr.Wrap(ctx, err, "validating macOS profiles")
} }
windowsProfiles, err := getWindowsProfiles(ctx, tmID, appCfg, profiles) windowsProfiles, err := getWindowsProfiles(ctx, tmID, appCfg, profiles, labelMap)
if err != nil { if err != nil {
return ctxerr.Wrap(ctx, err, "validating Windows profiles") return ctxerr.Wrap(ctx, err, "validating Windows profiles")
} }
@ -1466,42 +1563,54 @@ func (svc *Service) authorizeBatchProfiles(ctx context.Context, tmID *uint, tmNa
return tmID, tmName, nil return tmID, tmName, nil
} }
func getAppleProfiles(ctx context.Context, tmID *uint, appCfg *fleet.AppConfig, profiles map[string][]byte) ([]*fleet.MDMAppleConfigProfile, error) { func getAppleProfiles(
ctx context.Context,
tmID *uint,
appCfg *fleet.AppConfig,
profiles []fleet.MDMProfileBatchPayload,
labelMap map[string]fleet.ConfigurationProfileLabel,
) ([]*fleet.MDMAppleConfigProfile, error) {
// any duplicate identifier or name in the provided set results in an error // any duplicate identifier or name in the provided set results in an error
profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles)) profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles))
byName, byIdent := make(map[string]bool, len(profiles)), make(map[string]bool, len(profiles)) byName, byIdent := make(map[string]bool, len(profiles)), make(map[string]bool, len(profiles))
for i, prof := range profiles { for _, prof := range profiles {
if mdm.GetRawProfilePlatform(prof) != "darwin" { if mdm.GetRawProfilePlatform(prof.Contents) != "darwin" {
continue continue
} }
mdmProf, err := fleet.NewMDMAppleConfigProfile(prof, tmID) mdmProf, err := fleet.NewMDMAppleConfigProfile(prof.Contents, tmID)
if err != nil { if err != nil {
return nil, ctxerr.Wrap(ctx, return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", i), err.Error()), fleet.NewInvalidArgumentError(prof.Name, err.Error()),
"invalid mobileconfig profile") "invalid mobileconfig profile")
} }
for _, labelName := range prof.Labels {
if lbl, ok := labelMap[labelName]; ok {
mdmProf.Labels = append(mdmProf.Labels, lbl)
}
}
if err := mdmProf.ValidateUserProvided(); err != nil { if err := mdmProf.ValidateUserProvided(); err != nil {
return nil, ctxerr.Wrap(ctx, return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", i), err.Error())) fleet.NewInvalidArgumentError(prof.Name, err.Error()))
} }
if mdmProf.Name != i { if mdmProf.Name != prof.Name {
return nil, ctxerr.Wrap(ctx, return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", i), fmt.Sprintf("Couldnt edit custom_settings. The name provided for the profile must match the profile PayloadDisplayName: %q", mdmProf.Name)), fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldnt edit custom_settings. The name provided for the profile must match the profile PayloadDisplayName: %q", mdmProf.Name)),
"duplicate mobileconfig profile by name") "duplicate mobileconfig profile by name")
} }
if byName[mdmProf.Name] { if byName[mdmProf.Name] {
return nil, ctxerr.Wrap(ctx, return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", i), fmt.Sprintf("Couldnt edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)), fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldnt edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
"duplicate mobileconfig profile by name") "duplicate mobileconfig profile by name")
} }
byName[mdmProf.Name] = true byName[mdmProf.Name] = true
if byIdent[mdmProf.Identifier] { if byIdent[mdmProf.Identifier] {
return nil, ctxerr.Wrap(ctx, return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", i), fmt.Sprintf("Couldnt edit custom_settings. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)), fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldnt edit custom_settings. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)),
"duplicate mobileconfig profile by identifier") "duplicate mobileconfig profile by identifier")
} }
byIdent[mdmProf.Identifier] = true byIdent[mdmProf.Identifier] = true
@ -1525,23 +1634,34 @@ func getAppleProfiles(ctx context.Context, tmID *uint, appCfg *fleet.AppConfig,
return profs, nil return profs, nil
} }
func getWindowsProfiles(ctx context.Context, tmID *uint, appCfg *fleet.AppConfig, profiles map[string][]byte) ([]*fleet.MDMWindowsConfigProfile, error) { func getWindowsProfiles(
ctx context.Context,
tmID *uint,
appCfg *fleet.AppConfig,
profiles []fleet.MDMProfileBatchPayload,
labelMap map[string]fleet.ConfigurationProfileLabel,
) ([]*fleet.MDMWindowsConfigProfile, error) {
profs := make([]*fleet.MDMWindowsConfigProfile, 0, len(profiles)) profs := make([]*fleet.MDMWindowsConfigProfile, 0, len(profiles))
for name, syncML := range profiles { for _, profile := range profiles {
if mdm.GetRawProfilePlatform(syncML) != "windows" { if mdm.GetRawProfilePlatform(profile.Contents) != "windows" {
continue continue
} }
mdmProf := &fleet.MDMWindowsConfigProfile{ mdmProf := &fleet.MDMWindowsConfigProfile{
TeamID: tmID, TeamID: tmID,
Name: name, Name: profile.Name,
SyncML: syncML, SyncML: profile.Contents,
}
for _, labelName := range profile.Labels {
if lbl, ok := labelMap[labelName]; ok {
mdmProf.Labels = append(mdmProf.Labels, lbl)
}
} }
if err := mdmProf.ValidateUserProvided(); err != nil { if err := mdmProf.ValidateUserProvided(); err != nil {
return nil, ctxerr.Wrap(ctx, return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", name), err.Error())) fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", profile.Name), err.Error()))
} }
profs = append(profs, mdmProf) profs = append(profs, mdmProf)
@ -1563,9 +1683,9 @@ func getWindowsProfiles(ctx context.Context, tmID *uint, appCfg *fleet.AppConfig
return profs, nil return profs, nil
} }
func validateProfiles(profiles map[string][]byte) error { func validateProfiles(profiles []fleet.MDMProfileBatchPayload) error {
for _, rawBytes := range profiles { for _, profile := range profiles {
platform := mdm.GetRawProfilePlatform(rawBytes) platform := mdm.GetRawProfilePlatform(profile.Contents)
if platform != "darwin" && platform != "windows" { if platform != "darwin" && platform != "windows" {
// TODO(roberto): there's ongoing feedback with Marko about improving this message, as it's too windows specific // TODO(roberto): there's ongoing feedback with Marko about improving this message, as it's too windows specific
return fleet.NewInvalidArgumentError("mdm", "Only <Replace> supported as a top level element. Make sure you dont have other top level elements.") return fleet.NewInvalidArgumentError("mdm", "Only <Replace> supported as a top level element. Make sure you dont have other top level elements.")

View File

@ -971,11 +971,11 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) {
checkShouldFail(t, err, tt.shouldFailTeamRead) checkShouldFail(t, err, tt.shouldFailTeamRead)
// test authz create new profile (no team) // test authz create new profile (no team)
_, err = svc.NewMDMWindowsConfigProfile(ctx, 0, "prof", strings.NewReader(winProfContent)) _, err = svc.NewMDMWindowsConfigProfile(ctx, 0, "prof", strings.NewReader(winProfContent), nil)
checkShouldFail(t, err, tt.shouldFailGlobalWrite) checkShouldFail(t, err, tt.shouldFailGlobalWrite)
// test authz create new profile (team 1) // test authz create new profile (team 1)
_, err = svc.NewMDMWindowsConfigProfile(ctx, 1, "prof", strings.NewReader(winProfContent)) _, err = svc.NewMDMWindowsConfigProfile(ctx, 1, "prof", strings.NewReader(winProfContent), nil)
checkShouldFail(t, err, tt.shouldFailTeamWrite) checkShouldFail(t, err, tt.shouldFailTeamWrite)
// test authz delete config profile (no team) // test authz delete config profile (no team)
@ -1057,7 +1057,7 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) {
}, nil }, nil
} }
ctx = test.UserContext(ctx, test.UserAdmin) ctx = test.UserContext(ctx, test.UserAdmin)
_, err := svc.NewMDMWindowsConfigProfile(ctx, c.tmID, "foo", strings.NewReader(c.profile)) _, err := svc.NewMDMWindowsConfigProfile(ctx, c.tmID, "foo", strings.NewReader(c.profile), nil)
if c.wantErr != "" { if c.wantErr != "" {
require.Error(t, err) require.Error(t, err)
require.ErrorContains(t, err, c.wantErr) require.ErrorContains(t, err, c.wantErr)
@ -1109,7 +1109,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
premium bool premium bool
teamID *uint teamID *uint
teamName *string teamName *string
profiles map[string][]byte profiles []fleet.MDMProfileBatchPayload
wantErr string wantErr string
}{ }{
{ {
@ -1271,9 +1271,9 @@ func TestMDMBatchSetProfiles(t *testing.T) {
true, true,
ptr.Uint(1), ptr.Uint(1),
nil, nil,
map[string][]byte{ []fleet.MDMProfileBatchPayload{
"N1": mobileconfigForTest("N1", "I1"), {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
"N2": mobileconfigForTest("N1", "I2"), {Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
}, },
`The name provided for the profile must match the profile PayloadDisplayName: "N1"`, `The name provided for the profile must match the profile PayloadDisplayName: "N1"`,
}, },
@ -1283,10 +1283,10 @@ func TestMDMBatchSetProfiles(t *testing.T) {
true, true,
ptr.Uint(1), ptr.Uint(1),
nil, nil,
map[string][]byte{ []fleet.MDMProfileBatchPayload{
"N1": mobileconfigForTest("N1", "I1"), {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
"N2": mobileconfigForTest("N2", "I2"), {Name: "N2", Contents: mobileconfigForTest("N2", "I2")},
"N3": mobileconfigForTest("N3", "I1"), {Name: "N3", Contents: mobileconfigForTest("N3", "I1")},
}, },
`More than one configuration profile have the same identifier (PayloadIdentifier): "I1"`, `More than one configuration profile have the same identifier (PayloadIdentifier): "I1"`,
}, },
@ -1296,10 +1296,10 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false, false,
nil, nil,
nil, nil,
map[string][]byte{ []fleet.MDMProfileBatchPayload{
"N1": mobileconfigForTest("N1", "I1"), {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
"N2": mobileconfigForTest("N2", "I2"), {Name: "N2", Contents: mobileconfigForTest("N2", "I2")},
"N3": mobileconfigForTest("N3", "I3"), {Name: "N3", Contents: mobileconfigForTest("N3", "I3")},
}, },
``, ``,
}, },
@ -1309,13 +1309,13 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false, false,
nil, nil,
nil, nil,
map[string][]byte{ []fleet.MDMProfileBatchPayload{
"N1": syncMLForTest("./foo/bar"), {Name: "N1", Contents: syncMLForTest("./foo/bar")},
"N2": syncMLForTest("./baz"), {Name: "N2", Contents: syncMLForTest("./baz")},
"N3": syncMLForTest("./zab"), {Name: "N3", Contents: syncMLForTest("./zab")},
"N4": mobileconfigForTest("N4", "I1"), {Name: "N4", Contents: mobileconfigForTest("N4", "I1")},
"N5": mobileconfigForTest("N5", "I2"), {Name: "N5", Contents: mobileconfigForTest("N5", "I2")},
"N6": mobileconfigForTest("N6", "I3"), {Name: "N6", Contents: mobileconfigForTest("N6", "I3")},
}, },
``, ``,
}, },
@ -1325,10 +1325,10 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false, false,
nil, nil,
nil, nil,
map[string][]byte{ []fleet.MDMProfileBatchPayload{
"N1": syncMLForTest("./foo/bar"), {Name: "N1", Contents: syncMLForTest("./foo/bar")},
"N2": syncMLForTest("./baz"), {Name: "N2", Contents: syncMLForTest("./baz")},
"N3": syncMLForTest("./zab"), {Name: "N3", Contents: syncMLForTest("./zab")},
}, },
``, ``,
}, },
@ -1338,8 +1338,8 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false, false,
nil, nil,
nil, nil,
map[string][]byte{ []fleet.MDMProfileBatchPayload{
"foo": []byte(`<?xml version="1.0" encoding="UTF-8"?> {Name: "foo", Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
@ -1372,7 +1372,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
<integer>1</integer> <integer>1</integer>
</dict> </dict>
</plist>`), </plist>`),
}, }},
"unsupported PayloadType(s)", "unsupported PayloadType(s)",
}, },
} }
@ -1405,42 +1405,42 @@ func TestMDMBatchSetProfiles(t *testing.T) {
func TestValidateProfiles(t *testing.T) { func TestValidateProfiles(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
profiles map[string][]byte profiles []fleet.MDMProfileBatchPayload
wantErr bool wantErr bool
}{ }{
{ {
name: "Valid Darwin Profile", name: "Valid Darwin Profile",
profiles: map[string][]byte{ profiles: []fleet.MDMProfileBatchPayload{
"darwinProfile": []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"), {Name: "darwinProfile", Contents: []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")},
}, },
wantErr: false, wantErr: false,
}, },
{ {
name: "Valid Windows Profile", name: "Valid Windows Profile",
profiles: map[string][]byte{ profiles: []fleet.MDMProfileBatchPayload{
"windowsProfile": []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>"), {Name: "windowsProfile", Contents: []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>")},
}, },
wantErr: false, wantErr: false,
}, },
{ {
name: "Invalid Profile", name: "Invalid Profile",
profiles: map[string][]byte{ profiles: []fleet.MDMProfileBatchPayload{
"invalidProfile": []byte("invalid data"), {Name: "invalidProfile", Contents: []byte("invalid data")},
}, },
wantErr: true, wantErr: true,
}, },
{ {
name: "Mixed Valid and Invalid Profiles", name: "Mixed Valid and Invalid Profiles",
profiles: map[string][]byte{ profiles: []fleet.MDMProfileBatchPayload{
"validProfile": []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"), {Name: "validProfile", Contents: []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")},
"invalidProfile": []byte("invalid data"), {Name: "invalidProfile", Contents: []byte("invalid data")},
}, },
wantErr: true, wantErr: true,
}, },
{ {
name: "Empty Profile", name: "Empty Profile",
profiles: map[string][]byte{ profiles: []fleet.MDMProfileBatchPayload{
"emptyProfile": []byte(""), {Name: "emptyProfile", Contents: []byte("")},
}, },
wantErr: true, wantErr: true,
}, },
@ -1457,3 +1457,65 @@ func TestValidateProfiles(t *testing.T) {
}) })
} }
} }
func TestBackwardsCompatProfilesParamUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input []byte
expect backwardsCompatProfilesParam
expectError bool
}{
{
name: "empty input",
input: []byte(""),
expect: nil,
expectError: false,
},
{
name: "new format",
input: []byte(`[{"name": "profile1", "contents": "Zm9vCg=="}, {"name": "profile2", "contents": "YmFyCg=="}]`),
expect: backwardsCompatProfilesParam{
{Name: "profile1", Contents: []byte("foo\n")},
{Name: "profile2", Contents: []byte("bar\n")},
},
expectError: false,
},
{
name: "new format with labels",
input: []byte(`[{"name": "profile1", "contents": "Zm9vCg==", "labels": ["foo", "bar"]}, {"name": "profile2", "contents": "YmFyCg=="}]`),
expect: backwardsCompatProfilesParam{
{Name: "profile1", Contents: []byte("foo\n"), Labels: []string{"foo", "bar"}},
{Name: "profile2", Contents: []byte("bar\n")},
},
expectError: false,
},
{
name: "old format",
input: []byte(`{"profile1": "Zm9vCg==", "profile2": "YmFyCg=="}`),
expect: backwardsCompatProfilesParam{
{Name: "profile1", Contents: []byte("foo\n")},
{Name: "profile2", Contents: []byte("bar\n")},
},
expectError: false,
},
{
name: "invalid json",
input: []byte(`{invalid json}`),
expect: nil,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var bcp backwardsCompatProfilesParam
err := bcp.UnmarshalJSON(tc.input)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.ElementsMatch(t, tc.expect, bcp)
}
})
}
}

View File

@ -41,7 +41,7 @@ func translateLabelToID(ctx context.Context, ds fleet.Datastore, identifier stri
if err != nil { if err != nil {
return 0, err return 0, err
} }
return labelIDs[0], nil return labelIDs[identifier], nil
} }
func translateTeamToID(ctx context.Context, ds fleet.Datastore, identifier string) (uint, error) { func translateTeamToID(ctx context.Context, ds fleet.Datastore, identifier string) (uint, error) {

View File

@ -160,29 +160,3 @@ func RemoveDuplicatesFromSlice[T comparable](slice []T) []T {
} }
return list return list
} }
// SliceStringsMatch checks if two slices contain the same string elements,
// regardless of order.
func SliceStringsMatch(a, b []string) bool {
if len(a) != len(b) {
return false
}
// create a map to count occurrences of elements in a
elementCount := make(map[string]int, len(a))
for _, item := range a {
elementCount[item]++
}
// decrease the count for each element in b
for _, item := range b {
elementCount[item]--
if elementCount[item] < 0 {
// if the count goes below zero, b has an element not
// in a or more occurrences of it than a
return false
}
}
return true
}

View File

@ -150,28 +150,3 @@ func TestRemoveDuplicatesFromSlice(t *testing.T) {
) )
} }
} }
func TestSliceStringsMatch(t *testing.T) {
testCases := []struct {
a, b []string
want bool
name string
}{
{[]string{"foo", "bar"}, []string{"bar", "foo"}, true, "same elements in different order"},
{[]string{"foo", "bar"}, []string{"foo", "bar"}, true, "same elements in same order"},
{[]string{"foo", "bar"}, []string{"bar", "bar"}, false, "different number of same elements"},
{[]string{"foo", "foo", "bar"}, []string{"bar", "foo", "foo"}, true, "both have duplicates"},
{[]string{"foo", "bar", "bar"}, []string{"bar", "foo", "foo"}, false, "both have duplicates but elements don't match"},
{[]string{"foo", "bar"}, []string{"foo"}, false, "different lengths"},
{[]string{}, []string{}, true, "both slices empty"},
{[]string{"foo"}, []string{}, false, "one slice empty"},
{[]string{"unique"}, []string{"unique", "unique"}, false, "duplicate in one slice"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := SliceStringsMatch(tc.a, tc.b)
require.Equal(t, tc.want, got)
})
}
}

View File

@ -102,7 +102,9 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int
github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int
github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSettings fleet.MacOSSettings github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSettings fleet.MacOSSettings
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []string github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings DeprecatedEnableDiskEncryption *bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings DeprecatedEnableDiskEncryption *bool
github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSetup fleet.MacOSSetup github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSetup fleet.MacOSSetup
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String
@ -120,10 +122,13 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Bool Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Bool Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool github.com/fleetdm/fleet/v4/pkg/optjson/Bool Value bool
github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsSettings fleet.WindowsSettings
github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[string] github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec]
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Value []fleet.MDMProfileSpec
github.com/fleetdm/fleet/v4/server/fleet/AppConfig Scripts optjson.Slice[string]
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Value []string github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Value []string
github.com/fleetdm/fleet/v4/server/fleet/AppConfig Scripts optjson.Slice[string]
github.com/fleetdm/fleet/v4/server/fleet/AppConfig strictDecoding bool github.com/fleetdm/fleet/v4/server/fleet/AppConfig strictDecoding bool
github.com/fleetdm/fleet/v4/server/fleet/AppConfig didUnmarshalLegacySettings []string github.com/fleetdm/fleet/v4/server/fleet/AppConfig didUnmarshalLegacySettings []string

View File

@ -0,0 +1,2 @@
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string

View File

@ -12,14 +12,16 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int
github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettings
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []string github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Path string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec Labels []string
github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings DeprecatedEnableDiskEncryption *bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings DeprecatedEnableDiskEncryption *bool
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSetup fleet.MacOSSetup github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSetup fleet.MacOSSetup
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings
github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[string] github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec]
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Value []string github.com/fleetdm/fleet/v4/pkg/optjson/Slice[github.com/fleetdm/fleet/v4/server/fleet.MDMProfileSpec] Value []fleet.MDMProfileSpec

View File

@ -47,6 +47,7 @@ var cacheableItems = []fleet.Cloner{
&fleet.Features{}, &fleet.Features{},
&fleet.TeamMDM{}, &fleet.TeamMDM{},
&fleet.Query{}, &fleet.Query{},
&fleet.MDMProfileSpec{},
// TeamAgentOptions is not in the list because it is a json.RawMessage, no fields can change. // TeamAgentOptions is not in the list because it is a json.RawMessage, no fields can change.
// Same for ResultCountForQuery, it's just an int. // Same for ResultCountForQuery, it's just an int.
} }

View File

@ -240,8 +240,296 @@ func main() {
} }
} }
var profiles = map[string][]byte{ var profiles = []fleet.MDMProfileBatchPayload{
"Disable Bluetooth sharing": []byte(`<?xml version="1.0" encoding="UTF-8"?> {
Name: "Ensure Install Security Responses and System Files Is Enabled",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.6.check</string>
<key>PayloadUUID</key>
<string>0D8F676A-A705-4F57-8FF8-3118360EFDEB</string>
<key>ConfigDataInstall</key>
<true/>
<key>CriticalUpdateInstall</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Install Security Responses and System Files Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.6</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>EBEE9B81-9D33-477F-AFBE-9691360B7A74</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Ensure Software Update Deferment Is Less Than or Equal to 30 Days",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.applicationaccess</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.7.check</string>
<key>PayloadUUID</key>
<string>123FD592-D1C3-41FD-BC41-F91F3E1E2CF4</string>
<key>enforcedSoftwareUpdateDelay</key>
<integer>29</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Software Update Deferment Is Less Than or Equal to 30 Days</string>
<key>PayloadIdentifier</key>
<string>com.zwass.cis-1.7</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>385A0C13-2472-41B3-851C-1311FA12EB49</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Ensure Auto Update Is Enabled",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.2.check</string>
<key>PayloadUUID</key>
<string>4DC539B5-837E-4DC3-B60B-43A8C556A8F0</string>
<key>AutomaticCheckEnabled</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Auto Update Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.2</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>03E69A02-02CE-4CA0-8F17-3BAAD5D3852F</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Ensure Download New Updates When Available Is Enabled",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.3.check</string>
<key>PayloadUUID</key>
<string>5FDE6D58-79CD-447A-AFB0-BA32D889C396</string>
<key>AutomaticDownload</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Download New Updates When Available Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.3</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>0A1C2F97-D6FA-4CDB-ABB6-47DF2B151F4F</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Ensure Install of macOS Updates Is Enabled",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.4.check</string>
<key>PayloadUUID</key>
<string>15BF7634-276A-411B-8C4E-52D89B4ED82C</string>
<key>AutomaticallyInstallMacOSUpdates</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Install of macOS Updates Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.4</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>7DB8733E-BD11-4E88-9AE0-273EF2D0974B</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Ensure Firewall Logging Is Enabled and Configured",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.security.firewall</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-3.6.check</string>
<key>PayloadUUID</key>
<string>604D8218-D7B6-43B1-95E6-DFCA4C25D73D</string>
<key>EnableFirewall</key>
<true/>
<key>EnableLogging</key>
<true/>
<key>LoggingOption</key>
<string>detail</string>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Firewall Logging Is Enabled and Configured</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-3.6</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>5E27501E-50DF-4804-9DEC-0E63C34E8831</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Ensure Bonjour Advertising Services Is Disabled",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.mDNSResponder</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-4.1.check</string>
<key>PayloadUUID</key>
<string>08FEA43B-CE9B-4098-804C-11459D109992</string>
<key>NoMulticastAdvertisements</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Bonjour Advertising Services Is Disabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-4.1</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>25BD1312-2B79-40C7-99FA-E60B49A1883E</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
},
{
Name: "Disable Bluetooth sharing",
Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
@ -301,118 +589,10 @@ var profiles = map[string][]byte{
</array> </array>
</dict> </dict>
</plist>`), </plist>`),
"Ensure Auto Update Is Enabled": []byte(`<?xml version="1.0" encoding="UTF-8"?> },
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> {
<plist version="1.0"> Name: "Ensure Install Application Updates from the App Store Is Enabled",
<dict> Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.2.check</string>
<key>PayloadUUID</key>
<string>4DC539B5-837E-4DC3-B60B-43A8C556A8F0</string>
<key>AutomaticCheckEnabled</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Auto Update Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.2</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>03E69A02-02CE-4CA0-8F17-3BAAD5D3852F</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
"Ensure Download New Updates When Available Is Enabled": []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.3.check</string>
<key>PayloadUUID</key>
<string>5FDE6D58-79CD-447A-AFB0-BA32D889C396</string>
<key>AutomaticDownload</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Download New Updates When Available Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.3</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>0A1C2F97-D6FA-4CDB-ABB6-47DF2B151F4F</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
"Ensure Install of macOS Updates Is Enabled": []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.4.check</string>
<key>PayloadUUID</key>
<string>15BF7634-276A-411B-8C4E-52D89B4ED82C</string>
<key>AutomaticallyInstallMacOSUpdates</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Install of macOS Updates Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.4</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>7DB8733E-BD11-4E88-9AE0-273EF2D0974B</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
"Ensure Install Application Updates from the App Store Is Enabled": []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
@ -449,83 +629,10 @@ var profiles = map[string][]byte{
<integer>1</integer> <integer>1</integer>
</dict> </dict>
</plist>`), </plist>`),
"Ensure Install Security Responses and System Files Is Enabled": []byte(`<?xml version="1.0" encoding="UTF-8"?> },
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> {
<plist version="1.0"> Name: "Disable iCloud Drive storage solution usage",
<dict> Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.SoftwareUpdate</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.6.check</string>
<key>PayloadUUID</key>
<string>0D8F676A-A705-4F57-8FF8-3118360EFDEB</string>
<key>ConfigDataInstall</key>
<true/>
<key>CriticalUpdateInstall</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Install Security Responses and System Files Is Enabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.6</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>EBEE9B81-9D33-477F-AFBE-9691360B7A74</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
"Ensure Software Update Deferment Is Less Than or Equal to 30 Days": []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.applicationaccess</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-1.7.check</string>
<key>PayloadUUID</key>
<string>123FD592-D1C3-41FD-BC41-F91F3E1E2CF4</string>
<key>enforcedSoftwareUpdateDelay</key>
<integer>29</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Software Update Deferment Is Less Than or Equal to 30 Days</string>
<key>PayloadIdentifier</key>
<string>com.zwass.cis-1.7</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>385A0C13-2472-41B3-851C-1311FA12EB49</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
"Disable iCloud Drive storage solution usage": []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
@ -562,84 +669,7 @@ var profiles = map[string][]byte{
<integer>1</integer> <integer>1</integer>
</dict> </dict>
</plist>`), </plist>`),
"Ensure Firewall Logging Is Enabled and Configured": []byte(`<?xml version="1.0" encoding="UTF-8"?> },
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.security.firewall</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-3.6.check</string>
<key>PayloadUUID</key>
<string>604D8218-D7B6-43B1-95E6-DFCA4C25D73D</string>
<key>EnableFirewall</key>
<true/>
<key>EnableLogging</key>
<true/>
<key>LoggingOption</key>
<string>detail</string>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Firewall Logging Is Enabled and Configured</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-3.6</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>5E27501E-50DF-4804-9DEC-0E63C34E8831</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
"Ensure Bonjour Advertising Services Is Disabled": []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>test</string>
<key>PayloadType</key>
<string>com.apple.mDNSResponder</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-4.1.check</string>
<key>PayloadUUID</key>
<string>08FEA43B-CE9B-4098-804C-11459D109992</string>
<key>NoMulticastAdvertisements</key>
<true/>
</dict>
</array>
<key>PayloadDescription</key>
<string>test</string>
<key>PayloadDisplayName</key>
<string>Ensure Bonjour Advertising Services Is Disabled</string>
<key>PayloadIdentifier</key>
<string>com.fleetdm.cis-4.1</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadScope</key>
<string>System</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>25BD1312-2B79-40C7-99FA-E60B49A1883E</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`),
} }
var newProfile = []byte(`<?xml version="1.0" encoding="UTF-8"?> var newProfile = []byte(`<?xml version="1.0" encoding="UTF-8"?>