mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
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:
parent
901004e149
commit
7d00d5a41e
1
changes/15217-profile-labels-ui
Normal file
1
changes/15217-profile-labels-ui
Normal file
@ -0,0 +1 @@
|
||||
- Updated UI with ability to target MDM profiles by label.
|
1
changes/15218-profile-labels-api
Normal file
1
changes/15218-profile-labels-api
Normal 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).
|
1
changes/15286-profile-labels-reconciliation
Normal file
1
changes/15286-profile-labels-reconciliation
Normal file
@ -0,0 +1 @@
|
||||
* Added support for label-based MDM profiles reconciliation.
|
1
changes/15287-profile-verifier
Normal file
1
changes/15287-profile-verifier
Normal file
@ -0,0 +1 @@
|
||||
- Adds ability for labeled profiles to be fetched for verification.
|
@ -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"),
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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",
|
||||
`Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`))
|
||||
}
|
||||
@ -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"
|
||||
|
22
frontend/components/icons/Upload.tsx
Normal file
22
frontend/components/icons/Upload.tsx
Normal 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;
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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";
|
||||
|
@ -14,4 +14,8 @@
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.side-nav__card-container > .custom-settings {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 won’t 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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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={`${subClass}__action-button`}
|
||||
variant="text-icon"
|
||||
onClick={() => setProfileLabelsModalData({ ...profile })}
|
||||
>
|
||||
<Icon name="filter" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__action-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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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 =
|
||||
"Couldn’t 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;
|
||||
};
|
||||
|
@ -1 +0,0 @@
|
||||
export { default } from "./ProfileUploader";
|
@ -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);
|
||||
},
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -38,31 +38,48 @@ INSERT INTO
|
||||
teamID = *cp.TeamID
|
||||
}
|
||||
|
||||
res, err := ds.writer(ctx).ExecContext(ctx, stmt,
|
||||
profUUID, teamID, cp.Identifier, cp.Name, cp.Mobileconfig, cp.Mobileconfig, cp.Name, teamID)
|
||||
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 ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
|
||||
default:
|
||||
return ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
|
||||
}
|
||||
}
|
||||
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff == 0 {
|
||||
return &existsError{
|
||||
ResourceType: "MDMAppleConfigProfile.PayloadDisplayName",
|
||||
Identifier: cp.Name,
|
||||
TeamID: cp.TeamID,
|
||||
}
|
||||
}
|
||||
|
||||
// record the ID as we want to return a fleet.Profile instance with it
|
||||
// filled in.
|
||||
profileID, _ = res.LastInsertId()
|
||||
|
||||
for i := range cp.Labels {
|
||||
cp.Labels[i].ProfileUUID = profUUID
|
||||
}
|
||||
if err := batchSetProfileLabelAssociationsDB(ctx, tx, cp.Labels, "darwin"); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDuplicate(err):
|
||||
return nil, ctxerr.Wrap(ctx, formatErrorDuplicateConfigProfile(err, &cp))
|
||||
default:
|
||||
return nil, ctxerr.Wrap(ctx, err, "creating new apple mdm config profile")
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "inserting profile and label associations")
|
||||
}
|
||||
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff == 0 {
|
||||
return nil, &existsError{
|
||||
ResourceType: "MDMAppleConfigProfile.PayloadDisplayName",
|
||||
Identifier: cp.Name,
|
||||
TeamID: cp.TeamID,
|
||||
}
|
||||
}
|
||||
|
||||
id, _ := res.LastInsertId()
|
||||
|
||||
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,39 +1381,50 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
|
||||
return nil
|
||||
}
|
||||
|
||||
const desiredStateStmt = `
|
||||
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
|
||||
// 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 ( %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
|
||||
WHERE
|
||||
-- profile has been updated
|
||||
( hmap.checksum != ds.checksum ) OR
|
||||
-- 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,39 +1435,40 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
|
||||
return ctxerr.Wrap(ctx, err, "bulk set pending profile status execute")
|
||||
}
|
||||
|
||||
const currentStateStmt = `
|
||||
SELECT
|
||||
hmap.profile_uuid as profile_uuid,
|
||||
hmap.host_uuid as host_uuid,
|
||||
hmap.profile_identifier as profile_identifier,
|
||||
hmap.profile_name as profile_name,
|
||||
hmap.checksum as checksum,
|
||||
hmap.status as status,
|
||||
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
|
||||
toRemoveStmt := fmt.Sprintf(`
|
||||
SELECT
|
||||
hmap.profile_uuid as profile_uuid,
|
||||
hmap.host_uuid as host_uuid,
|
||||
hmap.profile_identifier as profile_identifier,
|
||||
hmap.profile_name as profile_name,
|
||||
hmap.checksum as checksum,
|
||||
hmap.status as status,
|
||||
hmap.operation_type as operation_type,
|
||||
COALESCE(hmap.detail, '') as detail,
|
||||
hmap.command_uuid as command_uuid
|
||||
FROM ( %s ) as ds
|
||||
RIGHT JOIN host_mdm_apple_profiles hmap
|
||||
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.uuid
|
||||
WHERE
|
||||
hmap.host_uuid IN (?)
|
||||
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
|
||||
WHERE
|
||||
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,39 +1699,35 @@ 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 := `
|
||||
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
|
||||
LEFT JOIN host_mdm_apple_profiles hmap
|
||||
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
|
||||
WHERE
|
||||
-- profile has been updated
|
||||
( hmap.checksum != ds.checksum ) OR
|
||||
-- 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 ) ) 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 )
|
||||
`
|
||||
// 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 ( %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
|
||||
-- profile has been updated
|
||||
( hmap.checksum != ds.checksum ) OR
|
||||
-- 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 ) ) 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,31 +1753,43 @@ func (ds *Datastore) ListMDMAppleProfilesToRemove(ctx context.Context) ([]*fleet
|
||||
// Any other case are profiles that are in both B and A, and as such are
|
||||
// processed by the ListMDMAppleProfilesToInstall method (since they are in
|
||||
// both, their desired state is necessarily to be installed).
|
||||
query := `
|
||||
SELECT
|
||||
hmap.profile_uuid,
|
||||
hmap.profile_identifier,
|
||||
hmap.profile_name,
|
||||
hmap.host_uuid,
|
||||
hmap.checksum,
|
||||
hmap.operation_type,
|
||||
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
|
||||
RIGHT JOIN host_mdm_apple_profiles hmap
|
||||
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.uuid
|
||||
-- profiles that are in B but not in A
|
||||
WHERE ds.profile_uuid IS NULL AND ds.uuid IS NULL
|
||||
-- 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 )
|
||||
`
|
||||
//
|
||||
// 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,
|
||||
hmap.profile_name,
|
||||
hmap.host_uuid,
|
||||
hmap.checksum,
|
||||
hmap.operation_type,
|
||||
COALESCE(hmap.detail, '') as detail,
|
||||
hmap.status,
|
||||
hmap.command_uuid
|
||||
FROM ( %s ) as ds
|
||||
RIGHT JOIN host_mdm_apple_profiles hmap
|
||||
ON hmap.profile_uuid = ds.profile_uuid AND hmap.host_uuid = ds.host_uuid
|
||||
WHERE
|
||||
-- profiles that are in B but not in A
|
||||
ds.profile_uuid IS NULL AND ds.host_uuid IS NULL AND
|
||||
-- except "remove" operations in a terminal state or already pending
|
||||
( hmap.operation_type IS NULL OR hmap.operation_type != ? OR hmap.status IS NULL ) AND
|
||||
-- except "would be removed" profiles if they are a broken label-based profile
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM mdm_configuration_profile_labels mcpl
|
||||
WHERE
|
||||
mcpl.apple_profile_uuid = hmap.profile_uuid AND
|
||||
mcpl.label_id IS NULL
|
||||
)
|
||||
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "TRUE", "TRUE"))
|
||||
|
||||
var profiles []*fleet.MDMAppleProfilePayload
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, query, fleet.MDMOperationTypeRemove)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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
@ -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,26 +1170,26 @@ 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 := `
|
||||
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
|
||||
LEFT JOIN host_mdm_windows_profiles hmwp
|
||||
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid
|
||||
WHERE
|
||||
-- profiles in A but not in B
|
||||
( 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 )
|
||||
`
|
||||
//
|
||||
// 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 ( %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
|
||||
-- profiles in A but not in B
|
||||
( 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,39 +1237,51 @@ 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 := `
|
||||
SELECT
|
||||
hmwp.profile_uuid,
|
||||
hmwp.host_uuid,
|
||||
hmwp.operation_type,
|
||||
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
|
||||
RIGHT JOIN host_mdm_windows_profiles hmwp
|
||||
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.uuid
|
||||
-- profiles that are in B but not in A
|
||||
WHERE ds.profile_uuid IS NULL
|
||||
AND ds.uuid IS NULL
|
||||
AND (%s)
|
||||
`
|
||||
// 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,
|
||||
hmwp.operation_type,
|
||||
COALESCE(hmwp.detail, '') as detail,
|
||||
hmwp.status,
|
||||
hmwp.command_uuid
|
||||
FROM ( %s ) as ds
|
||||
RIGHT JOIN host_mdm_windows_profiles hmwp
|
||||
ON hmwp.profile_uuid = ds.profile_uuid AND hmwp.host_uuid = ds.host_uuid
|
||||
WHERE
|
||||
-- profiles that are in B but not in A
|
||||
ds.profile_uuid IS NULL AND ds.host_uuid IS NULL AND
|
||||
-- TODO(mna): why don't we have the same exception for "remove" operations as for Apple
|
||||
|
||||
-- except "would be removed" profiles if they are a broken label-based profile
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM mdm_configuration_profile_labels mcpl
|
||||
WHERE
|
||||
mcpl.windows_profile_uuid = hmwp.profile_uuid AND
|
||||
mcpl.label_id IS NULL
|
||||
) AND
|
||||
(%s)
|
||||
`, fmt.Sprintf(windowsMDMProfilesDesiredStateQuery, "TRUE", "TRUE"), hostFilter)
|
||||
|
||||
var err error
|
||||
var 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,27 +1471,41 @@ INSERT INTO
|
||||
teamID = *cp.TeamID
|
||||
}
|
||||
|
||||
res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case isDuplicate(err):
|
||||
return nil, &existsError{
|
||||
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 &existsError{
|
||||
ResourceType: "MDMWindowsConfigProfile.Name",
|
||||
Identifier: cp.Name,
|
||||
TeamID: cp.TeamID,
|
||||
}
|
||||
default:
|
||||
return ctxerr.Wrap(ctx, err, "creating new windows mdm config profile")
|
||||
}
|
||||
}
|
||||
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff == 0 {
|
||||
return &existsError{
|
||||
ResourceType: "MDMWindowsConfigProfile.Name",
|
||||
Identifier: cp.Name,
|
||||
TeamID: cp.TeamID,
|
||||
}
|
||||
default:
|
||||
return nil, ctxerr.Wrap(ctx, err, "creating new windows mdm config profile")
|
||||
}
|
||||
}
|
||||
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff == 0 {
|
||||
return nil, &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{
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
@ -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)
|
||||
})
|
||||
|
@ -298,8 +298,8 @@ 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"`
|
||||
DeprecatedEnableDiskEncryption *bool `json:"enable_disk_encryption,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
@ -195,9 +195,23 @@ type MDMAppleConfigProfile struct {
|
||||
// representation of the configuration profile. It must be XML or PKCS7 parseable.
|
||||
Mobileconfig mobileconfig.Mobileconfig `db:"mobileconfig" json:"-"`
|
||||
// Checksum is an MD5 hash of the Mobileconfig bytes
|
||||
Checksum []byte `db:"checksum" json:"checksum,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
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) {
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
@ -351,14 +357,23 @@ func (m MDMConfigProfileAuthz) AuthzType() string {
|
||||
// MDMConfigProfilePayload is the platform-agnostic struct returned by
|
||||
// endpoints that return MDM configuration profiles (get/list profiles).
|
||||
type MDMConfigProfilePayload struct {
|
||||
ProfileUUID string `json:"profile_uuid" db:"profile_uuid"`
|
||||
TeamID *uint `json:"team_id" db:"team_id"` // null for no-team
|
||||
Name string `json:"name" db:"name"`
|
||||
Platform string `json:"platform" db:"platform"` // "windows" or "darwin"
|
||||
Identifier string `json:"identifier,omitempty" db:"identifier"` // only set for macOS
|
||||
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"`
|
||||
ProfileUUID string `json:"profile_uuid" db:"profile_uuid"`
|
||||
TeamID *uint `json:"team_id" db:"team_id"` // null for no-team
|
||||
Name string `json:"name" db:"name"`
|
||||
Platform string `json:"platform" db:"platform"` // "windows" or "darwin"
|
||||
Identifier string `json:"identifier,omitempty" db:"identifier"` // only set for macOS
|
||||
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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
@ -31,12 +31,13 @@ type MDMWindowsBitLockerSummary struct {
|
||||
type MDMWindowsConfigProfile struct {
|
||||
// ProfileUUID is the unique identifier of the configuration profile in
|
||||
// Fleet. For Windows profiles, it is the letter "w" followed by a uuid.
|
||||
ProfileUUID string `db:"profile_uuid" json:"profile_uuid"`
|
||||
TeamID *uint `db:"team_id" json:"team_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
SyncML []byte `db:"syncml" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ProfileUUID string `db:"profile_uuid" json:"profile_uuid"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// ValidateUserProvided ensures that the SyncML content in the profile is valid
|
||||
|
@ -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()
|
||||
|
@ -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",
|
||||
`Couldn’t edit windows_settings.custom_settings. Windows MDM isn’t turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if profSpec.Path != "" {
|
||||
csSpecs = append(csSpecs, profSpec)
|
||||
}
|
||||
} else if m, ok := v.(string); ok { // for backwards compatibility with the old way to define profiles
|
||||
if m != "" {
|
||||
csSpecs = append(csSpecs, fleet.MDMProfileSpec{Path: m})
|
||||
}
|
||||
}
|
||||
}
|
||||
return csStrings
|
||||
return csSpecs
|
||||
}
|
||||
|
||||
func extractAppCfgWindowsCustomSettings(appCfg interface{}) []string {
|
||||
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...)
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
expectError bool
|
||||
expectedKeys []string
|
||||
name string
|
||||
baseDir string
|
||||
setupFiles [][2]string
|
||||
labels []string
|
||||
expectError bool
|
||||
want []fleet.MDMProfileBatchPayload
|
||||
}{
|
||||
{
|
||||
name: "invalid darwin xml",
|
||||
@ -350,34 +504,54 @@ func TestGetProfilesContents(t *testing.T) {
|
||||
setupFiles: [][2]string{
|
||||
{"foo.mobileconfig", `<?xml version="1.0" encoding="UTF-8"?>`},
|
||||
},
|
||||
expectError: true,
|
||||
expectedKeys: []string{"foo"},
|
||||
expectError: true,
|
||||
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,
|
||||
want: []fleet.MDMProfileBatchPayload{
|
||||
{Name: "foo", Contents: windowsProfile},
|
||||
{Name: "bar", Contents: darwinProfile},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windows and darwin files with labels",
|
||||
baseDir: tempDir,
|
||||
setupFiles: [][2]string{
|
||||
{"foo.xml", string(windowsProfile)},
|
||||
{"bar.mobileconfig", string(darwinProfile)},
|
||||
},
|
||||
labels: []string{"foo", "bar"},
|
||||
expectError: false,
|
||||
want: []fleet.MDMProfileBatchPayload{
|
||||
{Name: "foo", Contents: windowsProfile, Labels: []string{"foo", "bar"}},
|
||||
{Name: "bar", Contents: darwinProfile, Labels: []string{"foo", "bar"}},
|
||||
},
|
||||
expectError: false,
|
||||
expectedKeys: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "darwin files with file name != PayloadDisplayName",
|
||||
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,
|
||||
want: []fleet.MDMProfileBatchPayload{
|
||||
{Name: "foo", Contents: windowsProfile},
|
||||
{Name: "bar", Contents: darwinProfile},
|
||||
},
|
||||
expectError: false,
|
||||
expectedKeys: []string{"foo", "fizz"},
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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"})
|
||||
|
@ -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
@ -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,15 +1341,92 @@ 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
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
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"`
|
||||
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 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("Couldn’t edit custom_settings. The name provided for the profile must match the profile PayloadDisplayName: %q", mdmProf.Name)),
|
||||
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldn’t 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("Couldn’t edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
|
||||
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldn’t 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("Couldn’t edit custom_settings. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)),
|
||||
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldn’t 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 don’t have other top level elements.")
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
2
tools/cloner-check/generated_files/mdmprofilespec.txt
Normal file
2
tools/cloner-check/generated_files/mdmprofilespec.txt
Normal 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
|
@ -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
|
||||
|
@ -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.
|
||||
}
|
||||
|
@ -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"?>
|
||||
|
Loading…
Reference in New Issue
Block a user