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),
},
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileCfgPath},
CustomSettings: []fleet.MDMProfileSpec{{Path: mobileCfgPath}},
},
}
@ -302,7 +302,7 @@ spec:
GracePeriodDays: optjson.SetInt(1),
},
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"}}}`)
@ -375,7 +375,7 @@ spec:
GracePeriodDays: optjson.Int{Set: true},
},
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{},
CustomSettings: []fleet.MDMProfileSpec{},
},
}
@ -1094,7 +1094,7 @@ spec:
GracePeriodDays: optjson.SetInt(0),
},
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath},
CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
},
WindowsEnabledAndConfigured: true,
}, currentAppConfig.MDM)
@ -1136,7 +1136,7 @@ spec:
GracePeriodDays: optjson.SetInt(0),
},
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath},
CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
},
WindowsEnabledAndConfigured: true,
}, currentAppConfig.MDM)
@ -1176,7 +1176,7 @@ spec:
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath},
CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
},
MacOSUpdates: fleet.MacOSUpdates{
MinimumVersion: optjson.SetString("10.10.10"),
@ -1215,7 +1215,7 @@ spec:
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath},
CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
},
MacOSUpdates: fleet.MacOSUpdates{
MinimumVersion: optjson.SetString("10.10.10"),
@ -1251,7 +1251,7 @@ spec:
assert.Equal(t, fleet.TeamMDM{
EnableDiskEncryption: false,
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{mobileConfigPath},
CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}},
},
MacOSUpdates: fleet.MacOSUpdates{
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
}
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)
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) {
@ -136,9 +136,9 @@ func TestHostsTransferByStatus(t *testing.T) {
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)
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) {
@ -192,9 +192,9 @@ func TestHostsTransferByStatusAndSearchQuery(t *testing.T) {
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)
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) {

View File

@ -51,7 +51,7 @@ func TestSavedLiveQuery(t *testing.T) {
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
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
}
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) {
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
}
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 !appCfg.MDM.WindowsEnabledAndConfigured &&
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",
`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"] &&
len(applyUpon.CustomSettings) > 0 &&
!server.SliceStringsMatch(applyUpon.CustomSettings, oldCustomSettings)
!fleet.MDMProfileSpecsMatch(applyUpon.CustomSettings, oldCustomSettings)
if customSettingsChanged || (setFields["enable_disk_encryption"] && *applyUpon.DeprecatedEnableDiskEncryption) {
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 Profile from "./Profile";
import Download from "./Download";
import Upload from "./Upload";
import Refresh from "./Refresh";
// a mapping of the usable names of icons to the icon source.
@ -107,6 +108,7 @@ export const ICON_MAP = {
"premium-feature": PremiumFeature,
profile: Profile,
download: Download,
upload: Upload,
refresh: Refresh,
};

View File

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

View File

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

View File

@ -1,7 +1,18 @@
.custom-settings {
.section-header {
margin: 0;
padding: 0 0 12px 0;
h2 {
padding-bottom: 0;
border-bottom: none;
margin: 0;
}
}
&__description {
font-size: $x-small;
margin: $pad-xxlarge 0;
margin: $pad-large 0;
}
&__profiles-header {
@ -28,4 +39,183 @@
&__file-uploader {
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";
const baseClass = "profile-list-heading";
const ProfileListHeading = () => {
interface IProfileListHeadingProps {
onClickAddProfile?: () => void;
}
const ProfileListHeading = ({
onClickAddProfile,
}: IProfileListHeadingProps) => {
return (
<div className={baseClass}>
<span>Configuration profile</span>
<span className={`${baseClass}__actions-heading`}>Actions</span>
<span className={`${baseClass}__profile-name-heading`}>
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>
);
};

View File

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

View File

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

View File

@ -16,4 +16,24 @@
width: 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 { 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 add them in this function. Otherwise, we'll just return the error message from the
* API.
*/
// eslint-disable-next-line import/prefer-default-export
export const getErrorMessage = (err: AxiosResponse<IApiError>) => {
const apiReason = err.data.errors[0].reason;
const apiReason = err?.data?.errors?.[0]?.reason;
if (
apiReason.includes(
@ -33,5 +127,5 @@ export const getErrorMessage = (err: AxiosResponse<IApiError>) => {
</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 = {
downloadDeviceUserEnrollmentProfile: (token: string) => {
const { DEVICE_USER_MDM_ENROLLMENT_PROFILE } = endpoints;
@ -79,7 +85,7 @@ const mdmService = {
return sendRequest("GET", path);
},
uploadProfile: (file: File, teamId?: number) => {
uploadProfile: ({ file, teamId, labels }: IUploadProfileApiParams) => {
const { MDM_PROFILES } = endpoints;
const formData = new FormData();
@ -89,6 +95,10 @@ const mdmService = {
formData.append("team_id", teamId.toString());
}
labels?.forEach((label) => {
formData.append("labels", label);
});
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 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 => {
const { email } = resource;
const gravatarAvailable =
@ -844,6 +869,7 @@ export const getUniqueColumnNamesFromRows = (rows: any[]) =>
);
export default {
pluralize,
addGravatarUrlToResource,
formatConfigDataForServer,
formatLabelResponse,

View File

@ -565,7 +565,7 @@ func TestCachedTeamMDMConfig(t *testing.T) {
Deadline: optjson.SetString("1992-03-01"),
},
MacOSSettings: fleet.MacOSSettings{
CustomSettings: []string{"a", "b"},
CustomSettings: []fleet.MDMProfileSpec{{Path: "a"}, {Path: "b"}},
DeprecatedEnableDiskEncryption: ptr.Bool(false),
},
MacOSSetup: fleet.MacOSSetup{
@ -614,7 +614,7 @@ func TestCachedTeamMDMConfig(t *testing.T) {
require.False(t, mockedDS.TeamMDMConfigFuncInvoked)
// 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)
// saving a team updates config in cache

View File

@ -38,31 +38,48 @@ INSERT INTO
teamID = *cp.TeamID
}
res, err := ds.writer(ctx).ExecContext(ctx, stmt,
var profileID int64
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 nil, ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
return ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
default:
return nil, ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
return ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
}
}
aff, _ := res.RowsAffected()
if aff == 0 {
return nil, &existsError{
return &existsError{
ResourceType: "MDMAppleConfigProfile.PayloadDisplayName",
Identifier: cp.Name,
TeamID: cp.TeamID,
}
}
id, _ := res.LastInsertId()
// 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 {
return nil, ctxerr.Wrap(ctx, err, "inserting profile and label associations")
}
return &fleet.MDMAppleConfigProfile{
ProfileUUID: profUUID,
ProfileID: uint(id),
ProfileID: uint(profileID),
Identifier: cp.Identifier,
Name: cp.Name,
Mobileconfig: cp.Mobileconfig,
@ -172,6 +189,19 @@ WHERE
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
}
@ -1150,6 +1180,7 @@ func (ds *Datastore) batchSetMDMAppleProfilesDB(
const loadExistingProfiles = `
SELECT
identifier,
profile_uuid,
mobileconfig
FROM
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)
}
}
// 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
})
}
@ -1316,25 +1381,35 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
return nil
}
const desiredStateStmt = `
// TODO(mna): the conditions here (and in toRemoveStmt) are subtly different
// than the ones in ListMDMAppleProfilesToInstall/Remove, so I'm keeping
// those statements distinct to avoid introducing a subtle bug, but we should
// take the time to properly analyze this and try to reuse
// ListMDMAppleProfilesToInstall/Remove as we do in the Windows equivalent
// method.
//
// I.e. for toInstallStmt, this is missing:
// -- profiles in A and B with operation type "install" and NULL status
// but I believe it would be a no-op and no harm in adding (status is
// already NULL).
//
// And for toRemoveStmt, this is different:
// -- except "remove" operations in any state
// vs
// -- except "remove" operations in a terminal state or already pending
// but again I believe it would be a no-op and no harm in making them the
// same (if I'm understanding correctly, the only difference is that it
// 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 (
SELECT
macp.profile_uuid,
h.uuid as host_uuid,
macp.identifier as profile_identifier,
macp.name as profile_name,
macp.checksum as checksum
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
FROM ( %s ) as ds
LEFT JOIN host_mdm_apple_profiles hmap
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
WHERE
@ -1343,12 +1418,13 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
-- profiles in A but not in B
( hmap.profile_uuid IS NULL AND hmap.host_uuid IS NULL ) OR
-- 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
// uuids, teams or profile IDs), could result in too many placeholders (not
// 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 {
return ctxerr.Wrap(ctx, err, "building profiles to install statement")
}
@ -1359,7 +1435,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute")
}
const currentStateStmt = `
toRemoveStmt := fmt.Sprintf(`
SELECT
hmap.profile_uuid as profile_uuid,
hmap.host_uuid as host_uuid,
@ -1370,28 +1446,29 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
hmap.operation_type as operation_type,
COALESCE(hmap.detail, '') as detail,
hmap.command_uuid as command_uuid
FROM (
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
FROM ( %s ) as ds
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
hmap.host_uuid IN (?)
hmap.host_uuid IN (?) AND
-- 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
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
// uuids, teams or profile IDs), could result in too many placeholders (not
// 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 {
return ctxerr.Wrap(ctx, err, "building profiles to remove statement")
}
@ -1534,19 +1611,79 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
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) {
// 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.
// - 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:
//
// - profiles that are in A but not in B
//
// - 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
// "remove", regardless of the status. (technically, if status is NULL then
@ -1562,27 +1699,23 @@ func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*flee
// and a NULL status. Other statuses mean that the operation is already in
// flight (pending), the operation has been completed but is still subject
// 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
// be marked as status NULL so that it gets re-installed.
query := `
// 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.
//
// Note that for label-based profiles, only fully-satisfied profiles are
// considered for installation. This means that a broken label-based profile,
// where one of the labels does not exist anymore, will not be considered for
// installation.
query := fmt.Sprintf(`
SELECT
ds.profile_uuid,
ds.host_uuid,
ds.profile_identifier,
ds.profile_name,
ds.checksum
FROM (
SELECT
macp.profile_uuid,
h.uuid as host_uuid,
macp.identifier as profile_identifier,
macp.name as profile_name,
macp.checksum as checksum
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'
) as ds
FROM ( %s ) as ds
LEFT JOIN host_mdm_apple_profiles hmap
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
WHERE
@ -1594,7 +1727,7 @@ func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*flee
( 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 )
`
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "TRUE", "TRUE"))
var profiles []*fleet.MDMAppleProfilePayload
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) {
// 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.
// - 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:
//
@ -1619,7 +1753,16 @@ func (ds *Datastore) ListMDMAppleProfilesToRemove(ctx context.Context) ([]*fleet
// 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
// both, their desired state is necessarily to be installed).
query := `
//
// Note that for label-based profiles, only those that are fully-sastisfied
// by the host are considered for install (are part of the desired state used
// to compute the ones to remove). However, as a special case, a broken
// label-based profile will NOT be removed from a host where it was
// previously installed. However, if a host used to satisfy a label-based
// profile but no longer does (and that label-based profile is not "broken"),
// the profile will be removed from the host.
query := fmt.Sprintf(`
SELECT
hmap.profile_uuid,
hmap.profile_identifier,
@ -1630,20 +1773,23 @@ func (ds *Datastore) ListMDMAppleProfilesToRemove(ctx context.Context) ([]*fleet
COALESCE(hmap.detail, '') as detail,
hmap.status,
hmap.command_uuid
FROM (
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'
) as ds
FROM ( %s ) as ds
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
-- profiles that are in B but not in A
WHERE 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 a terminal state or already pending
AND ( hmap.operation_type IS NULL OR hmap.operation_type != ? OR hmap.status IS NULL )
`
( 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
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)
}{
{"TestNewMDMAppleConfigProfileDuplicateName", testNewMDMAppleConfigProfileDuplicateName},
{"TestNewMDMAppleConfigProfileLabels", testNewMDMAppleConfigProfileLabels},
{"TestNewMDMAppleConfigProfileDuplicateIdentifier", testNewMDMAppleConfigProfileDuplicateIdentifier},
{"TestDeleteMDMAppleConfigProfile", testDeleteMDMAppleConfigProfile},
{"TestDeleteMDMAppleConfigProfileByTeamAndIdentifier", testDeleteMDMAppleConfigProfileByTeamAndIdentifier},
@ -138,6 +139,38 @@ func testNewMDMAppleConfigProfileDuplicateName(t *testing.T, ds *Datastore) {
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) {
ctx := context.Background()
initialCP := storeDummyConfigProfileForTest(t, ds)
@ -163,9 +196,46 @@ func testNewMDMAppleConfigProfileDuplicateIdentifier(t *testing.T, ds *Datastore
storedCP, err := ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, newCP.ProfileID)
require.NoError(t, err)
checkConfigProfile(t, *newCP, *storedCP)
require.Nil(t, storedCP.Labels)
storedCP, err = ds.GetMDMAppleConfigProfile(ctx, newCP.ProfileUUID)
require.NoError(t, err)
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 {
@ -1027,12 +1097,17 @@ func configProfileBytesForTest(name, identifier, uuid string) []byte {
`, 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)
cp, err := fleet.NewMDMAppleConfigProfile(configProfileBytesForTest(name, identifier, uuid), nil)
require.NoError(t, err)
sum := md5.Sum(prof) // nolint:gosec // used only to hash for efficient comparisons
cp.Checksum = sum[:]
for _, lbl := range labels {
cp.Labels = append(cp.Labels, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
}
return cp
}

View File

@ -861,27 +861,32 @@ func (ds *Datastore) SearchLabels(ctx context.Context, filter fleet.TeamFilter,
return matches, nil
}
func (ds *Datastore) LabelIDsByName(ctx context.Context, labels []string) ([]uint, error) {
if len(labels) == 0 {
return []uint{}, nil
func (ds *Datastore) LabelIDsByName(ctx context.Context, names []string) (map[string]uint, error) {
if len(names) == 0 {
return map[string]uint{}, nil
}
sqlStatement := `
SELECT id FROM labels
SELECT id, name FROM labels
WHERE name IN (?)
`
sql, args, err := sqlx.In(sqlStatement, labels)
sql, args, err := sqlx.In(sqlStatement, names)
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
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labelIDs, sql, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get label IDs")
var labels []fleet.Label
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &labels, sql, args...); err != nil {
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

View File

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

View File

@ -188,9 +188,73 @@ FROM (
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
}
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
// (i.e. pass 0 in that case as part of the teamIDs slice). Only one of the
// slice arguments can have values.
@ -512,23 +576,58 @@ func (ds *Datastore) GetHostMDMProfilesExpectedForVerification(ctx context.Conte
switch host.Platform {
case "darwin":
return ds.getHostMDMAppleProfilesExpectedForVerification(ctx, teamID)
return ds.getHostMDMAppleProfilesExpectedForVerification(ctx, teamID, host.ID)
case "windows":
return ds.getHostMDMWindowsProfilesExpectedForVerification(ctx, teamID)
return ds.getHostMDMWindowsProfilesExpectedForVerification(ctx, teamID, host.ID)
default:
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 := `
SELECT name, syncml as raw_profile, updated_at as earliest_install_date
FROM mdm_windows_configuration_profiles mwcp
WHERE mwcp.team_id = ?
SELECT
name,
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
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 {
return nil, err
}
@ -541,10 +640,12 @@ func (ds *Datastore) getHostMDMWindowsProfilesExpectedForVerification(ctx contex
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 := `
SELECT
identifier,
macp.identifier AS identifier,
0 AS count_profile_labels,
0 AS count_host_labels,
earliest_install_date
FROM
mdm_apple_configuration_profiles macp
@ -555,13 +656,48 @@ FROM
FROM
mdm_apple_configuration_profiles
GROUP BY
checksum) cs
ON macp.checksum = cs.checksum
checksum) cs ON macp.checksum = cs.checksum
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
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))
}
@ -649,3 +785,96 @@ WHERE
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")
}
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
}
@ -1074,8 +1083,63 @@ GROUP BY
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) {
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 {
var err error
result, err = listMDMWindowsProfilesToInstallDB(ctx, tx, nil)
@ -1091,9 +1155,10 @@ func listMDMWindowsProfilesToInstallDB(
) ([]*fleet.MDMWindowsProfilePayload, error) {
// 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.
// - 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:
//
@ -1105,18 +1170,18 @@ func listMDMWindowsProfilesToInstallDB(
// 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
// be marked as status NULL so that it gets re-installed.
query := `
//
// Note that for label-based profiles, only fully-satisfied profiles are
// considered for installation. This means that a broken label-based profile,
// where one of the labels does not exist anymore, will not be considered for
// installation.
query := fmt.Sprintf(`
SELECT
ds.profile_uuid,
ds.host_uuid,
ds.name as profile_name
FROM (
SELECT mwcp.profile_uuid, mwcp.name, h.uuid as host_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' AND (%s)
) as ds
FROM ( %s ) as ds
LEFT JOIN host_mdm_windows_profiles hmwp
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid
WHERE
@ -1124,7 +1189,7 @@ func listMDMWindowsProfilesToInstallDB(
( hmwp.profile_uuid IS NULL AND hmwp.host_uuid IS NULL ) OR
-- 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 )
`
`, windowsMDMProfilesDesiredStateQuery)
hostFilter := "TRUE"
if len(hostUUIDs) > 0 {
@ -1133,9 +1198,9 @@ func listMDMWindowsProfilesToInstallDB(
var err error
args := []any{fleet.MDMOperationTypeInstall}
query = fmt.Sprintf(query, hostFilter)
query = fmt.Sprintf(query, hostFilter, hostFilter)
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 {
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) {
var result []*fleet.MDMWindowsProfilePayload
// TODO(mna): same question here
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
var err error
result, err = listMDMWindowsProfilesToRemoveDB(ctx, tx, nil)
@ -1171,9 +1237,23 @@ func listMDMWindowsProfilesToRemoveDB(
// 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
// processed by the ListMDMWindowsProfilesToInstall method (since they are in
// both, their desired state is necessarily to be installed).
query := `
// processed by the ListMDMWindowsProfilesToInstall method (since they are
// in both, their desired state is necessarily to be installed).
//
// Note that for label-based profiles, only those that are fully-sastisfied
// by the host are considered for install (are part of the desired state used
// to compute the ones to remove). However, as a special case, a broken
// label-based profile will NOT be removed from a host where it was
// previously installed. However, if a host used to satisfy a label-based
// profile but no longer does (and that label-based profile is not "broken"),
// the profile will be removed from the host.
hostFilter := "TRUE"
if len(hostUUIDs) > 0 {
hostFilter = "hmwp.host_uuid IN (?)"
}
query := fmt.Sprintf(`
SELECT
hmwp.profile_uuid,
hmwp.host_uuid,
@ -1181,29 +1261,27 @@ func listMDMWindowsProfilesToRemoveDB(
COALESCE(hmwp.detail, '') as detail,
hmwp.status,
hmwp.command_uuid
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
FROM ( %s ) as ds
RIGHT JOIN host_mdm_windows_profiles hmwp
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.uuid
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid
WHERE
-- profiles that are in B but not in A
WHERE ds.profile_uuid IS NULL
AND ds.uuid IS NULL
AND (%s)
`
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
hostFilter := "TRUE"
if len(hostUUIDs) > 0 {
hostFilter = "hmwp.host_uuid IN (?)"
}
-- 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 args []any
query = fmt.Sprintf(query, hostFilter)
if len(hostUUIDs) > 0 {
query, args, err = sqlx.In(query, hostUUIDs)
if err != nil {
@ -1379,7 +1457,7 @@ func (ds *Datastore) bulkDeleteMDMWindowsHostsConfigProfilesDB(
func (ds *Datastore) NewMDMWindowsConfigProfile(ctx context.Context, cp fleet.MDMWindowsConfigProfile) (*fleet.MDMWindowsConfigProfile, error) {
profileUUID := "w" + uuid.New().String()
stmt := `
insertProfileStmt := `
INSERT INTO
mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml)
(SELECT ?, ?, ?, ? FROM DUAL WHERE
@ -1393,29 +1471,43 @@ INSERT INTO
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 {
res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):
return nil, &existsError{
return &existsError{
ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name,
TeamID: cp.TeamID,
}
default:
return nil, ctxerr.Wrap(ctx, err, "creating new windows mdm config profile")
return ctxerr.Wrap(ctx, err, "creating new windows mdm config profile")
}
}
aff, _ := res.RowsAffected()
if aff == 0 {
return nil, &existsError{
return &existsError{
ResourceType: "MDMWindowsConfigProfile.Name",
Identifier: cp.Name,
TeamID: cp.TeamID,
}
}
for i := range cp.Labels {
cp.Labels[i].ProfileUUID = profileUUID
}
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{
ProfileUUID: profileUUID,
Name: cp.Name,
@ -1478,6 +1570,7 @@ func (ds *Datastore) batchSetMDMWindowsProfilesDB(
const loadExistingProfiles = `
SELECT
name,
profile_uuid,
syncml
FROM
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)
}
}
// 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
})
}

View File

@ -1781,11 +1781,61 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
require.Error(t, err)
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")
require.Error(t, 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.Equal(t, profA.ProfileUUID, prof.ProfileUUID)
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.NotZero(t, prof.CreatedAt)
require.NotZero(t, prof.UpdatedAt)
require.Nil(t, prof.Labels)
err = ds.DeleteMDMWindowsConfigProfile(ctx, "not-valid")
require.Error(t, err)
@ -1976,8 +2027,8 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
applyAndExpect(nil, ptr.Uint(1), nil)
}
func windowsConfigProfileForTest(t *testing.T, name, locURI string) *fleet.MDMWindowsConfigProfile {
return &fleet.MDMWindowsConfigProfile{
func windowsConfigProfileForTest(t *testing.T, name, locURI string, labels ...*fleet.Label) *fleet.MDMWindowsConfigProfile {
prof := &fleet.MDMWindowsConfigProfile{
Name: name,
SyncML: []byte(fmt.Sprintf(`
<Replace>
@ -1989,4 +2040,10 @@ func windowsConfigProfileForTest(t *testing.T, name, locURI string) *fleet.MDMWi
</Replace>
`, 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"),
},
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"),
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.SetSlice([]string{"foo", "bar"}),
CustomSettings: optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo"}, {Path: "bar"}}),
},
}, mdm)
})

View File

@ -298,7 +298,7 @@ type MacOSSettings struct {
//
// NOTE: These are only present here for informational purposes.
// (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"`
// 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{})
if v == nil || ok {
strs := make([]string, 0, len(vals))
csSpecs := make([]MDMProfileSpec, 0, len(vals))
for _, v := range vals {
str, ok := v.(string)
if !ok {
// error, must be a []string
if m, ok := v.(map[string]interface{}); ok {
var spec MDMProfileSpec
// 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{
Value: fmt.Sprintf("%T", v),
Type: reflect.TypeOf(s.CustomSettings),
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 {
clone.MDM.MacOSSettings.CustomSettings = make([]string, len(c.MDM.MacOSSettings.CustomSettings))
copy(clone.MDM.MacOSSettings.CustomSettings, c.MDM.MacOSSettings.CustomSettings)
clone.MDM.MacOSSettings.CustomSettings = make([]MDMProfileSpec, len(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 {
@ -564,8 +587,10 @@ func (c *AppConfig) Copy() *AppConfig {
}
if c.MDM.WindowsSettings.CustomSettings.Set {
windowsSettings := make([]string, len(c.MDM.WindowsSettings.CustomSettings.Value))
copy(windowsSettings, c.MDM.WindowsSettings.CustomSettings.Value)
windowsSettings := make([]MDMProfileSpec, len(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)
}
@ -1205,5 +1230,5 @@ func (v *Version) AuthzType() string {
type WindowsSettings struct {
// NOTE: These are only present here for informational purposes.
// (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

@ -196,10 +196,24 @@ type MDMAppleConfigProfile struct {
Mobileconfig mobileconfig.Mobileconfig `db:"mobileconfig" json:"-"`
// Checksum is an MD5 hash of the Mobileconfig bytes
Checksum []byte `db:"checksum" json:"checksum,omitempty"`
// Labels are the associated labels for this profile
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) {
mc := mobileconfig.Mobileconfig(raw)
cp, err := mc.ParseConfigProfile()

View File

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

View File

@ -1,7 +1,9 @@
package fleet
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"time"
@ -117,6 +119,10 @@ type ExpectedMDMProfile struct {
EarliestInstallDate time.Time `db:"earliest_install_date"`
// RawProfile contains the raw profile contents
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.
@ -359,6 +365,15 @@ type MDMConfigProfilePayload struct {
Checksum []byte `json:"checksum,omitempty" db:"checksum"` // only set for macOS
CreatedAt time.Time `json:"created_at" db:"created_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 {
@ -373,6 +388,7 @@ func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConf
Platform: "windows",
CreatedAt: cp.CreatedAt,
UpdatedAt: cp.UpdatedAt,
Labels: cp.Labels,
}
}
@ -390,5 +406,108 @@ func NewMDMConfigProfilePayloadFromApple(cp *MDMAppleConfigProfile) *MDMConfigPr
Checksum: cp.Checksum,
CreatedAt: cp.CreatedAt,
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.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)
// 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
// configuration profile via its numeric ID. This method is deprecated and
// should not be used for new endpoints.
@ -856,7 +856,7 @@ type Service interface {
// NewMDMWindowsConfigProfile creates a new Windows configuration profile for
// 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
// unsupported extension is uploaded.
@ -867,7 +867,7 @@ type Service interface {
// BatchSetMDMProfiles replaces the custom Windows/macOS profiles for a specified
// 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

View File

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

View File

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

View File

@ -35,6 +35,7 @@ type MDMWindowsConfigProfile struct {
TeamID *uint `db:"team_id" json:"team_id"`
Name string `db:"name" json:"name"`
SyncML []byte `db:"syncml" json:"-"`
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"`
}

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 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
@ -2422,7 +2422,7 @@ func (s *DataStore) SearchLabels(ctx context.Context, filter fleet.TeamFilter, q
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.LabelIDsByNameFuncInvoked = true
s.mu.Unlock()

View File

@ -15,7 +15,6 @@ import (
"net/url"
"github.com/fleetdm/fleet/v4/pkg/rawjson"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"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
// server at startup and can't be modified by the user
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",
`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.WindowsSettings.CustomSettings.Set &&
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",
`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}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
},
}, {
name: "newDefaultTeamNoLicense",
@ -843,7 +845,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
},
}, {
name: "foundEdit",
@ -857,7 +861,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
},
}, {
name: "ssoFree",
@ -877,7 +883,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
},
}, {
name: "ssoAllFields",
@ -900,7 +908,9 @@ func TestMDMAppleConfig(t *testing.T) {
MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}},
WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}},
EnableDiskEncryption: optjson.Bool{Set: true, Valid: false},
WindowsSettings: fleet.WindowsSettings{CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}}},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
},
}, {
name: "ssoShortEntityID",

View File

@ -298,7 +298,8 @@ func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{},
return &newMDMAppleConfigProfileResponse{Err: err}, nil
}
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 {
return &newMDMAppleConfigProfileResponse{Err: err}, nil
}
@ -307,7 +308,7 @@ func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{},
}, 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 {
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()})
}
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)
if err != nil {
var existsErr existsErrorInterface

View File

@ -593,11 +593,11 @@ func TestMDMAppleConfigProfileAuthz(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
// 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)
// 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)
// test authz list profiles (no team)
@ -659,7 +659,7 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
return nil
}
cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r)
cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil)
require.NoError(t, err)
require.Equal(t, "Foo", cp.Name)
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")
}
labelIDs, err := svc.ds.LabelIDsByName(ctx, labels)
labelMap, err := svc.ds.LabelIDsByName(ctx, labels)
if err != nil {
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}
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) {
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
}
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)
}
// getProfilesContents takes file paths and creates a map of profile contents
// keyed by the name of the profile (the file name on Windows,
// PayloadDisplayName on macOS)
func getProfilesContents(baseDir string, paths []string) (map[string][]byte, error) {
files := resolveApplyRelativePaths(baseDir, paths)
fileContents := make(map[string][]byte, len(files))
for _, f := range files {
b, err := os.ReadFile(f)
// getProfilesContents takes file paths and creates a slice of profile payloads
// ready to batch-apply.
func getProfilesContents(baseDir string, profiles []fleet.MDMProfileSpec) ([]fleet.MDMProfileBatchPayload, error) {
fileNameMap := make(map[string]struct{}, len(profiles))
result := make([]fleet.MDMProfileBatchPayload, 0, len(profiles))
for _, profile := range profiles {
filePath := resolveApplyRelativePath(baseDir, profile.Path)
fileContents, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("applying fleet config: %w", err)
}
// by default, use the file name. macOS profiles use their PayloadDisplayName
name := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
if mdm.GetRawProfilePlatform(b) == "darwin" {
mc, err := fleet.NewMDMAppleConfigProfile(b, nil)
name := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if mdm.GetRawProfilePlatform(fileContents) == "darwin" {
mc, err := fleet.NewMDMAppleConfigProfile(fileContents, nil)
if err != nil {
return nil, fmt.Errorf("applying fleet config: %w", err)
}
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).")
}
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.
@ -384,7 +390,14 @@ func (c *Client) ApplyGroup(
macosCustomSettings := extractAppCfgMacOSCustomSettings(specs.AppConfig)
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)
if err != nil {
return err
@ -461,7 +474,7 @@ func (c *Client) ApplyGroup(
// that any non-existing file error is found before applying the specs.
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 {
fileContents, err := getProfilesContents(baseDir, paths)
if err != nil {
@ -606,7 +619,7 @@ func resolveApplyRelativePaths(baseDir string, paths []string) []string {
return resolved
}
func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string {
func extractAppCfgCustomSettings(appCfg interface{}, platformKey string) []fleet.MDMProfileSpec {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
return nil
@ -615,7 +628,7 @@ func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string {
if !ok {
return nil
}
mos, ok := mmdm["macos_settings"].(map[string]interface{})
mos, ok := mmdm[platformKey].(map[string]interface{})
if !ok || mos == nil {
return nil
}
@ -630,54 +643,46 @@ func extractAppCfgMacOSCustomSettings(appCfg interface{}) []string {
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{}
return []fleet.MDMProfileSpec{}
}
csStrings := make([]string, 0, len(csAny))
csSpecs := make([]fleet.MDMProfileSpec, 0, len(csAny))
for _, v := range csAny {
s, _ := v.(string)
if s != "" {
csStrings = append(csStrings, s)
if m, ok := v.(map[string]interface{}); ok {
var profSpec fleet.MDMProfileSpec
// 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)
}
}
return csStrings
}
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 csSpecs
}
func extractAppCfgWindowsCustomSettings(appCfg interface{}) []string {
asMap, ok := appCfg.(map[string]interface{})
if !ok {
return nil
}
mmdm, ok := asMap["mdm"].(map[string]interface{})
if !ok {
return nil
}
mos, ok := mmdm["windows_settings"].(map[string]interface{})
if !ok || mos == nil {
return nil
}
func extractAppCfgMacOSCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
return extractAppCfgCustomSettings(appCfg, "macos_settings")
}
cs, ok := mos["custom_settings"]
if !ok {
// custom settings is not present
return nil
}
csAny, ok := cs.([]interface{})
if !ok || csAny == nil {
// return a non-nil, empty slice instead, so the caller knows that the
// custom_settings key was actually provided.
return []string{}
}
csStrings := make([]string, 0, len(csAny))
for _, v := range csAny {
s, _ := v.(string)
if s != "" {
csStrings = append(csStrings, s)
}
}
return csStrings
func extractAppCfgWindowsCustomSettings(appCfg interface{}) []fleet.MDMProfileSpec {
return extractAppCfgCustomSettings(appCfg, "windows_settings")
}
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.
func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]string {
var m map[string][]string
func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]fleet.MDMProfileSpec {
var m map[string][]fleet.MDMProfileSpec
for _, tm := range tmSpecs {
var spec struct {
Name string `json:"name"`
@ -729,15 +734,15 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]str
continue
}
if spec.Name != "" {
var macOSSettings []string
var windowsSettings []string
var macOSSettings []fleet.MDMProfileSpec
var windowsSettings []fleet.MDMProfileSpec
// to keep existing bahavior, if any of the custom
// settings is provided, make the map a non-nil map
if len(spec.MDM.MacOSSettings.CustomSettings) > 0 ||
len(spec.MDM.WindowsSettings.CustomSettings) > 0 {
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 {
// to be consistent with the AppConfig custom settings, set it to an
// empty slice if the provided custom settings are present but empty.
macOSSettings = []string{}
macOSSettings = []fleet.MDMProfileSpec{}
}
}
if len(spec.MDM.WindowsSettings.CustomSettings) > 0 {
@ -760,10 +765,11 @@ func extractTmSpecsMDMCustomSettings(tmSpecs []json.RawMessage) map[string][]str
if windowsSettings == nil {
// to be consistent with the AppConfig custom settings, set it to an
// 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 {
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
// 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"
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
// 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"
query, err := url.ParseQuery(opts.RawQuery())
if err != nil {

View File

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

View File

@ -3713,7 +3713,7 @@ func (s *integrationTestSuite) TestListHostsByLabel() {
lblIDs, err := s.ds.LabelIDsByName(context.Background(), []string{"All Hosts"})
require.NoError(t, err)
require.Len(t, lblIDs, 1)
labelID := lblIDs[0]
labelID := lblIDs["All Hosts"]
hosts := s.createHosts(t, "darwin")
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)
// create a saved script
body, headers := generateNewScriptMultipartRequest(t, nil,
"myscript.sh", []byte(`echo "hello"`), s.token)
body, headers := generateNewScriptMultipartRequest(t,
"myscript.sh", []byte(`echo "hello"`), s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusPaymentRequired, headers)
// delete a saved script
@ -6160,9 +6160,9 @@ func (s *integrationTestSuite) TestSearchTargets() {
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.Len(t, lblIDs, 1)
require.Len(t, lblMap, 1)
// no search criteria
var searchResp searchTargetsResponse
@ -6172,6 +6172,11 @@ func (s *integrationTestSuite) TestSearchTargets() {
require.Len(t, searchResp.Targets.Labels, 1)
require.Len(t, searchResp.Targets.Teams, 0)
var lblIDs []uint
for _, labelID := range lblMap {
lblIDs = append(lblIDs, labelID)
}
searchResp = searchTargetsResponse{}
s.DoJSON("POST", "/api/latest/fleet/targets", searchTargetsRequest{Selected: fleet.HostTargets{LabelIDs: lblIDs}}, http.StatusOK, &searchResp)
require.Equal(t, uint(0), searchResp.TargetsCount)
@ -6272,12 +6277,12 @@ func (s *integrationTestSuite) TestCountTargets() {
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.Len(t, lblIDs, 1)
require.Len(t, lblMap, 1)
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)
}
@ -6299,6 +6304,10 @@ func (s *integrationTestSuite) TestCountTargets() {
require.Equal(t, uint(0), countResp.TargetsOnline)
require.Equal(t, uint(0), countResp.TargetsOffline)
var lblIDs []uint
for _, labelID := range lblMap {
lblIDs = append(lblIDs, labelID)
}
// all hosts label selected
countResp = countTargetsResponse{}
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()})
require.NoError(t, err)
require.Len(t, lids, 1)
customLabelID := lids[0]
customLabelID := lids[t.Name()]
// 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"})

View File

@ -148,7 +148,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
// it did get marshalled, and then when unmarshalled it was set (but
// empty).
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}},
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, team.Config.MDM)
@ -206,7 +206,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
BootstrapPackage: optjson.String{Set: true},
},
WindowsSettings: fleet.WindowsSettings{
CustomSettings: optjson.Slice[string]{Set: true, Value: []string{}},
CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}},
},
}, team.Config.MDM)
@ -227,7 +227,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
BootstrapPackage: optjson.String{Set: true},
},
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)
@ -250,7 +250,7 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() {
BootstrapPackage: optjson.String{Set: true},
},
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)
@ -1911,7 +1911,7 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() {
BootstrapPackage: optjson.String{Set: true},
},
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)
@ -4998,8 +4998,8 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
// create a saved script for no team
var newScriptResp createScriptResponse
body, headers := generateNewScriptMultipartRequest(t, nil,
"script1.sh", []byte(`echo "hello"`), s.token)
body, headers := generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, nil)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusOK, headers)
err := json.NewDecoder(res.Body).Decode(&newScriptResp)
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")
// file name is empty
body, headers = generateNewScriptMultipartRequest(t, nil,
"", []byte(`echo "hello"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusBadRequest, headers)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "no file headers for script")
// file name is not .sh
body, headers = generateNewScriptMultipartRequest(t, nil,
"not_sh.txt", []byte(`echo "hello"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"not_sh.txt", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Validation Failed: File type not supported. Only .sh and .ps1 file type is allowed.")
// file content is empty
body, headers = generateNewScriptMultipartRequest(t, nil,
"script2.sh", []byte(``), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(``), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script contents must not be empty")
// file content is too large
body, headers = generateNewScriptMultipartRequest(t, nil,
"script2.sh", []byte(strings.Repeat("a", 10001)), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(strings.Repeat("a", 10001)), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Script is too large. It's limited to 10,000 characters")
// invalid hashbang
body, headers = generateNewScriptMultipartRequest(t, nil,
"script2.sh", []byte(`#!/bin/python`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"script2.sh", []byte(`#!/bin/python`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Interpreter not supported.")
// script already exists with this name for this no-team
body, headers = generateNewScriptMultipartRequest(t, nil,
"script1.sh", []byte(`echo "hello"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"script1.sh", []byte(`echo "hello"`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/scripts", body.Bytes(), http.StatusConflict, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A script with this name already exists")
// team id does not exist
body, headers = generateNewScriptMultipartRequest(t, ptr.Uint(123),
"script1.sh", []byte(`echo "hello"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"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)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "The team does not exist.")
@ -5084,8 +5084,8 @@ func (s *integrationEnterpriseTestSuite) TestSavedScripts() {
require.NoError(t, err)
// create with existing name for this time for a team
body, headers = generateNewScriptMultipartRequest(t, &tm.ID,
"script1.sh", []byte(`echo "team"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"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)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
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)
// create a windows script
body, headers = generateNewScriptMultipartRequest(t, &tm.ID,
"script2.ps1", []byte(`Write-Host "Hello, World!"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"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)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
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"))
// script already exists with this name for this team
body, headers = generateNewScriptMultipartRequest(t, &tm.ID,
"script1.sh", []byte(`echo "hello"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"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)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A script with this name already exists")
// create with a different name for this team
body, headers = generateNewScriptMultipartRequest(t, &tm.ID,
"script2.sh", []byte(`echo "hello"`), s.token)
body, headers = generateNewScriptMultipartRequest(t,
"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)
err = json.NewDecoder(res.Body).Decode(&newScriptResp)
require.NoError(t, err)
@ -5657,10 +5660,10 @@ VALUES
// generates the body and headers part of a multipart request ready to be
// used via s.DoRawWithHeaders to POST /api/_version_/fleet/scripts.
func generateNewScriptMultipartRequest(t *testing.T, tmID *uint,
fileName string, fileContent []byte, token string,
func generateNewScriptMultipartRequest(t *testing.T,
fileName string, fileContent []byte, token string, extraFields 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() {

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"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,
// or 0 and false otherwise.
func isAppleProfileUUID(profileUUID string) bool {
if strings.HasPrefix(profileUUID, "a") {
return true
}
return false
return strings.HasPrefix(profileUUID, "a")
}
////////////////////////////////////////////////////////////////////////////////
@ -1165,6 +1164,7 @@ func isAppleProfileUUID(profileUUID string) bool {
type newMDMConfigProfileRequest struct {
TeamID uint
Profile *multipart.FileHeader
Labels []string
}
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"]
if !ok || len(val) < 1 {
// default is no team
@ -1190,12 +1191,16 @@ func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Req
decoded.TeamID = uint(teamID)
}
// add profile
fhs, ok := r.MultipartForm.File["profile"]
if !ok || len(fhs) < 1 {
return nil, &fleet.BadRequestError{Message: "no file headers for profile"}
}
decoded.Profile = fhs[0]
// add labels
decoded.Labels = r.MultipartForm.Value["labels"]
return &decoded, nil
}
@ -1217,7 +1222,7 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
fileExt := filepath.Ext(req.Profile.Filename)
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 {
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 {
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 {
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."}
}
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 {
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")
}
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)
if err != nil {
var existsErr existsErrorInterface
@ -1330,6 +1341,53 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint,
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
////////////////////////////////////////////////////////////////////////////////
@ -1338,7 +1396,37 @@ type batchSetMDMProfilesRequest struct {
TeamID *uint `json:"-" query:"team_id,optional"`
TeamName *string `json:"-" query:"team_name,optional"`
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 {
@ -1357,7 +1445,7 @@ func batchSetMDMProfilesEndpoint(ctx context.Context, request interface{}, svc f
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
if tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName); err != nil {
return err
@ -1372,12 +1460,21 @@ func (svc *Service) BatchSetMDMProfiles(ctx context.Context, tmID *uint, tmName
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 {
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 {
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
}
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
profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles))
byName, byIdent := make(map[string]bool, len(profiles)), make(map[string]bool, len(profiles))
for i, prof := range profiles {
if mdm.GetRawProfilePlatform(prof) != "darwin" {
for _, prof := range profiles {
if mdm.GetRawProfilePlatform(prof.Contents) != "darwin" {
continue
}
mdmProf, err := fleet.NewMDMAppleConfigProfile(prof, tmID)
mdmProf, err := fleet.NewMDMAppleConfigProfile(prof.Contents, tmID)
if err != nil {
return nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", i), err.Error()),
fleet.NewInvalidArgumentError(prof.Name, err.Error()),
"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 {
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,
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")
}
if byName[mdmProf.Name] {
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")
}
byName[mdmProf.Name] = true
if byIdent[mdmProf.Identifier] {
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")
}
byIdent[mdmProf.Identifier] = true
@ -1525,23 +1634,34 @@ func getAppleProfiles(ctx context.Context, tmID *uint, appCfg *fleet.AppConfig,
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))
for name, syncML := range profiles {
if mdm.GetRawProfilePlatform(syncML) != "windows" {
for _, profile := range profiles {
if mdm.GetRawProfilePlatform(profile.Contents) != "windows" {
continue
}
mdmProf := &fleet.MDMWindowsConfigProfile{
TeamID: tmID,
Name: name,
SyncML: syncML,
Name: profile.Name,
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 {
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)
@ -1563,9 +1683,9 @@ func getWindowsProfiles(ctx context.Context, tmID *uint, appCfg *fleet.AppConfig
return profs, nil
}
func validateProfiles(profiles map[string][]byte) error {
for _, rawBytes := range profiles {
platform := mdm.GetRawProfilePlatform(rawBytes)
func validateProfiles(profiles []fleet.MDMProfileBatchPayload) error {
for _, profile := range profiles {
platform := mdm.GetRawProfilePlatform(profile.Contents)
if platform != "darwin" && platform != "windows" {
// 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.")

View File

@ -971,11 +971,11 @@ func TestMDMWindowsConfigProfileAuthz(t *testing.T) {
checkShouldFail(t, err, tt.shouldFailTeamRead)
// 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)
// 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)
// test authz delete config profile (no team)
@ -1057,7 +1057,7 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) {
}, nil
}
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 != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.wantErr)
@ -1109,7 +1109,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
premium bool
teamID *uint
teamName *string
profiles map[string][]byte
profiles []fleet.MDMProfileBatchPayload
wantErr string
}{
{
@ -1271,9 +1271,9 @@ func TestMDMBatchSetProfiles(t *testing.T) {
true,
ptr.Uint(1),
nil,
map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": mobileconfigForTest("N1", "I2"),
[]fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
},
`The name provided for the profile must match the profile PayloadDisplayName: "N1"`,
},
@ -1283,10 +1283,10 @@ func TestMDMBatchSetProfiles(t *testing.T) {
true,
ptr.Uint(1),
nil,
map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": mobileconfigForTest("N2", "I2"),
"N3": mobileconfigForTest("N3", "I1"),
[]fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N2", "I2")},
{Name: "N3", Contents: mobileconfigForTest("N3", "I1")},
},
`More than one configuration profile have the same identifier (PayloadIdentifier): "I1"`,
},
@ -1296,10 +1296,10 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false,
nil,
nil,
map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": mobileconfigForTest("N2", "I2"),
"N3": mobileconfigForTest("N3", "I3"),
[]fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N2", "I2")},
{Name: "N3", Contents: mobileconfigForTest("N3", "I3")},
},
``,
},
@ -1309,13 +1309,13 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false,
nil,
nil,
map[string][]byte{
"N1": syncMLForTest("./foo/bar"),
"N2": syncMLForTest("./baz"),
"N3": syncMLForTest("./zab"),
"N4": mobileconfigForTest("N4", "I1"),
"N5": mobileconfigForTest("N5", "I2"),
"N6": mobileconfigForTest("N6", "I3"),
[]fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: syncMLForTest("./foo/bar")},
{Name: "N2", Contents: syncMLForTest("./baz")},
{Name: "N3", Contents: syncMLForTest("./zab")},
{Name: "N4", Contents: mobileconfigForTest("N4", "I1")},
{Name: "N5", Contents: mobileconfigForTest("N5", "I2")},
{Name: "N6", Contents: mobileconfigForTest("N6", "I3")},
},
``,
},
@ -1325,10 +1325,10 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false,
nil,
nil,
map[string][]byte{
"N1": syncMLForTest("./foo/bar"),
"N2": syncMLForTest("./baz"),
"N3": syncMLForTest("./zab"),
[]fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: syncMLForTest("./foo/bar")},
{Name: "N2", Contents: syncMLForTest("./baz")},
{Name: "N3", Contents: syncMLForTest("./zab")},
},
``,
},
@ -1338,8 +1338,8 @@ func TestMDMBatchSetProfiles(t *testing.T) {
false,
nil,
nil,
map[string][]byte{
"foo": []byte(`<?xml version="1.0" encoding="UTF-8"?>
[]fleet.MDMProfileBatchPayload{
{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">
<plist version="1.0">
<dict>
@ -1372,7 +1372,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
<integer>1</integer>
</dict>
</plist>`),
},
}},
"unsupported PayloadType(s)",
},
}
@ -1405,42 +1405,42 @@ func TestMDMBatchSetProfiles(t *testing.T) {
func TestValidateProfiles(t *testing.T) {
tests := []struct {
name string
profiles map[string][]byte
profiles []fleet.MDMProfileBatchPayload
wantErr bool
}{
{
name: "Valid Darwin Profile",
profiles: map[string][]byte{
"darwinProfile": []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"),
profiles: []fleet.MDMProfileBatchPayload{
{Name: "darwinProfile", Contents: []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")},
},
wantErr: false,
},
{
name: "Valid Windows Profile",
profiles: map[string][]byte{
"windowsProfile": []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>"),
profiles: []fleet.MDMProfileBatchPayload{
{Name: "windowsProfile", Contents: []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>")},
},
wantErr: false,
},
{
name: "Invalid Profile",
profiles: map[string][]byte{
"invalidProfile": []byte("invalid data"),
profiles: []fleet.MDMProfileBatchPayload{
{Name: "invalidProfile", Contents: []byte("invalid data")},
},
wantErr: true,
},
{
name: "Mixed Valid and Invalid Profiles",
profiles: map[string][]byte{
"validProfile": []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"),
"invalidProfile": []byte("invalid data"),
profiles: []fleet.MDMProfileBatchPayload{
{Name: "validProfile", Contents: []byte("<?xml version=\"1.0\" encoding=\"UTF-8\"?>")},
{Name: "invalidProfile", Contents: []byte("invalid data")},
},
wantErr: true,
},
{
name: "Empty Profile",
profiles: map[string][]byte{
"emptyProfile": []byte(""),
profiles: []fleet.MDMProfileBatchPayload{
{Name: "emptyProfile", Contents: []byte("")},
},
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 {
return 0, err
}
return labelIDs[0], nil
return labelIDs[identifier], nil
}
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
}
// 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/server/fleet/WindowsUpdates GracePeriodDays optjson.Int
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/MDM MacOSSetup fleet.MacOSSetup
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 Value bool
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] Valid bool
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 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/server/fleet/WindowsUpdates GracePeriodDays optjson.Int
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/TeamMDM MacOSSetup fleet.MacOSSetup
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup BootstrapPackage optjson.String
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup EnableEndUserAuthentication bool
github.com/fleetdm/fleet/v4/server/fleet/MacOSSetup MacOSSetupAssistant optjson.String
github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsSettings fleet.WindowsSettings
github.com/fleetdm/fleet/v4/server/fleet/WindowsSettings CustomSettings optjson.Slice[string]
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] Value []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

View File

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

View File

@ -240,8 +240,296 @@ func main() {
}
}
var profiles = map[string][]byte{
"Disable Bluetooth sharing": []byte(`<?xml version="1.0" encoding="UTF-8"?>
var profiles = []fleet.MDMProfileBatchPayload{
{
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">
<plist version="1.0">
<dict>
@ -301,118 +589,10 @@ var profiles = map[string][]byte{
</array>
</dict>
</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">
<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>`),
"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"?>
},
{
Name: "Ensure Install Application Updates from the App Store 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>
@ -449,83 +629,10 @@ var profiles = map[string][]byte{
<integer>1</integer>
</dict>
</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">
<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>`),
"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"?>
},
{
Name: "Disable iCloud Drive storage solution usage",
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>
@ -562,84 +669,7 @@ var profiles = map[string][]byte{
<integer>1</integer>
</dict>
</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"?>