Feature branch for CP Exclude Labels (#20014)

Feature branch for this story: #17315
This commit is contained in:
Martin Angers 2024-07-03 16:38:00 -04:00 committed by GitHub
commit 2daff642d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 2998 additions and 946 deletions

View file

@ -0,0 +1,5 @@
* Added the database migrations to create the new `exclude` column for labels associated with MDM profiles (and declarations).
* Added the API changes to support the `labels_include_all` and `labels_exclude_any` fields (and accept the deprecated `labels` field as an alias for `labels_include_all`).
* Added `fleetctl gitops` and `fleetctl apply` support for `labels_include_all` and `labels_exclude_any` to configure a custom setting.
* Updated the profile reconciliation logic to handle the new "exclude any" labels.
* Fix bug where macOS declarations were stuck in "to be removed" state indefinitely.

View file

@ -0,0 +1,2 @@
- add UI for uploading custom profiles with a target of hosts that include all/exclude
any selected labels

View file

@ -1088,6 +1088,53 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) {
}
}
func TestCustomSettingsGitOps(t *testing.T) {
cases := []struct {
file string
wantErr string
}{
{"testdata/gitops/global_macos_windows_custom_settings_valid.yml", ""},
{"testdata/gitops/global_macos_custom_settings_valid_deprecated.yml", ""},
{"testdata/gitops/global_windows_custom_settings_invalid_label_mix.yml", `For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included`},
{"testdata/gitops/global_windows_custom_settings_unknown_label.yml", `some or all the labels provided don't exist`},
{"testdata/gitops/team_macos_windows_custom_settings_valid.yml", ""},
{"testdata/gitops/team_macos_custom_settings_valid_deprecated.yml", ""},
{"testdata/gitops/team_macos_windows_custom_settings_invalid_labels_mix.yml", `For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included.`},
{"testdata/gitops/team_macos_windows_custom_settings_unknown_label.yml", `some or all the labels provided don't exist`},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {
ds, appCfgPtr, _ := setupFullGitOpsPremiumServer(t)
(*appCfgPtr).MDM.EnabledAndConfigured = true
(*appCfgPtr).MDM.WindowsEnabledAndConfigured = true
labelToIDs := map[string]uint{
fleet.BuiltinLabelMacOS14Plus: 1,
"A": 2,
"B": 3,
"C": 4,
}
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
// for this test, recognize labels A, B and C (as well as the built-in macos 14+ one)
ret := make(map[string]uint)
for _, lbl := range labels {
id, ok := labelToIDs[lbl]
if ok {
ret[lbl] = id
}
}
return ret, nil
}
_, err := runAppNoChecks([]string{"gitops", "-f", c.file})
if c.wantErr == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, c.wantErr)
}
})
}
}
func startSoftwareInstallerServer(t *testing.T) {
// start the web server that will serve the installer
b, err := os.ReadFile(filepath.Join("..", "..", "server", "service", "testdata", "software-installers", "ruby.deb"))

View file

@ -0,0 +1,93 @@
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
labels:
- A
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
mdm:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: ABC

View file

@ -0,0 +1,99 @@
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
labels_include_all:
- A
- B
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
labels_exclude_any:
- C
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
mdm:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: ABC

View file

@ -0,0 +1,95 @@
controls:
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
labels_include_all:
- B
labels_exclude_any:
- C
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
mdm:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: ABC

View file

@ -0,0 +1,93 @@
controls:
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
labels_include_all:
- ZZZ
scripts:
enable_disk_encryption: false
macos_migration:
enable: false
mode: ""
webhook_url: ""
macos_setup:
bootstrap_package: null
enable_end_user_authentication: false
macos_setup_assistant: null
macos_updates:
deadline: null
minimum_version: null
windows_enabled_and_configured: true
windows_updates:
deadline_days: null
grace_period_days: null
queries:
policies:
agent_options:
command_line_flags:
distributed_denylist_duration: 0
config:
options:
disable_distributed: false
distributed_interval: 10
distributed_plugin: tls
distributed_tls_max_attempts: 3
logger_tls_endpoint: /api/v1/osquery/log
pack_delimiter: /
org_settings:
server_settings:
deferred_save_host: false
enable_analytics: true
live_query_disabled: false
query_report_cap: 2000
query_reports_disabled: false
scripts_disabled: false
server_url: $FLEET_SERVER_URL
ai_features_disabled: true
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url: ""
org_logo_url_light_background: ""
org_name: $ORG_NAME
smtp_settings:
authentication_method: authmethod_plain
authentication_type: authtype_username_password
configured: false
domain: ""
enable_smtp: false
enable_ssl_tls: true
enable_start_tls: true
password: ""
port: 587
sender_address: ""
server: ""
user_name: ""
verify_ssl_certs: true
sso_settings:
enable_jit_provisioning: false
enable_jit_role_sync: false
enable_sso: true
enable_sso_idp_login: false
entity_id: https://saml.example.com/entityid
idp_image_url: ""
idp_name: MockSAML
issuer_uri: ""
metadata: ""
metadata_url: https://mocksaml.com/api/saml/metadata
integrations:
mdm:
webhook_settings:
fleet_desktop:
transparency_url: https://fleetdm.com/transparency
host_expiry_settings:
host_expiry_enabled: false
activity_expiry_settings:
activity_expiry_enabled: true
activity_expiry_window: 60
features:
enable_host_users: true
enable_software_inventory: true
vulnerability_settings:
databases_path: ""
secrets:
- secret: ABC

View file

@ -0,0 +1,21 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
labels:
- A
- B
policies:
queries:
software:

View file

@ -0,0 +1,29 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
labels_include_all:
- A
labels:
- B
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
labels_include_all:
- A
labels_exclude_any:
- C
policies:
queries:
software:

View file

@ -0,0 +1,25 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
labels_include_all:
- ZZZ
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
labels_exclude_any:
- ZZZ
policies:
queries:
software:

View file

@ -0,0 +1,26 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
macos_settings:
custom_settings:
- path: ./lib/macos-password.mobileconfig
labels_include_all:
- A
- B
windows_settings:
custom_settings:
- path: ./lib/windows-screenlock.xml
labels_exclude_any:
- C
policies:
queries:
software:

View file

@ -1119,7 +1119,7 @@ func (svc *Service) mdmAppleEditedMacOSUpdates(ctx context.Context, teamID *uint
if err != nil {
return err
}
d.Labels = []fleet.ConfigurationProfileLabel{
d.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
{LabelName: fleet.BuiltinLabelMacOS14Plus, LabelID: lblIDs[fleet.BuiltinLabelMacOS14Plus]},
}

View file

@ -950,6 +950,9 @@ func (svc *Service) createTeamFromSpec(
)
}
validateTeamCustomSettings(invalid, "macos", macOSSettings.CustomSettings)
validateTeamCustomSettings(invalid, "windows", spec.MDM.WindowsSettings.CustomSettings.Value)
var hostExpirySettings fleet.HostExpirySettings
if spec.HostExpirySettings != nil {
if spec.HostExpirySettings.HostExpiryEnabled && spec.HostExpirySettings.HostExpiryWindow <= 0 {
@ -1006,6 +1009,7 @@ func (svc *Service) createTeamFromSpec(
WindowsUpdates: spec.MDM.WindowsUpdates,
MacOSSettings: macOSSettings,
MacOSSetup: macOSSetup,
WindowsSettings: spec.MDM.WindowsSettings,
},
HostExpirySettings: hostExpirySettings,
WebhookSettings: fleet.TeamWebhookSettings{
@ -1183,6 +1187,9 @@ func (svc *Service) editTeamFromSpec(
team.Config.HostExpirySettings = *spec.HostExpirySettings
}
validateTeamCustomSettings(invalid, "macos", team.Config.MDM.MacOSSettings.CustomSettings)
validateTeamCustomSettings(invalid, "windows", team.Config.MDM.WindowsSettings.CustomSettings.Value)
// If host status webhook is not provided, do not change it
if spec.WebhookSettings.HostStatusWebhook != nil {
fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid)
@ -1282,6 +1289,25 @@ func (svc *Service) editTeamFromSpec(
return nil
}
func validateTeamCustomSettings(invalid *fleet.InvalidArgumentError, prefix string, customSettings []fleet.MDMProfileSpec) {
for i, prof := range customSettings {
count := 0
for _, b := range []bool{len(prof.Labels) > 0, len(prof.LabelsIncludeAll) > 0, len(prof.LabelsExcludeAny) > 0} {
if b {
count++
}
}
if count > 1 {
invalid.Append(fmt.Sprintf("%s_settings.custom_settings", prefix),
fmt.Sprintf(`Couldn't edit %s_settings.custom_settings. For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included.`, prefix))
}
if len(prof.Labels) > 0 {
customSettings[i].LabelsIncludeAll = customSettings[i].Labels
customSettings[i].Labels = nil
}
}
}
func (svc *Service) validateTeamCalendarIntegrations(
calendarIntegration *fleet.TeamGoogleCalendarIntegration,
appCfg *fleet.AppConfig, dryRun bool, invalid *fleet.InvalidArgumentError,

View file

@ -29,6 +29,12 @@ export interface IMacOsMigrationSettings {
webhook_url: string;
}
interface ICustomSetting {
path: string;
labels_include_all?: string[];
labels_exclude_any?: string[];
}
export interface IMdmConfig {
enable_disk_encryption: boolean;
enabled_and_configured: boolean;
@ -42,7 +48,7 @@ export interface IMdmConfig {
deadline: string | null;
};
macos_settings: {
custom_settings: null;
custom_settings: null | ICustomSetting[];
enable_disk_encryption: boolean;
};
macos_setup: {

View file

@ -1,3 +1,4 @@
import { ReactNode } from "react";
import PropTypes from "prop-types";
export default PropTypes.shape({
@ -10,6 +11,7 @@ export interface IDropdownOption {
disabled?: boolean;
label: string | JSX.Element;
value: string | number;
helpText?: ReactNode;
premiumOnly?: boolean;
tooltipContent?: string | JSX.Element;
}

View file

@ -71,7 +71,8 @@ export type ProfilePlatform = "darwin" | "windows";
export interface IProfileLabel {
name: string;
broken: boolean;
id?: number; // id is only present when the label is not broken
broken?: boolean;
}
export interface IMdmProfile {
@ -83,7 +84,8 @@ export interface IMdmProfile {
created_at: string;
updated_at: string;
checksum: string | null; // null for windows profiles
labels?: IProfileLabel[];
labels_include_all?: IProfileLabel[];
labels_exclude_any?: IProfileLabel[];
}
export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed";

View file

@ -20,7 +20,7 @@ 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 AddProfileModal from "./components/ProfileUploader/components/AddProfileModal/AddProfileModal";
import DeleteProfileModal from "./components/DeleteProfileModal/DeleteProfileModal";
import ProfileLabelsModal from "./components/ProfileLabelsModal/ProfileLabelsModal";
import ProfileListItem from "./components/ProfileListItem";
@ -101,7 +101,7 @@ const CustomSettings = ({
onMutation();
renderFlash("success", "Successfully deleted!");
} catch (e) {
renderFlash("error", "Couldnt delete. Please try again.");
renderFlash("error", "Couldn't delete. Please try again.");
} finally {
selectedProfile.current = null;
setShowDeleteProfileModal(false);
@ -169,6 +169,10 @@ const CustomSettings = ({
);
};
const hasLabels =
!!profileLabelsModalData?.labels_include_all?.length ||
!!profileLabelsModalData?.labels_exclude_any?.length;
return (
<div className={baseClass}>
<SectionHeader title="Custom settings" />
@ -189,7 +193,6 @@ const CustomSettings = ({
)}
{showAddProfileModal && (
<AddProfileModal
baseClass="add-profile"
currentTeamId={currentTeamId}
isPremiumTier={!!isPremiumTier}
onUpload={onUploadProfile}
@ -204,7 +207,7 @@ const CustomSettings = ({
onDelete={onDeleteProfile}
/>
)}
{!!isPremiumTier && !!profileLabelsModalData?.labels?.length && (
{isPremiumTier && hasLabels && (
<ProfileLabelsModal
baseClass={baseClass}
profile={profileLabelsModalData}

View file

@ -94,136 +94,11 @@
padding: 28.5px 0;
}
&__modal-content-wrap {
margin-top: $pad-large;
.add-profile__file {
padding: $pad-medium $pad-large;
}
}
&__upload-button {
margin-top: 8px;
padding: 0;
}
&__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;
cursor: pointer;
height: 38px;
align-items: center;
}
}
&__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: auto;
.loading-spinner {
margin: 69.5px auto;
}
}
&__label {
width: 100%;
padding: $pad-small $pad-medium;
box-sizing: border-box;
display: flex;
align-items: center;
&:not(:last-child) {
border-bottom: 1px solid $ui-fleet-black-10;
}
.form-field--checkbox {
width: auto;
}
}
&__label-name {
padding-left: $pad-large;
}
.fleet-checkbox {
height: 20px;
display: flex;
align-items: center;
&__label {
width: 490px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View file

@ -6,25 +6,42 @@ import InfoBanner from "components/InfoBanner";
import TooltipWrapper from "components/TooltipWrapper";
import Icon from "components/Icon";
interface IModalDescriptionProps {
baseClass: string;
profileName: string;
targetType: "includeAll" | "excludeAny";
}
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>
);
targetType,
}: IModalDescriptionProps) => {
const targetTypeText =
targetType === "includeAll" ? (
<>
have <b>all</b>
</>
) : (
<>
don&apos;t have <b>any</b>
</>
);
return (
<div className={`${baseClass}__description`}>
<b>{profileName}</b> profile only applies to hosts that {targetTypeText}{" "}
of these labels:
</div>
);
};
const BrokenLabelWarning = () => (
<InfoBanner color="yellow">
<span>
The configuration profile is{" "}
<TooltipWrapper
tipContent={`It wont be applied to new hosts because one or more labels are deleted. To apply the profile to new hosts, please delete it and upload a new profile.`}
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
@ -67,7 +84,14 @@ const ProfileLabelsModal = ({
profile,
setModalData,
}: IProfileLabelsModalProps) => {
if (!profile?.labels?.length) {
if (!profile) {
return null;
}
const { name, labels_include_all, labels_exclude_any } = profile;
const labels = labels_include_all || labels_exclude_any;
if (!labels?.length) {
// caller ensures this never happens
return null;
}
@ -75,9 +99,13 @@ const ProfileLabelsModal = ({
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} />
{labels.some((label) => label.broken) && <BrokenLabelWarning />}
<ModalDescription
baseClass={baseClass}
profileName={name}
targetType={labels_include_all ? "includeAll" : "excludeAny"}
/>
<LabelsList baseClass={baseClass} labels={labels} />
<div className="modal-cta-wrap">
<Button variant="brand" onClick={() => setModalData(null)}>
Done

View file

@ -83,7 +83,13 @@ const ProfileListItem = ({
onDelete,
setProfileLabelsModalData,
}: IProfileListItemProps) => {
const { created_at, labels, name, platform } = profile;
const {
created_at,
labels_include_all,
labels_exclude_any,
name,
platform,
} = profile;
const subClass = "list-item";
const onClickDownload = async () => {
@ -95,6 +101,21 @@ const ProfileListItem = ({
FileSaver.saveAs(file);
};
const labels = labels_include_all || labels_exclude_any;
const renderLabelInfo = () => {
if (!isPremium || labels === undefined || labels.length === 0) {
return null;
}
return (
<div className={`${subClass}__labels`}>
{labels?.some((label) => label.broken) && <Icon name="warning" />}
<LabelCount className={subClass} count={labels.length} />
</div>
);
};
return (
<div className={classnames(subClass, baseClass)}>
<div className={`${subClass}__main-content`}>
@ -111,14 +132,9 @@ const ProfileListItem = ({
</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>
)}
{renderLabelInfo()}
<div className={`${subClass}__actions`}>
{isPremium && !!labels?.length && (
{isPremium && labels !== undefined && labels.length && (
<Button
className={`${subClass}__action-button`}
variant="text-icon"

View file

@ -10,6 +10,8 @@ import { ILabelSummary } from "interfaces/label";
import labelsAPI from "services/entities/labels";
import mdmAPI from "services/entities/mdm";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Button from "components/buttons/Button";
import Card from "components/Card";
import Checkbox from "components/forms/fields/Checkbox";
@ -19,24 +21,28 @@ import Modal from "components/Modal";
import Radio from "components/forms/fields/Radio";
import Spinner from "components/Spinner";
import ProfileGraphic from "./AddProfileGraphic";
import ProfileGraphic from "../AddProfileGraphic";
import {
DEFAULT_ERROR_MESSAGE,
getErrorMessage,
parseFile,
} from "../../helpers";
import {
CUSTOM_TARGET_OPTIONS,
CustomTargetOption,
generateLabelKey,
listNamesFromSelectedLabels,
} from "../helpers";
} from "./helpers";
const FileChooser = ({
baseClass,
isLoading,
onFileOpen,
}: {
baseClass: string;
const baseClass = "add-profile-modal";
interface IFileChooserProps {
isLoading: boolean;
onFileOpen: (files: FileList | null) => void;
}) => (
}
const FileChooser = ({ isLoading, onFileOpen }: IFileChooserProps) => (
<div className={`${baseClass}__file-chooser`}>
<ProfileGraphic baseClass={baseClass} showMessage />
<Button
@ -62,19 +68,17 @@ const FileChooser = ({
</div>
);
// TODO: if we reuse this one more time, we should consider moving this
// into FileUploader as a default preview. Currently we have this in
// AddSoftwareForm.tsx and here.
const FileDetails = ({
baseClass,
details: { name, platform },
}: {
baseClass: string;
interface IFileDetailsProps {
details: {
name: string;
platform: string;
};
}) => (
}
// TODO: if we reuse this one more time, we should consider moving this
// into FileUploader as a default preview. Currently we have this in
// AddSoftwareForm.tsx and here.
const FileDetails = ({ details: { name, platform } }: IFileDetailsProps) => (
<div className={`${baseClass}__selected-file`}>
<ProfileGraphic baseClass={baseClass} />
<div className={`${baseClass}__selected-file--details`}>
@ -86,15 +90,15 @@ const FileDetails = ({
</div>
);
const TargetChooser = ({
baseClass,
selectedTarget,
setSelectedTarget,
}: {
baseClass: string;
interface ITargetChooserProps {
selectedTarget: string;
setSelectedTarget: React.Dispatch<React.SetStateAction<string>>;
}) => {
}
const TargetChooser = ({
selectedTarget,
setSelectedTarget,
}: ITargetChooserProps) => {
return (
<div className={`form-field`}>
<div className="form-field__label">Target</div>
@ -120,67 +124,95 @@ const TargetChooser = ({
);
};
const LabelChooser = ({
baseClass,
isError,
isLoading,
labels,
selectedLabels,
setSelectedLabels,
}: {
baseClass: string;
interface ILabelChooserProps {
isError: boolean;
isLoading: boolean;
labels: ILabelSummary[];
selectedLabels: Record<string, boolean>;
customTargetOption: CustomTargetOption;
setSelectedLabels: React.Dispatch<
React.SetStateAction<Record<string, boolean>>
>;
}) => {
onSelectCustomTargetOption: (val: CustomTargetOption) => void;
}
const LabelChooser = ({
isError,
isLoading,
labels,
selectedLabels,
customTargetOption,
setSelectedLabels,
onSelectCustomTargetOption,
}: ILabelChooserProps) => {
const updateSelectedLabels = useCallback(
({ name, value }: { name: string; value: boolean }) => {
setSelectedLabels((prevItems) => ({ ...prevItems, [name]: value }));
},
[setSelectedLabels]
);
const descriptionText =
customTargetOption === "labelsIncludeAll" ? (
<>
Profile will only be applied to hosts that have <b>all</b> these labels:
</>
) : (
<>
Profile will be applied to hosts that don&apos;t have <b>any</b> of
these labels:{" "}
</>
);
const renderLabels = () => {
if (isLoading) {
return <Spinner centered={false} />;
}
if (isError) {
return <DataError />;
}
if (!labels.length) {
return (
<div className={`${baseClass}__no-labels`}>
<b>No labels exist in Fleet</b>
<span>Add labels to target specific hosts.</span>
</div>
);
}
return 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>
);
});
};
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>
</>
<div className={`${baseClass}__custom-label-chooser`}>
<Dropdown
value={customTargetOption}
options={CUSTOM_TARGET_OPTIONS}
searchable={false}
onChange={onSelectCustomTargetOption}
/>
<div className={`${baseClass}__description`}>{descriptionText}</div>
<div className={`${baseClass}__checkboxes`}>{renderLabels()}</div>
</div>
);
};
interface IAddProfileModalProps {
baseClass: string;
currentTeamId: number;
isPremiumTier: boolean;
onUpload: () => void;
@ -188,7 +220,6 @@ interface IAddProfileModalProps {
}
const AddProfileModal = ({
baseClass,
currentTeamId,
isPremiumTier,
onUpload,
@ -205,18 +236,20 @@ const AddProfileModal = ({
const [selectedLabels, setSelectedLabels] = useState<Record<string, boolean>>(
{}
);
const [
customTargetOption,
setCustomTargetOption,
] = useState<CustomTargetOption>("labelsIncludeAll");
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
["custom_labels"],
() =>
labelsAPI
.summary()
@ -246,10 +279,15 @@ const AddProfileModal = ({
setIsLoading(true);
try {
const labelKey = generateLabelKey(
selectedTarget,
customTargetOption,
selectedLabels
);
await mdmAPI.uploadProfile({
file,
teamId: currentTeamId,
labels: listNamesFromSelectedLabels(selectedLabels),
...labelKey,
});
renderFlash("success", "Successfully uploaded!");
onUpload();
@ -282,6 +320,10 @@ const AddProfileModal = ({
}
};
const onSelectCustomTargetOption = (val: CustomTargetOption) => {
setCustomTargetOption(val);
};
return (
<Modal title="Add profile" onExit={onDone}>
<>
@ -291,30 +333,26 @@ const AddProfileModal = ({
<div className={`${baseClass}__modal-content-wrap`}>
<Card color="gray" className={`${baseClass}__file`}>
{!fileDetails ? (
<FileChooser
baseClass={baseClass}
isLoading={isLoading}
onFileOpen={onFileOpen}
/>
<FileChooser isLoading={isLoading} onFileOpen={onFileOpen} />
) : (
<FileDetails baseClass={baseClass} details={fileDetails} />
<FileDetails details={fileDetails} />
)}
</Card>
{isPremiumTier && (
<div className={`${baseClass}__target`}>
<TargetChooser
baseClass={baseClass}
selectedTarget={selectedTarget}
setSelectedTarget={setSelectedTarget}
/>
{selectedTarget === "Custom" && (
<LabelChooser
baseClass={baseClass}
customTargetOption={customTargetOption}
isError={isErrorLabels}
isLoading={isFetchingLabels}
labels={labels || []}
selectedLabels={selectedLabels}
setSelectedLabels={setSelectedLabels}
onSelectCustomTargetOption={onSelectCustomTargetOption}
/>
)}
</div>
@ -326,7 +364,6 @@ const AddProfileModal = ({
onClick={onFileUpload}
isLoading={isLoading}
disabled={
// TODO: consider adding tooltip to explain why button is disabled
(selectedTarget === "Custom" &&
!listNamesFromSelectedLabels(selectedLabels).length) ||
!fileDetails

View file

@ -0,0 +1,138 @@
.add-profile-modal {
&__modal-content-wrap {
margin-top: $pad-large;
.add-profile__file {
padding: $pad-medium $pad-large;
}
}
&__upload-button {
margin-top: 8px;
padding: 0;
}
&__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;
cursor: pointer;
height: 38px;
align-items: center;
}
}
&__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;
}
&__custom-label-chooser {
margin-top: $pad-medium;
}
&__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: auto;
.loading-spinner {
margin: 69.5px auto;
}
}
&__label {
width: 100%;
padding: $pad-small $pad-medium;
box-sizing: border-box;
display: flex;
align-items: center;
&:not(:last-child) {
border-bottom: 1px solid $ui-fleet-black-10;
}
.form-field--checkbox {
width: auto;
}
}
&__label-name {
padding-left: $pad-large;
}
.fleet-checkbox {
height: 20px;
display: flex;
align-items: center;
&__label {
width: 490px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}

View file

@ -0,0 +1,54 @@
import React from "react";
import { IDropdownOption } from "interfaces/dropdownOption";
import { snakeCase } from "lodash";
export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
{
value: "labelsIncludeAll",
label: "Include all ",
helpText: (
<>
Profile will only be applied to hosts that have <b>all</b> of these
labels{" "}
</>
),
disabled: false,
},
{
value: "labelsExcludeAny",
label: "Exclude all",
helpText: (
<>
Profile will be applied to hosts that don&apos;t have <b>any</b> of
these labels{" "}
</>
),
disabled: false,
},
];
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 type CustomTargetOption = "labelsIncludeAll" | "labelsExcludeAny";
export const generateLabelKey = (
target: string,
customTargetOption: CustomTargetOption,
selectedLabels: Record<string, boolean>
) => {
if (target !== "Custom") {
return {};
}
return {
[customTargetOption]: listNamesFromSelectedLabels(selectedLabels),
};
};

View file

@ -2,61 +2,6 @@ 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(".");
@ -68,14 +13,6 @@ export const parseFile = async (file: File): Promise<[string, string]> => {
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"];
}
case "json": {
@ -87,15 +24,6 @@ export const parseFile = async (file: File): Promise<[string, string]> => {
}
};
export const listNamesFromSelectedLabels = (dict: Record<string, boolean>) => {
return Object.entries(dict).reduce((acc, [labelName, isSelected]) => {
if (isSelected) {
acc.push(labelName);
}
return acc;
}, [] as string[]);
};
export const DEFAULT_ERROR_MESSAGE =
"Couldnt add configuration profile. Please try again.";

View file

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { createMockMdmProfile } from "__mocks__/mdmMock";
import {
DiskEncryptionStatus,
IHostMdmProfile,
@ -45,7 +46,8 @@ export interface IMdmProfilesResponse {
export interface IUploadProfileApiParams {
file: File;
teamId?: number;
labels?: string[];
labelsIncludeAll?: string[];
labelsExcludeAny?: string[];
}
export const isDDMProfile = (profile: IMdmProfile | IHostMdmProfile) => {
@ -62,7 +64,7 @@ export interface IAppleSetupEnrollmentProfileResponse {
name: string;
uploaded_at: string;
// enrollment profile is an object with keys found here https://developer.apple.com/documentation/devicemanagement/profile.
enrollment_profile: Record<string, any>;
enrollment_profile: Record<string, unknown>;
}
const mdmService = {
@ -97,7 +99,12 @@ const mdmService = {
return sendRequest("GET", path);
},
uploadProfile: ({ file, teamId, labels }: IUploadProfileApiParams) => {
uploadProfile: ({
file,
teamId,
labelsIncludeAll,
labelsExcludeAny,
}: IUploadProfileApiParams) => {
const { MDM_PROFILES } = endpoints;
const formData = new FormData();
@ -107,9 +114,15 @@ const mdmService = {
formData.append("team_id", teamId.toString());
}
labels?.forEach((label) => {
formData.append("labels", label);
});
if (labelsIncludeAll || labelsExcludeAny) {
const labels = labelsIncludeAll || labelsExcludeAny;
const labelKey = labelsIncludeAll
? "labels_include_all"
: "labels_exclude_any";
labels?.forEach((label) => {
formData.append(labelKey, label);
});
}
return sendRequest("POST", MDM_PROFILES, formData);
},
@ -272,7 +285,7 @@ const mdmService = {
return new Promise((resolve, reject) => {
reader.addEventListener("load", () => {
try {
const body: Record<string, any> = {
const body: Record<string, unknown> = {
name: file.name,
enrollment_profile: JSON.parse(reader.result as string),
};
@ -284,7 +297,7 @@ const mdmService = {
);
} catch {
// catches invalid JSON
reject("Couldnt upload. The file should include valid JSON.");
reject("Couldn't upload. The file should include valid JSON.");
}
});
});

View file

@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"time"
@ -72,10 +73,17 @@ INSERT INTO
// filled in.
profileID, _ = res.LastInsertId()
for i := range cp.Labels {
cp.Labels[i].ProfileUUID = profUUID
labels := make([]fleet.ConfigurationProfileLabel, 0, len(cp.LabelsIncludeAll)+len(cp.LabelsExcludeAny))
for i := range cp.LabelsIncludeAll {
cp.LabelsIncludeAll[i].ProfileUUID = profUUID
labels = append(labels, cp.LabelsIncludeAll[i])
}
if err := batchSetProfileLabelAssociationsDB(ctx, tx, cp.Labels, "darwin"); err != nil {
for i := range cp.LabelsExcludeAny {
cp.LabelsExcludeAny[i].ProfileUUID = profUUID
cp.LabelsExcludeAny[i].Exclude = true
labels = append(labels, cp.LabelsExcludeAny[i])
}
if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "darwin"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting darwin profile label associations")
}
@ -223,9 +231,12 @@ WHERE
if err != nil {
return nil, err
}
if len(labels) > 0 {
// ensure we leave Labels nil if there are none
res.Labels = labels
for _, lbl := range labels {
if lbl.Exclude {
res.LabelsExcludeAny = append(res.LabelsExcludeAny, lbl)
} else {
res.LabelsIncludeAll = append(res.LabelsIncludeAll, lbl)
}
}
}
@ -262,9 +273,12 @@ WHERE
if err != nil {
return nil, err
}
if len(labels) > 0 {
// ensure we leave Labels nil if there are none
res.Labels = labels
for _, lbl := range labels {
if lbl.Exclude {
res.LabelsExcludeAny = append(res.LabelsExcludeAny, lbl)
} else {
res.LabelsIncludeAll = append(res.LabelsIncludeAll, lbl)
}
}
return &res, nil
@ -1540,10 +1554,15 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Identifier)
}
for _, label := range incomingProf.Labels {
for _, label := range incomingProf.LabelsIncludeAll {
label.ProfileUUID = newlyInsertedProf.ProfileUUID
incomingLabels = append(incomingLabels, label)
}
for _, label := range incomingProf.LabelsExcludeAny {
label.ProfileUUID = newlyInsertedProf.ProfileUUID
label.Exclude = true
incomingLabels = append(incomingLabels, label)
}
}
}
@ -1665,12 +1684,12 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
( 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 ) )
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "h.uuid IN (?)"))
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "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(toInstallStmt, uuids, uuids, fleet.MDMOperationTypeRemove)
stmt, args, err := sqlx.In(toInstallStmt, uuids, uuids, uuids, fleet.MDMOperationTypeRemove)
if err != nil {
return ctxerr.Wrap(ctx, err, "building profiles to install statement")
}
@ -1705,6 +1724,7 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
-- except "remove" operations in any state
( hmap.operation_type IS NULL OR hmap.operation_type != ? ) AND
-- except "would be removed" profiles if they are a broken label-based profile
-- (regardless of if it is an include-all or exclude-any label)
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
@ -1712,12 +1732,12 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
mcpl.apple_profile_uuid = hmap.profile_uuid AND
mcpl.label_id IS NULL
)
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "h.uuid IN (?)"))
`, fmt.Sprintf(appleMDMProfilesDesiredStateQuery, "h.uuid IN (?)", "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(toRemoveStmt, uuids, uuids, uuids, fleet.MDMOperationTypeRemove)
stmt, args, err = sqlx.In(toRemoveStmt, uuids, uuids, uuids, uuids, fleet.MDMOperationTypeRemove)
if err != nil {
return ctxerr.Wrap(ctx, err, "building profiles to remove statement")
}
@ -1867,29 +1887,55 @@ func (ds *Datastore) bulkSetPendingMDMAppleHostProfilesDB(
return nil
}
// mdmEntityTypeToTable tracks what table should be used in the templates for
// SQL statements based on the given entity type.
var mdmEntityTypeToTable = map[string]string{
"declaration": "declaration",
"profile": "configuration_profile",
// mdmEntityTypeToDynamicNames tracks what names should be used in the
// templates for SQL statements based on the given entity type. The dynamic
// names are deliberately spelled out in full (instead of using an fmt.Sprintf
// approach) so that they are greppable in the codebase.
var mdmEntityTypeToDynamicNames = map[string]map[string]string{
"declaration": {
"entityUUIDColumn": "declaration_uuid",
"entityIdentifierColumn": "declaration_identifier",
"entityNameColumn": "declaration_name",
"countEntityLabelsColumn": "count_declaration_labels",
"mdmAppleEntityTable": "mdm_apple_declarations",
"mdmEntityLabelsTable": "mdm_declaration_labels",
"appleEntityUUIDColumn": "apple_declaration_uuid",
"hostMDMAppleEntityTable": "host_mdm_apple_declarations",
},
"profile": {
"entityUUIDColumn": "profile_uuid",
"entityIdentifierColumn": "profile_identifier",
"entityNameColumn": "profile_name",
"countEntityLabelsColumn": "count_profile_labels",
"mdmAppleEntityTable": "mdm_apple_configuration_profiles",
"mdmEntityLabelsTable": "mdm_configuration_profile_labels",
"appleEntityUUIDColumn": "apple_profile_uuid",
"hostMDMAppleEntityTable": "host_mdm_apple_profiles",
},
}
// generateDesiredStateQuery generates a query string that represents the
// desired state of an Apple entity based on its type (profile or declaration)
func generateDesiredStateQuery(entityType string) string {
return fmt.Sprintf(`
dynamicNames := mdmEntityTypeToDynamicNames[entityType]
if dynamicNames == nil {
panic(fmt.Sprintf("unknown entity type %q", entityType))
}
return os.Expand(`
-- non label-based entities
SELECT
mae.%[1]s_uuid,
mae.${entityUUIDColumn},
h.uuid as host_uuid,
h.platform as host_platform,
mae.identifier as %[1]s_identifier,
mae.name as %[1]s_name,
mae.identifier as ${entityIdentifierColumn},
mae.name as ${entityNameColumn},
mae.checksum as checksum,
0 as count_%[1]s_labels,
0 as ${countEntityLabelsColumn},
0 as count_non_broken_labels,
0 as count_host_labels
FROM
mdm_apple_%[2]ss mae
${mdmAppleEntityTable} mae
JOIN hosts h
ON h.team_id = mae.team_id OR (h.team_id IS NULL AND mae.team_id = 0)
JOIN nano_enrollments ne
@ -1900,44 +1946,81 @@ func generateDesiredStateQuery(entityType string) string {
ne.type = 'Device' AND
NOT EXISTS (
SELECT 1
FROM mdm_%[2]s_labels mel
WHERE mel.apple_%[1]s_uuid = mae.%[1]s_uuid
FROM ${mdmEntityLabelsTable} mel
WHERE mel.${appleEntityUUIDColumn} = mae.${entityUUIDColumn}
) AND
( %[3]s )
( %s )
UNION
-- label-based entities where the host is a member of all the labels
-- label-based entities where the host is a member of all the labels (include-all).
-- by design, "include" labels cannot match if they are broken (the host cannot be
-- a member of a deleted label).
SELECT
mae.%[1]s_uuid,
mae.${entityUUIDColumn},
h.uuid as host_uuid,
h.platform as host_platform,
mae.identifier as %[1]s_identifier,
mae.name as %[1]s_name,
mae.identifier as ${entityIdentifierColumn},
mae.name as ${entityNameColumn},
mae.checksum as checksum,
COUNT(*) as count_%[1]s_labels,
COUNT(*) as ${countEntityLabelsColumn},
COUNT(mel.label_id) as count_non_broken_labels,
COUNT(lm.label_id) as count_host_labels
FROM
mdm_apple_%[2]ss mae
${mdmAppleEntityTable} mae
JOIN hosts h
ON h.team_id = mae.team_id OR (h.team_id IS NULL AND mae.team_id = 0)
JOIN nano_enrollments ne
ON ne.device_id = h.uuid
JOIN mdm_%[2]s_labels mel
ON mel.apple_%[1]s_uuid = mae.%[1]s_uuid
JOIN ${mdmEntityLabelsTable} mel
ON mel.${appleEntityUUIDColumn} = mae.${entityUUIDColumn} AND mel.exclude = 0
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mel.label_id AND lm.host_id = h.id
WHERE
(h.platform = 'darwin' OR h.platform = 'ios' OR h.platform = 'ipados') AND
ne.enabled = 1 AND
ne.type = 'Device' AND
( %[3]s )
( %s )
GROUP BY
mae.%[1]s_uuid, h.uuid, h.platform, mae.identifier, mae.name, mae.checksum
mae.${entityUUIDColumn}, h.uuid, h.platform, mae.identifier, mae.name, mae.checksum
HAVING
count_%[1]s_labels > 0 AND count_host_labels = count_%[1]s_labels
${countEntityLabelsColumn} > 0 AND count_host_labels = ${countEntityLabelsColumn}
`, entityType, mdmEntityTypeToTable[entityType], "%s")
UNION
-- label-based entities where the host is NOT a member of any of the labels (exclude-any).
-- explicitly ignore profiles with broken excluded labels so that they are never applied.
SELECT
mae.${entityUUIDColumn},
h.uuid as host_uuid,
h.platform as host_platform,
mae.identifier as ${entityIdentifierColumn},
mae.name as ${entityNameColumn},
mae.checksum as checksum,
COUNT(*) as ${countEntityLabelsColumn},
COUNT(mel.label_id) as count_non_broken_labels,
COUNT(lm.label_id) as count_host_labels
FROM
${mdmAppleEntityTable} mae
JOIN hosts h
ON h.team_id = mae.team_id OR (h.team_id IS NULL AND mae.team_id = 0)
JOIN nano_enrollments ne
ON ne.device_id = h.uuid
JOIN ${mdmEntityLabelsTable} mel
ON mel.${appleEntityUUIDColumn} = mae.${entityUUIDColumn} AND mel.exclude = 1
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mel.label_id AND lm.host_id = h.id
WHERE
(h.platform = 'darwin' OR h.platform = 'ios' OR h.platform = 'ipados') AND
ne.enabled = 1 AND
ne.type = 'Device' AND
( %s )
GROUP BY
mae.${entityUUIDColumn}, h.uuid, h.platform, mae.identifier, mae.name, mae.checksum
HAVING
-- considers only the profiles with labels, without any broken label, and with the host not in any label
${countEntityLabelsColumn} > 0 AND ${countEntityLabelsColumn} = count_non_broken_labels AND count_host_labels = 0
`, func(s string) string { return dynamicNames[s] })
}
// generateEntitiesToInstallQuery is a set difference between:
@ -1977,20 +2060,25 @@ func generateDesiredStateQuery(entityType string) string {
// where one of the labels does not exist anymore, will not be considered for
// installation.
func generateEntitiesToInstallQuery(entityType string) string {
return fmt.Sprintf(`
( %[3]s ) as ds
LEFT JOIN host_mdm_apple_%[1]ss hmae
ON hmae.%[1]s_uuid = ds.%[1]s_uuid AND hmae.host_uuid = ds.host_uuid
dynamicNames := mdmEntityTypeToDynamicNames[entityType]
if dynamicNames == nil {
panic(fmt.Sprintf("unknown entity type %q", entityType))
}
return fmt.Sprintf(os.Expand(`
( %s ) as ds
LEFT JOIN ${hostMDMAppleEntityTable} hmae
ON hmae.${entityUUIDColumn} = ds.${entityUUIDColumn} AND hmae.host_uuid = ds.host_uuid
WHERE
-- entity has been updated
( hmae.checksum != ds.checksum ) OR
-- entity in A but not in B
( hmae.%[1]s_uuid IS NULL AND hmae.host_uuid IS NULL ) OR
( hmae.${entityUUIDColumn} IS NULL AND hmae.host_uuid IS NULL ) OR
-- entities in A and B but with operation type "remove"
( hmae.host_uuid IS NOT NULL AND ( hmae.operation_type = ? OR hmae.operation_type IS NULL ) ) OR
-- entities in A and B with operation type "install" and NULL status
( hmae.host_uuid IS NOT NULL AND hmae.operation_type = ? AND hmae.status IS NULL )
`, entityType, mdmEntityTypeToTable[entityType], fmt.Sprintf(generateDesiredStateQuery(entityType), "TRUE", "TRUE"))
`, func(s string) string { return dynamicNames[s] }), fmt.Sprintf(generateDesiredStateQuery(entityType), "TRUE", "TRUE", "TRUE"))
}
// generateEntitiesToRemoveQuery is a set difference between:
@ -2020,24 +2108,30 @@ func generateEntitiesToInstallQuery(entityType string) string {
// entity but no longer does (and that label-based entity is not "broken"),
// the entity will be removed from the host.
func generateEntitiesToRemoveQuery(entityType string) string {
return fmt.Sprintf(`
( %[3]s ) as ds
RIGHT JOIN host_mdm_apple_%[1]ss hmae
ON hmae.%[1]s_uuid = ds.%[1]s_uuid AND hmae.host_uuid = ds.host_uuid
dynamicNames := mdmEntityTypeToDynamicNames[entityType]
if dynamicNames == nil {
panic(fmt.Sprintf("unknown entity type %q", entityType))
}
return fmt.Sprintf(os.Expand(`
( %s ) as ds
RIGHT JOIN ${hostMDMAppleEntityTable} hmae
ON hmae.${entityUUIDColumn} = ds.${entityUUIDColumn} AND hmae.host_uuid = ds.host_uuid
WHERE
-- entities that are in B but not in A
ds.%[1]s_uuid IS NULL AND ds.host_uuid IS NULL AND
ds.${entityUUIDColumn} IS NULL AND ds.host_uuid IS NULL AND
-- except "remove" operations in a terminal state or already pending
( hmae.operation_type IS NULL OR hmae.operation_type != ? OR hmae.status IS NULL ) AND
-- except "would be removed" entities if they are a broken label-based entities
-- (regardless of if it is an include-all or exclude-any label)
NOT EXISTS (
SELECT 1
FROM mdm_%[2]s_labels mcpl
FROM ${mdmEntityLabelsTable} mcpl
WHERE
mcpl.apple_%[1]s_uuid = hmae.%[1]s_uuid AND
mcpl.${appleEntityUUIDColumn} = hmae.${entityUUIDColumn} AND
mcpl.label_id IS NULL
)
`, entityType, mdmEntityTypeToTable[entityType], fmt.Sprintf(generateDesiredStateQuery(entityType), "TRUE", "TRUE"))
`, func(s string) string { return dynamicNames[s] }), fmt.Sprintf(generateDesiredStateQuery(entityType), "TRUE", "TRUE", "TRUE"))
}
func (ds *Datastore) ListMDMAppleProfilesToInstall(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) {
@ -3706,10 +3800,15 @@ WHERE
return nil, ctxerr.Wrapf(ctx, err, "declaration %q is in the database but was not incoming", newlyInsertedDecl.Name)
}
for _, label := range incomingDecl.Labels {
for _, label := range incomingDecl.LabelsIncludeAll {
label.ProfileUUID = newlyInsertedDecl.DeclarationUUID
incomingLabels = append(incomingLabels, label)
}
for _, label := range incomingDecl.LabelsExcludeAny {
label.ProfileUUID = newlyInsertedDecl.DeclarationUUID
label.Exclude = true
incomingLabels = append(incomingLabels, label)
}
}
}
@ -3806,10 +3905,18 @@ func (ds *Datastore) insertOrUpsertMDMAppleDeclaration(ctx context.Context, insO
return ctxerr.Wrap(ctx, err, "reload apple mdm declaration")
}
for i := range declaration.Labels {
declaration.Labels[i].ProfileUUID = declUUID
labels := make([]fleet.ConfigurationProfileLabel, 0,
len(declaration.LabelsIncludeAll)+len(declaration.LabelsExcludeAny))
for i := range declaration.LabelsIncludeAll {
declaration.LabelsIncludeAll[i].ProfileUUID = declUUID
labels = append(labels, declaration.LabelsIncludeAll[i])
}
if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, declaration.Labels); err != nil {
for i := range declaration.LabelsExcludeAny {
declaration.LabelsExcludeAny[i].ProfileUUID = declUUID
declaration.LabelsExcludeAny[i].Exclude = true
labels = append(labels, declaration.LabelsExcludeAny[i])
}
if err := batchSetDeclarationLabelAssociationsDB(ctx, tx, labels); err != nil {
return ctxerr.Wrap(ctx, err, "inserting mdm declaration label associations")
}
@ -3839,11 +3946,12 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
upsertStmt := `
INSERT INTO mdm_declaration_labels
(apple_declaration_uuid, label_id, label_name)
(apple_declaration_uuid, label_id, label_name, exclude)
VALUES
%s
ON DUPLICATE KEY UPDATE
label_id = VALUES(label_id)
label_id = VALUES(label_id),
exclude = VALUES(exclude)
`
var (
@ -3859,9 +3967,9 @@ func batchSetDeclarationLabelAssociationsDB(ctx context.Context, tx sqlx.ExtCont
insertBuilder.WriteString(",")
deleteBuilder.WriteString(",")
}
insertBuilder.WriteString("(?, ?, ?)")
insertBuilder.WriteString("(?, ?, ?, ?)")
deleteBuilder.WriteString("(?, ?)")
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName)
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude)
deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID)
setProfileUUIDs[pl.ProfileUUID] = struct{}{}
@ -3907,10 +4015,18 @@ FROM
host_mdm_apple_declarations hmad
JOIN mdm_apple_declarations mad ON hmad.declaration_uuid = mad.declaration_uuid
WHERE
hmad.host_uuid = ?`
hmad.host_uuid = ? AND hmad.operation_type = ?`
// NOTE: the token generated as part of this query decides if the DDM session
// proceeds with sending the declarations - if the token differs from what
// the host last applied, it will proceed. That's why we use only the "to be
// installed" declarations for the token generation. If some declarations get
// removed, then they will be ignored in the token generation, which will
// change the token and make the DDM session proceed (and declarations not
// sent get removed).
var res fleet.MDMAppleDDMDeclarationsToken
if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, hostUUID); err != nil {
if err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, hostUUID, fleet.MDMOperationTypeInstall); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get DDM declarations token")
}

View file

@ -158,7 +158,7 @@ func testNewMDMAppleConfigProfileLabels(t *testing.T, ds *Datastore) {
Identifier: "DummyTestIdentifier",
Mobileconfig: dummyMC,
TeamID: nil,
Labels: []fleet.ConfigurationProfileLabel{
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: "foo", LabelID: 1},
},
}
@ -174,7 +174,7 @@ func testNewMDMAppleConfigProfileLabels(t *testing.T, ds *Datastore) {
}
label, err = ds.NewLabel(ctx, label)
require.NoError(t, err)
cp.Labels = []fleet.ConfigurationProfileLabel{
cp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
{LabelName: label.Name, LabelID: label.ID},
}
prof, err := ds.NewMDMAppleConfigProfile(ctx, cp)
@ -207,11 +207,11 @@ 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)
require.Nil(t, storedCP.LabelsIncludeAll)
storedCP, err = ds.GetMDMAppleConfigProfile(ctx, newCP.ProfileUUID)
require.NoError(t, err)
checkConfigProfile(t, *newCP, *storedCP)
require.Nil(t, storedCP.Labels)
require.Nil(t, storedCP.LabelsIncludeAll)
// create a label-based profile
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "lbl", Query: "select 1"})
@ -221,7 +221,7 @@ func testNewMDMAppleConfigProfileDuplicateIdentifier(t *testing.T, ds *Datastore
Name: "label-based",
Identifier: "label-based",
Mobileconfig: mobileconfig.Mobileconfig([]byte("LabelTestMobileconfigBytes")),
Labels: []fleet.ConfigurationProfileLabel{
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelName: lbl.Name, LabelID: lbl.ID},
},
}
@ -232,21 +232,21 @@ func testNewMDMAppleConfigProfileDuplicateIdentifier(t *testing.T, ds *Datastore
// only included in the uuid one
prof, err := ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, labelProf.ProfileID)
require.NoError(t, err)
require.Nil(t, prof.Labels)
require.Nil(t, prof.LabelsIncludeAll)
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)
require.Len(t, prof.LabelsIncludeAll, 1)
require.Equal(t, lbl.Name, prof.LabelsIncludeAll[0].LabelName)
require.False(t, prof.LabelsIncludeAll[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)
require.Len(t, prof.LabelsIncludeAll, 1)
require.Equal(t, lbl.Name, prof.LabelsIncludeAll[0].LabelName)
require.True(t, prof.LabelsIncludeAll[0].Broken)
}
func generateCP(name string, identifier string, teamID uint) *fleet.MDMAppleConfigProfile {
@ -1094,7 +1094,7 @@ func expectAppleDeclarations(
require.Equal(t, wantD.Name, gotD.Name)
require.Equal(t, wantD.Identifier, gotD.Identifier)
require.Equal(t, wantD.Labels, gotD.Labels)
require.Equal(t, wantD.LabelsIncludeAll, gotD.LabelsIncludeAll)
}
return m
}
@ -1263,6 +1263,8 @@ func configProfileBytesForTest(name, identifier, uuid string) []byte {
`, name, identifier, uuid))
}
// if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise
// it is an "include-all".
func configProfileForTest(t *testing.T, name, identifier, uuid string, labels ...*fleet.Label) *fleet.MDMAppleConfigProfile {
prof := configProfileBytesForTest(name, identifier, uuid)
cp, err := fleet.NewMDMAppleConfigProfile(prof, nil)
@ -1271,12 +1273,18 @@ func configProfileForTest(t *testing.T, name, identifier, uuid string, labels ..
cp.Checksum = sum[:]
for _, lbl := range labels {
cp.Labels = append(cp.Labels, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
if strings.HasPrefix(lbl.Name, "exclude-") {
cp.LabelsExcludeAny = append(cp.LabelsExcludeAny, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
} else {
cp.LabelsIncludeAll = append(cp.LabelsIncludeAll, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
}
}
return cp
}
// if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise
// it is an "include-all".
func declForTest(name, identifier, payloadContent string, labels ...*fleet.Label) *fleet.MDMAppleDeclaration {
tmpl := `{
"Type": "com.apple.configuration.decl%s",
@ -1295,7 +1303,11 @@ func declForTest(name, identifier, payloadContent string, labels ...*fleet.Label
}
for _, l := range labels {
decl.Labels = append(decl.Labels, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
if strings.HasPrefix(l.Name, "exclude-") {
decl.LabelsExcludeAny = append(decl.LabelsExcludeAny, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
} else {
decl.LabelsIncludeAll = append(decl.LabelsIncludeAll, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
}
}
return decl
@ -4858,14 +4870,14 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
d1Ori, err := ds.GetMDMAppleDeclaration(ctx, d1.DeclarationUUID)
require.NoError(t, err)
require.Empty(t, d1Ori.Labels)
require.Empty(t, d1Ori.LabelsIncludeAll)
// update d1 with different identifier and labels
d1, err = ds.SetOrUpdateMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
Identifier: "i1b",
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
Labels: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}},
Identifier: "i1b",
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}},
})
require.NoError(t, err)
require.Equal(t, d1.DeclarationUUID, d1Ori.DeclarationUUID)
@ -4873,39 +4885,39 @@ func testSetOrUpdateMDMAppleDDMDeclaration(t *testing.T, ds *Datastore) {
d1B, err := ds.GetMDMAppleDeclaration(ctx, d1.DeclarationUUID)
require.NoError(t, err)
require.Len(t, d1B.Labels, 1)
require.Equal(t, l1.ID, d1B.Labels[0].LabelID)
require.Len(t, d1B.LabelsIncludeAll, 1)
require.Equal(t, l1.ID, d1B.LabelsIncludeAll[0].LabelID)
// update d1 with different label
d1, err = ds.SetOrUpdateMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
Identifier: "i1b",
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
Labels: []fleet.ConfigurationProfileLabel{{LabelName: l2.Name, LabelID: l2.ID}},
Identifier: "i1b",
Name: "d1",
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l2.Name, LabelID: l2.ID}},
})
require.NoError(t, err)
require.Equal(t, d1.DeclarationUUID, d1Ori.DeclarationUUID)
d1C, err := ds.GetMDMAppleDeclaration(ctx, d1.DeclarationUUID)
require.NoError(t, err)
require.Len(t, d1C.Labels, 1)
require.Equal(t, l2.ID, d1C.Labels[0].LabelID)
require.Len(t, d1C.LabelsIncludeAll, 1)
require.Equal(t, l2.ID, d1C.LabelsIncludeAll[0].LabelID)
// update d1tm1 with different identifier and label
d1tm1B, err := ds.SetOrUpdateMDMAppleDeclaration(ctx, &fleet.MDMAppleDeclaration{
Identifier: "i1b",
Name: "d1",
TeamID: &tm1.ID,
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
Labels: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}},
Identifier: "i1b",
Name: "d1",
TeamID: &tm1.ID,
RawJSON: json.RawMessage(`{"Identifier": "i1b"}`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: l1.Name, LabelID: l1.ID}},
})
require.NoError(t, err)
require.Equal(t, d1tm1B.DeclarationUUID, d1tm1.DeclarationUUID)
d1tm1B, err = ds.GetMDMAppleDeclaration(ctx, d1tm1B.DeclarationUUID)
require.NoError(t, err)
require.Len(t, d1tm1B.Labels, 1)
require.Equal(t, l1.ID, d1tm1B.Labels[0].LabelID)
require.Len(t, d1tm1B.LabelsIncludeAll, 1)
require.Equal(t, l1.ID, d1tm1B.LabelsIncludeAll[0].LabelID)
// delete no-team d1
err = ds.DeleteMDMAppleDeclarationByName(ctx, nil, "d1")

View file

@ -125,7 +125,6 @@ func (ds *Datastore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, op
var profs []*fleet.MDMConfigProfilePayload
// TODO(roberto): Consider using UNION ALL here, as we know there won't be any duplicates between the tables.
const selectStmt = `
SELECT
profile_uuid,
@ -152,7 +151,7 @@ FROM (
team_id = ? AND
identifier NOT IN (?)
UNION
UNION ALL
SELECT
profile_uuid,
@ -169,7 +168,7 @@ FROM (
team_id = ? AND
name NOT IN (?)
UNION
UNION ALL
SELECT
declaration_uuid AS profile_uuid,
@ -249,7 +248,11 @@ FROM (
}
for _, label := range labels {
if prof, ok := profMap[label.ProfileUUID]; ok {
prof.Labels = append(prof.Labels, label)
if label.Exclude {
prof.LabelsExcludeAny = append(prof.LabelsExcludeAny, label)
} else {
prof.LabelsIncludeAll = append(prof.LabelsIncludeAll, label)
}
}
}
@ -263,7 +266,8 @@ 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
IF(label_id IS NULL, 1, 0) as broken,
exclude
FROM
mdm_configuration_profile_labels mcpl
WHERE
@ -274,7 +278,8 @@ SELECT
apple_declaration_uuid as profile_uuid,
label_name,
COALESCE(label_id, 0) as label_id,
IF(label_id IS NULL, 1, 0) as broken
IF(label_id IS NULL, 1, 0) as broken,
exclude
FROM
mdm_declaration_labels mdl
WHERE
@ -282,7 +287,6 @@ WHERE
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 {
@ -678,49 +682,83 @@ func (ds *Datastore) GetHostMDMProfilesExpectedForVerification(ctx context.Conte
func (ds *Datastore) getHostMDMWindowsProfilesExpectedForVerification(ctx context.Context, teamID, hostID uint) (map[string]*fleet.ExpectedMDMProfile, error) {
stmt := `
-- profiles without labels
SELECT
name,
syncml AS raw_profile,
min(mwcp.uploaded_at) AS earliest_install_date,
0 AS count_profile_labels,
0 AS count_non_broken_labels,
0 AS count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
WHERE
mwcp.team_id = ?
AND NOT EXISTS (
mwcp.team_id = ? AND
NOT EXISTS (
SELECT
1
FROM
mdm_configuration_profile_labels mcpl
WHERE
mcpl.apple_profile_uuid = mwcp.profile_uuid)
GROUP BY name, syncml
UNION
SELECT
name,
syncml AS raw_profile,
min(mwcp.uploaded_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, syncml
HAVING
count_profile_labels > 0
AND count_host_labels = count_profile_labels
mcpl.windows_profile_uuid = mwcp.profile_uuid
)
GROUP BY name, syncml
`
UNION
-- label-based profiles where the host is a member of all the labels (include-all).
-- by design, "include" labels cannot match if they are broken (the host cannot be
-- a member of a deleted label).
SELECT
name,
syncml AS raw_profile,
min(mwcp.uploaded_at) AS earliest_install_date,
COUNT(*) AS count_profile_labels,
COUNT(mcpl.label_id) as count_non_broken_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 AND mcpl.exclude = 0
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mcpl.label_id AND lm.host_id = ?
WHERE
mwcp.team_id = ?
GROUP BY
name, syncml
HAVING
count_profile_labels > 0 AND
count_host_labels = count_profile_labels
UNION
-- label-based entities where the host is NOT a member of any of the labels (exclude-any).
-- explicitly ignore profiles with broken excluded labels so that they are never applied.
SELECT
name,
syncml AS raw_profile,
min(mwcp.uploaded_at) AS earliest_install_date,
COUNT(*) AS count_profile_labels,
COUNT(mcpl.label_id) as count_non_broken_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 AND mcpl.exclude = 1
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mcpl.label_id AND lm.host_id = ?
WHERE
mwcp.team_id = ?
GROUP BY
name, syncml
HAVING
-- considers only the profiles with labels, without any broken label, and with the host not in any label
count_profile_labels > 0 AND
count_profile_labels = count_non_broken_labels AND
count_host_labels = 0
`
var profiles []*fleet.ExpectedMDMProfile
// Note: teamID provided twice
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, stmt, teamID, hostID, teamID)
err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, stmt, teamID, hostID, teamID, hostID, teamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "running query for windows profiles")
}
@ -735,9 +773,11 @@ GROUP BY name, syncml
func (ds *Datastore) getHostMDMAppleProfilesExpectedForVerification(ctx context.Context, teamID, hostID uint) (map[string]*fleet.ExpectedMDMProfile, error) {
stmt := `
-- profiles without labels
SELECT
macp.identifier AS identifier,
0 AS count_profile_labels,
0 AS count_non_broken_labels,
0 AS count_host_labels,
earliest_install_date
FROM
@ -748,49 +788,89 @@ FROM
min(uploaded_at) AS earliest_install_date
FROM
mdm_apple_configuration_profiles
GROUP BY
checksum) cs ON macp.checksum = cs.checksum
GROUP BY checksum
) cs ON macp.checksum = cs.checksum
WHERE
macp.team_id = ?
AND NOT EXISTS (
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,
min(earliest_install_date) AS earliest_install_date
FROM
mdm_apple_configuration_profiles macp
JOIN (
SELECT
checksum,
min(uploaded_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
`
mcpl.apple_profile_uuid = macp.profile_uuid
)
UNION
-- label-based profiles where the host is a member of all the labels (include-all)
-- by design, "include" labels cannot match if they are broken (the host cannot be
-- a member of a deleted label).
SELECT
macp.identifier AS identifier,
COUNT(*) AS count_profile_labels,
COUNT(mcpl.label_id) AS count_non_broken_labels,
COUNT(lm.label_id) AS count_host_labels,
min(earliest_install_date) AS earliest_install_date
FROM
mdm_apple_configuration_profiles macp
JOIN (
SELECT
checksum,
min(uploaded_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 AND mcpl.exclude = 0
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
UNION
-- label-based entities where the host is NOT a member of any of the labels (exclude-any).
-- explicitly ignore profiles with broken excluded labels so that they are never applied.
SELECT
macp.identifier AS identifier,
COUNT(*) AS count_profile_labels,
COUNT(mcpl.label_id) AS count_non_broken_labels,
COUNT(lm.label_id) AS count_host_labels,
min(earliest_install_date) AS earliest_install_date
FROM
mdm_apple_configuration_profiles macp
JOIN (
SELECT
checksum,
min(uploaded_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 AND mcpl.exclude = 1
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
-- considers only the profiles with labels, without any broken label, and with the host not in any label
count_profile_labels > 0 AND
count_profile_labels = count_non_broken_labels AND
count_host_labels = 0
`
var rows []*fleet.ExpectedMDMProfile
// Note: teamID provided twice
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, teamID, hostID, teamID); err != nil {
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, teamID, hostID, teamID, hostID, teamID); err != nil {
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("getting expected profiles for host in team %d", teamID))
}
@ -915,11 +995,12 @@ func batchSetProfileLabelAssociationsDB(
upsertStmt := `
INSERT INTO mdm_configuration_profile_labels
(%s_profile_uuid, label_id, label_name)
(%s_profile_uuid, label_id, label_name, exclude)
VALUES
%s
ON DUPLICATE KEY UPDATE
label_id = VALUES(label_id)
label_id = VALUES(label_id),
exclude = VALUES(exclude)
`
var (
@ -935,9 +1016,9 @@ func batchSetProfileLabelAssociationsDB(
insertBuilder.WriteString(",")
deleteBuilder.WriteString(",")
}
insertBuilder.WriteString("(?, ?, ?)")
insertBuilder.WriteString("(?, ?, ?, ?)")
deleteBuilder.WriteString("(?, ?)")
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName)
insertParams = append(insertParams, pl.ProfileUUID, pl.LabelID, pl.LabelName, pl.Exclude)
deleteParams = append(deleteParams, pl.ProfileUUID, pl.LabelID)
setProfileUUIDs[pl.ProfileUUID] = struct{}{}
@ -1045,7 +1126,7 @@ SELECT
COALESCE(MAX(hm.fleet_enroll_ref), '') AS enroll_reference,
ne.enrolled_from_migration
FROM (
-- grab only the latest certificate associated with this device
-- grab only the latest certificate associated with this device
SELECT
n1.id,
n1.sha256,
@ -1162,14 +1243,14 @@ func (ds *Datastore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUI
}
selectStmt := fmt.Sprintf(`
SELECT
SELECT
COALESCE(status, ?) as status
FROM
%s
WHERE
operation_type = ?
AND host_uuid = ?
AND %s = ?
AND %s = ?
`, table, column)
var status fleet.MDMDeliveryStatus

View file

@ -37,10 +37,7 @@ func TestMDMShared(t *testing.T) {
{"TestBulkSetPendingMDMHostProfiles", testBulkSetPendingMDMHostProfiles},
{"TestBulkSetPendingMDMHostProfilesBatch2", testBulkSetPendingMDMHostProfilesBatch2},
{"TestBulkSetPendingMDMHostProfilesBatch3", testBulkSetPendingMDMHostProfilesBatch3},
{
"TestGetHostMDMProfilesExpectedForVerification",
testGetHostMDMProfilesExpectedForVerification,
},
{"TestGetHostMDMProfilesExpectedForVerification", testGetHostMDMProfilesExpectedForVerification},
{"TestBatchSetProfileLabelAssociations", testBatchSetProfileLabelAssociations},
{"TestBatchSetProfilesTransactionError", testBatchSetMDMProfilesTransactionError},
{"TestMDMEULA", testMDMEULA},
@ -49,6 +46,7 @@ func TestMDMShared(t *testing.T) {
{"TestMDMProfilesSummaryAndHostFilters", testMDMProfilesSummaryAndHostFilters},
{"TestIsHostConnectedToFleetMDM", testIsHostConnectedToFleetMDM},
{"TestAreHostsConnectedToFleetMDM", testAreHostsConnectedToFleetMDM},
{"TestBulkSetPendingMDMHostProfilesExcludeAny", testBulkSetPendingMDMHostProfilesExcludeAny},
}
for _, c := range cases {
@ -476,7 +474,7 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
// create label-based profiles for i==0, meaning CDEF will be label-based
acp := *generateCP(string(rune('C'+inc)), string(rune('C'+inc)), 0)
if i == 0 {
acp.Labels = []fleet.ConfigurationProfileLabel{
acp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
{LabelName: labels[0].Name, LabelID: labels[0].ID},
{LabelName: labels[1].Name, LabelID: labels[1].ID},
}
@ -486,7 +484,7 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
acp = *generateCP(string(rune('C'+inc+1)), string(rune('C'+inc+1)), team.ID)
if i == 0 {
acp.Labels = []fleet.ConfigurationProfileLabel{
acp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
{LabelName: labels[2].Name, LabelID: labels[2].ID},
{LabelName: labels[3].Name, LabelID: labels[3].ID},
}
@ -500,7 +498,7 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
SyncML: winProf,
}
if i == 0 {
wcp.Labels = []fleet.ConfigurationProfileLabel{
wcp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
{LabelName: labels[4].Name, LabelID: labels[4].ID},
{LabelName: labels[5].Name, LabelID: labels[5].ID},
}
@ -514,7 +512,7 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
SyncML: winProf,
}
if i == 0 {
wcp.Labels = []fleet.ConfigurationProfileLabel{
wcp.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{
{LabelName: labels[6].Name, LabelID: labels[6].ID},
{LabelName: labels[7].Name, LabelID: labels[7].ID},
}
@ -723,14 +721,14 @@ func testListMDMConfigProfiles(t *testing.T, ds *Datastore) {
got[i] = p.Name
wantProfs := profLabels[p.Name]
require.Equal(t, len(wantProfs), len(p.Labels), "profile name: %s", p.Name)
require.Equal(t, len(wantProfs), len(p.LabelsIncludeAll), "profile name: %s", p.Name)
if len(wantProfs) > 0 {
// clear the profile uuids from the labels list
for i, l := range p.Labels {
for i, l := range p.LabelsIncludeAll {
l.ProfileUUID = ""
p.Labels[i] = l
p.LabelsIncludeAll[i] = l
}
require.ElementsMatch(t, wantProfs, p.Labels, "profile name: %s", p.Name)
require.ElementsMatch(t, wantProfs, p.LabelsIncludeAll, "profile name: %s", p.Name)
}
}
require.Equal(t, got, c.wantNames)
@ -764,6 +762,91 @@ func testBulkSetPendingMDMHostProfilesBatch3(t *testing.T, ds *Datastore) {
testBulkSetPendingMDMHostProfiles(t, ds)
}
type anyProfile struct {
ProfileUUID string
Status *fleet.MDMDeliveryStatus
OperationType fleet.MDMOperationType
IdentifierOrName string
}
// only asserts the profile ID, status and operation
func assertHostProfiles(t *testing.T, ds *Datastore, want map[*fleet.Host][]anyProfile) {
ctx := context.Background()
for h, wantProfs := range want {
var gotProfs []anyProfile
switch h.Platform {
case "windows":
profs, err := ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(profs), "host uuid: %s", h.UUID)
for _, p := range profs {
gotProfs = append(gotProfs, anyProfile{
ProfileUUID: p.ProfileUUID,
Status: p.Status,
OperationType: p.OperationType,
IdentifierOrName: p.Name,
})
}
default:
profs, err := ds.GetHostMDMAppleProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(profs), "host uuid: %s", h.UUID)
for _, p := range profs {
gotProfs = append(gotProfs, anyProfile{
ProfileUUID: p.ProfileUUID,
Status: p.Status,
OperationType: p.OperationType,
IdentifierOrName: p.Identifier,
})
}
}
sortProfs := func(profs []anyProfile) []anyProfile {
sort.Slice(profs, func(i, j int) bool {
l, r := profs[i], profs[j]
if l.ProfileUUID == r.ProfileUUID {
return l.OperationType < r.OperationType
}
// default alphabetical comparison
return l.IdentifierOrName < r.IdentifierOrName
})
return profs
}
gotProfs = sortProfs(gotProfs)
wantProfs = sortProfs(wantProfs)
for i, wp := range wantProfs {
gp := gotProfs[i]
require.Equal(
t,
wp.ProfileUUID,
gp.ProfileUUID,
"host uuid: %s, prof id or name: %s",
h.UUID,
gp.IdentifierOrName,
)
require.Equal(
t,
wp.Status,
gp.Status,
"host uuid: %s, prof id or name: %s",
h.UUID,
gp.IdentifierOrName,
)
require.Equal(
t,
wp.OperationType,
gp.OperationType,
"host uuid: %s, prof id or name: %s",
h.UUID,
gp.IdentifierOrName,
)
}
}
}
func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
ctx := context.Background()
@ -775,95 +858,6 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
return ids
}
type anyProfile struct {
ProfileUUID string
Status *fleet.MDMDeliveryStatus
OperationType fleet.MDMOperationType
IdentifierOrName string
}
// only asserts the profile ID, status and operation
assertHostProfiles := func(want map[*fleet.Host][]anyProfile) {
// TODO(mna): it would help readability of this test to capture the "last
// state" of this call and accept the diff as the expected result, merging
// them together before the assertions. Would need some hackery to clear a
// profile from the list.
for h, wantProfs := range want {
var gotProfs []anyProfile
switch h.Platform {
case "windows":
profs, err := ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(profs), "host uuid: %s", h.UUID)
for _, p := range profs {
gotProfs = append(gotProfs, anyProfile{
ProfileUUID: p.ProfileUUID,
Status: p.Status,
OperationType: p.OperationType,
IdentifierOrName: p.Name,
})
}
default:
profs, err := ds.GetHostMDMAppleProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(profs), "host uuid: %s", h.UUID)
for _, p := range profs {
gotProfs = append(gotProfs, anyProfile{
ProfileUUID: p.ProfileUUID,
Status: p.Status,
OperationType: p.OperationType,
IdentifierOrName: p.Identifier,
})
}
}
sortProfs := func(profs []anyProfile) []anyProfile {
sort.Slice(profs, func(i, j int) bool {
l, r := profs[i], profs[j]
if l.ProfileUUID == r.ProfileUUID {
return l.OperationType < r.OperationType
}
// default alphabetical comparison
return l.IdentifierOrName < r.IdentifierOrName
})
return profs
}
gotProfs = sortProfs(gotProfs)
wantProfs = sortProfs(wantProfs)
for i, wp := range wantProfs {
gp := gotProfs[i]
require.Equal(
t,
wp.ProfileUUID,
gp.ProfileUUID,
"host uuid: %s, prof id or name: %s",
h.UUID,
gp.IdentifierOrName,
)
require.Equal(
t,
wp.Status,
gp.Status,
"host uuid: %s, prof id or name: %s",
h.UUID,
gp.IdentifierOrName,
)
require.Equal(
t,
wp.OperationType,
gp.OperationType,
"host uuid: %s, prof id or name: %s",
h.UUID,
gp.IdentifierOrName,
)
}
}
}
getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload {
// TODO(roberto): the docs says that you can pass a comma separated
// list of columns to OrderKey, but that doesn't seem to work
@ -947,7 +941,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
allHosts = append(allHosts, windowsHosts...)
err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {},
darwinHosts[1]: {},
darwinHosts[2]: {},
@ -1007,7 +1001,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// bulk set for all created hosts, enrolled hosts get the no-team profiles
err = ds.BulkSetPendingMDMHostProfiles(ctx, hostIDsFromHosts(allHosts...), nil, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[0].ProfileUUID,
@ -1192,7 +1186,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
nil,
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[0].ProfileUUID,
@ -1363,7 +1357,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
[]string{darwinHosts[1].UUID, windowsHosts[1].UUID},
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[0].ProfileUUID,
@ -1519,7 +1513,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
// update status of the affected team
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[0].ProfileUUID,
@ -1710,7 +1704,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: tm1Profiles[0].ProfileUUID,
@ -1871,7 +1865,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team1.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: newTm1Profiles[0].ProfileUUID,
@ -2041,7 +2035,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[1].UUID, nil))
require.NoError(t, ds.MDMAppleStoreDDMStatusReport(ctx, darwinHosts[2].UUID, nil))
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -2172,7 +2166,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newDarwinProfileUUID}, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -2283,7 +2277,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newWindowsProfileUUID}, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -2413,7 +2407,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID, 0}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -2620,7 +2614,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -2795,7 +2789,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -2999,7 +2993,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -3218,7 +3212,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// nothing changes - broken label-based profiles are simply ignored
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -3433,7 +3427,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -3646,7 +3640,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -3855,7 +3849,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -4054,7 +4048,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -4264,7 +4258,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -4479,7 +4473,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -4684,7 +4678,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
err = ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{team2.ID}, nil, nil)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -4886,7 +4880,7 @@ func testBulkSetPendingMDMHostProfiles(t *testing.T, ds *Datastore) {
)
require.NoError(t, err)
assertHostProfiles(map[*fleet.Host][]anyProfile{
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
darwinHosts[0]: {
{
ProfileUUID: globalProfiles[4].ProfileUUID,
@ -5824,8 +5818,9 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
t,
batchSetProfileLabelAssociationsDB(ctx, ds.writer(ctx), wantOtherWin, "windows"),
)
// make it an "exclude" label on the other macos profile
wantOtherMac := []fleet.ConfigurationProfileLabel{
{ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID},
{ProfileUUID: otherMacProfile.ProfileUUID, LabelName: label.Name, LabelID: label.ID, Exclude: true},
}
require.NoError(
t,
@ -5839,17 +5834,13 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
for platform, uuid := range platforms {
expectLabels := func(t *testing.T, profUUID, platform string, want []fleet.ConfigurationProfileLabel) {
if len(want) == 0 {
return
}
p := platform
if p == "darwin" {
p = "apple"
}
query := fmt.Sprintf(
"SELECT %s_profile_uuid as profile_uuid, label_id, label_name FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ?",
"SELECT %s_profile_uuid as profile_uuid, label_id, label_name, exclude FROM mdm_configuration_profile_labels WHERE %s_profile_uuid = ?",
p,
p,
)
@ -5888,6 +5879,19 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
// does not change other profiles
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
// now set it with Exclude mode
profileLabels = []fleet.ConfigurationProfileLabel{
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
})
require.NoError(t, err)
expectLabels(t, uuid, platform, profileLabels)
// does not change other profiles
expectLabels(t, otherWinProfile.ProfileUUID, "windows", wantOtherWin)
expectLabels(t, otherMacProfile.ProfileUUID, "darwin", wantOtherMac)
})
t.Run("invalid profile UUID "+platform, func(t *testing.T) {
@ -5933,8 +5937,8 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
// apply a batch set with the new label
profileLabels := []fleet.ConfigurationProfileLabel{
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID},
{ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID},
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID, Exclude: true},
{ProfileUUID: uuid, LabelName: newLabel.Name, LabelID: newLabel.ID, Exclude: true},
}
err = ds.withTx(ctx, func(tx sqlx.ExtContext) error {
return batchSetProfileLabelAssociationsDB(ctx, tx, profileLabels, platform)
@ -5943,7 +5947,7 @@ func testBatchSetProfileLabelAssociations(t *testing.T, ds *Datastore) {
// both are stored in the DB
expectLabels(t, uuid, platform, profileLabels)
// batch apply again without the newLabel
// batch apply again without the newLabel, and without Exclude flag
profileLabels = []fleet.ConfigurationProfileLabel{
{ProfileUUID: uuid, LabelName: label.Name, LabelID: label.ID},
}
@ -6911,3 +6915,263 @@ func testIsHostConnectedToFleetMDM(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, connected)
}
func testBulkSetPendingMDMHostProfilesExcludeAny(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create some "exclude" labels
var labels []*fleet.Label
for i := 0; i < 6; i++ {
lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "exclude-label-" + strconv.Itoa(i), Query: "select 1"})
require.NoError(t, err)
labels = append(labels, lbl)
}
// create an Apple profile, a Windows profile and an Apple Declaration with excluded labels
appleProfs := []*fleet.MDMAppleConfigProfile{
configProfileForTest(t, "A1", "A1", uuid.NewString(), labels[0], labels[1]),
}
windowsProfs := []*fleet.MDMWindowsConfigProfile{
windowsConfigProfileForTest(t, "W1", "W1", labels[2]),
}
appleDecls := []*fleet.MDMAppleDeclaration{
declForTest("D1", "D1", "{}", labels[3], labels[4], labels[5]),
}
err := ds.BatchSetMDMProfiles(ctx, nil, appleProfs, windowsProfs, appleDecls)
require.NoError(t, err)
// must reload them to get the profile/declaration uuid
getProfs := func(teamID *uint) []*fleet.MDMConfigProfilePayload {
// TODO(roberto): the docs says that you can pass a comma separated
// list of columns to OrderKey, but that doesn't seem to work
profs, _, err := ds.ListMDMConfigProfiles(ctx, teamID, fleet.ListOptions{})
require.NoError(t, err)
sort.Slice(profs, func(i, j int) bool {
l, r := profs[i], profs[j]
if l.Platform != r.Platform {
return l.Platform < r.Platform
}
return l.Name < r.Name
})
return profs
}
allProfs := getProfs(nil)
// create an Apple and Windows hosts, not members of any host
var i int
winHost, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("win-host%d-name", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
UUID: fmt.Sprintf("win-uuid-%d", i),
Platform: "windows",
})
require.NoError(t, err)
windowsEnroll(t, ds, winHost)
i++
appleHost, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("apple-host%d-name", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
UUID: fmt.Sprintf("apple-uuid-%d", i),
Platform: "darwin",
})
require.NoError(t, err)
nanoEnroll(t, ds, appleHost, false)
// do a sync, they get all platform-specific profiles since they are not part
// of any label
err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
{
ProfileUUID: allProfs[0].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[0].Identifier,
},
{
ProfileUUID: allProfs[1].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[1].Identifier,
},
},
winHost: {
{
ProfileUUID: allProfs[2].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[2].Name,
},
},
})
// make all hosts members of labels[1], [2], and [3] so that all profiles are
// excluded
err = ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{
{labels[1].ID, appleHost.ID},
{labels[2].ID, appleHost.ID},
{labels[3].ID, appleHost.ID},
{labels[1].ID, winHost.ID},
{labels[2].ID, winHost.ID},
{labels[3].ID, winHost.ID},
})
require.NoError(t, err)
err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
{
ProfileUUID: allProfs[0].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeRemove,
IdentifierOrName: allProfs[0].Identifier,
},
{
ProfileUUID: allProfs[1].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeRemove,
IdentifierOrName: allProfs[1].Identifier,
},
},
// windows profiles are directly deleted without a pending state (there's no on-host removal of profiles)
winHost: {},
})
// make apple host member of labels[2], and windows host member of [3], which are irrelevant
// for their platforms' profiles, so they get all profiles
err = ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{
{labels[1].ID, appleHost.ID},
{labels[3].ID, appleHost.ID},
{labels[1].ID, winHost.ID},
{labels[2].ID, winHost.ID},
})
require.NoError(t, err)
err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
{
ProfileUUID: allProfs[0].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[0].Identifier,
},
{
ProfileUUID: allProfs[1].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[1].Identifier,
},
},
winHost: {
{
ProfileUUID: allProfs[2].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[2].Name,
},
},
})
// delete labels 0, 2 and 3, breaking all profiles
err = ds.DeleteLabel(ctx, labels[0].Name)
require.NoError(t, err)
err = ds.DeleteLabel(ctx, labels[2].Name)
require.NoError(t, err)
err = ds.DeleteLabel(ctx, labels[3].Name)
require.NoError(t, err)
err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID}, nil, nil, nil)
require.NoError(t, err)
// broken profiles do not get reported as "to remove"
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
{
ProfileUUID: allProfs[0].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[0].Identifier,
},
{
ProfileUUID: allProfs[1].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[1].Identifier,
},
},
winHost: {
{
ProfileUUID: allProfs[2].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[2].Name,
},
},
})
// create a new windows and apple host, not a member of any label
i++
winHost2, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("win-host%d-name", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
UUID: fmt.Sprintf("win-uuid-%d", i),
Platform: "windows",
})
require.NoError(t, err)
windowsEnroll(t, ds, winHost2)
i++
appleHost2, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("apple-host%d-name", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
UUID: fmt.Sprintf("apple-uuid-%d", i),
Platform: "darwin",
})
require.NoError(t, err)
nanoEnroll(t, ds, appleHost2, false)
err = ds.BulkSetPendingMDMHostProfiles(ctx, []uint{winHost.ID, appleHost.ID, winHost2.ID, appleHost2.ID}, nil, nil, nil)
require.NoError(t, err)
// broken profiles do not get reported as "to install"
assertHostProfiles(t, ds, map[*fleet.Host][]anyProfile{
appleHost: {
{
ProfileUUID: allProfs[0].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[0].Identifier,
},
{
ProfileUUID: allProfs[1].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[1].Identifier,
},
},
winHost: {
{
ProfileUUID: allProfs[2].ProfileUUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
IdentifierOrName: allProfs[2].Name,
},
},
appleHost2: {},
winHost2: {},
})
}

View file

@ -744,9 +744,12 @@ WHERE
if err != nil {
return nil, err
}
if len(labels) > 0 {
// ensure we leave Labels nil if there are none
res.Labels = labels
for _, lbl := range labels {
if lbl.Exclude {
res.LabelsExcludeAny = append(res.LabelsExcludeAny, lbl)
} else {
res.LabelsIncludeAll = append(res.LabelsIncludeAll, lbl)
}
}
return &res, nil
@ -1127,6 +1130,7 @@ const windowsMDMProfilesDesiredStateQuery = `
mwcp.name,
h.uuid as host_uuid,
0 as count_profile_labels,
0 as count_non_broken_labels,
0 as count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
@ -1145,12 +1149,15 @@ const windowsMDMProfilesDesiredStateQuery = `
UNION
-- label-based profiles
-- label-based profiles where the host is a member of all the labels (include-all).
-- by design, "include" labels cannot match if they are broken (the host cannot be
-- a member of a deleted label).
SELECT
mwcp.profile_uuid,
mwcp.name,
h.uuid as host_uuid,
COUNT(*) as count_profile_labels,
COUNT(mcpl.label_id) as count_non_broken_labels,
COUNT(lm.label_id) as count_host_labels
FROM
mdm_windows_configuration_profiles mwcp
@ -1159,7 +1166,7 @@ const windowsMDMProfilesDesiredStateQuery = `
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
ON mcpl.windows_profile_uuid = mwcp.profile_uuid AND mcpl.exclude = 0
LEFT OUTER JOIN label_membership lm
ON lm.label_id = mcpl.label_id AND lm.host_id = h.id
WHERE
@ -1169,6 +1176,36 @@ const windowsMDMProfilesDesiredStateQuery = `
mwcp.profile_uuid, mwcp.name, h.uuid
HAVING
count_profile_labels > 0 AND count_host_labels = count_profile_labels
UNION
-- label-based entities where the host is NOT a member of any of the labels (exclude-any).
-- explicitly ignore profiles with broken excluded labels so that they are never applied.
SELECT
mwcp.profile_uuid,
mwcp.name,
h.uuid as host_uuid,
COUNT(*) as count_profile_labels,
COUNT(mcpl.label_id) as count_non_broken_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 AND mcpl.exclude = 1
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
-- considers only the profiles with labels, without any broken label, and with the host not in any label
count_profile_labels > 0 AND count_profile_labels = count_non_broken_labels AND count_host_labels = 0
`
func (ds *Datastore) ListMDMWindowsProfilesToInstall(ctx context.Context) ([]*fleet.MDMWindowsProfilePayload, error) {
@ -1235,9 +1272,9 @@ func listMDMWindowsProfilesToInstallDB(
var err error
args := []any{fleet.MDMOperationTypeInstall}
query = fmt.Sprintf(query, hostFilter, hostFilter)
query = fmt.Sprintf(query, hostFilter, hostFilter, hostFilter)
if len(hostUUIDs) > 0 {
query, args, err = sqlx.In(query, hostUUIDs, hostUUIDs, fleet.MDMOperationTypeInstall)
query, args, err = sqlx.In(query, hostUUIDs, hostUUIDs, hostUUIDs, fleet.MDMOperationTypeInstall)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In")
}
@ -1306,6 +1343,7 @@ func listMDMWindowsProfilesToRemoveDB(
-- 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
-- (regardless of if it is an include-all or exclude-any label)
NOT EXISTS (
SELECT 1
FROM mdm_configuration_profile_labels mcpl
@ -1314,7 +1352,7 @@ func listMDMWindowsProfilesToRemoveDB(
mcpl.label_id IS NULL
) AND
(%s)
`, fmt.Sprintf(windowsMDMProfilesDesiredStateQuery, "TRUE", "TRUE"), hostFilter)
`, fmt.Sprintf(windowsMDMProfilesDesiredStateQuery, "TRUE", "TRUE", "TRUE"), hostFilter)
var err error
var args []any
@ -1533,10 +1571,17 @@ INSERT INTO
}
}
for i := range cp.Labels {
cp.Labels[i].ProfileUUID = profileUUID
labels := make([]fleet.ConfigurationProfileLabel, 0, len(cp.LabelsIncludeAll)+len(cp.LabelsExcludeAny))
for i := range cp.LabelsIncludeAll {
cp.LabelsIncludeAll[i].ProfileUUID = profileUUID
labels = append(labels, cp.LabelsIncludeAll[i])
}
if err := batchSetProfileLabelAssociationsDB(ctx, tx, cp.Labels, "windows"); err != nil {
for i := range cp.LabelsExcludeAny {
cp.LabelsExcludeAny[i].ProfileUUID = profileUUID
cp.LabelsExcludeAny[i].Exclude = true
labels = append(labels, cp.LabelsExcludeAny[i])
}
if err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, "windows"); err != nil {
return ctxerr.Wrap(ctx, err, "inserting windows profile label associations")
}
@ -1767,10 +1812,15 @@ ON DUPLICATE KEY UPDATE
return ctxerr.Wrapf(ctx, err, "profile %q is in the database but was not incoming", newlyInsertedProf.Name)
}
for _, label := range incomingProf.Labels {
for _, label := range incomingProf.LabelsIncludeAll {
label.ProfileUUID = newlyInsertedProf.ProfileUUID
incomingLabels = append(incomingLabels, label)
}
for _, label := range incomingProf.LabelsExcludeAny {
label.ProfileUUID = newlyInsertedProf.ProfileUUID
label.Exclude = true
incomingLabels = append(incomingLabels, label)
}
}
}

View file

@ -1805,10 +1805,10 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
_, err = ds.NewMDMWindowsConfigProfile(
ctx,
fleet.MDMWindowsConfigProfile{
Name: "fake-labels",
TeamID: nil,
SyncML: []byte("<Replace></Replace>"),
Labels: []fleet.ConfigurationProfileLabel{{LabelName: "foo", LabelID: 1}},
Name: "fake-labels",
TeamID: nil,
SyncML: []byte("<Replace></Replace>"),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: "foo", LabelID: 1}},
})
require.NotNil(t, err)
require.True(t, fleet.IsForeignKey(err))
@ -1825,10 +1825,10 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
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}},
Name: "with-labels",
TeamID: nil,
SyncML: []byte("<Replace></Replace>"),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{{LabelName: label.Name, LabelID: label.ID}},
})
require.NoError(t, err)
require.NotEmpty(t, profWithLabel.ProfileUUID)
@ -1836,20 +1836,20 @@ func testMDMWindowsConfigProfiles(t *testing.T, ds *Datastore) {
// 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)
require.Len(t, prof.LabelsIncludeAll, 1)
require.Equal(t, label.Name, prof.LabelsIncludeAll[0].LabelName)
require.Equal(t, label.ID, prof.LabelsIncludeAll[0].LabelID)
require.False(t, prof.LabelsIncludeAll[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)
require.Len(t, prof.LabelsIncludeAll, 1)
require.Equal(t, label.Name, prof.LabelsIncludeAll[0].LabelName)
require.Zero(t, prof.LabelsIncludeAll[0].LabelID)
require.True(t, prof.LabelsIncludeAll[0].Broken)
_, err = ds.GetMDMWindowsConfigProfile(ctx, "not-valid")
require.Error(t, err)
@ -1864,7 +1864,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.UploadedAt)
require.Nil(t, prof.Labels)
require.Nil(t, prof.LabelsIncludeAll)
err = ds.DeleteMDMWindowsConfigProfile(ctx, "not-valid")
require.Error(t, err)
@ -2125,6 +2125,8 @@ func testBatchSetMDMWindowsProfiles(t *testing.T, ds *Datastore) {
applyAndExpect(nil, ptr.Uint(1), nil)
}
// if the label name starts with "exclude-", the label is considered an "exclude-any", otherwise
// it is an "include-all".
func windowsConfigProfileForTest(t *testing.T, name, locURI string, labels ...*fleet.Label) *fleet.MDMWindowsConfigProfile {
prof := &fleet.MDMWindowsConfigProfile{
Name: name,
@ -2140,7 +2142,11 @@ func windowsConfigProfileForTest(t *testing.T, name, locURI string, labels ...*f
}
for _, lbl := range labels {
prof.Labels = append(prof.Labels, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
if strings.HasPrefix(lbl.Name, "exclude-") {
prof.LabelsExcludeAny = append(prof.LabelsExcludeAny, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
} else {
prof.LabelsIncludeAll = append(prof.LabelsIncludeAll, fleet.ConfigurationProfileLabel{LabelName: lbl.Name, LabelID: lbl.ID})
}
}
return prof

View file

@ -0,0 +1,27 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20240703154849, Down_20240703154849)
}
func Up_20240703154849(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE mdm_configuration_profile_labels ADD COLUMN exclude TINYINT(1) NOT NULL DEFAULT 0`)
if err != nil {
return fmt.Errorf("failed to add exclude boolean to mdm_configuration_profile_labels: %w", err)
}
_, err = tx.Exec(`ALTER TABLE mdm_declaration_labels ADD COLUMN exclude TINYINT(1) NOT NULL DEFAULT 0`)
if err != nil {
return fmt.Errorf("failed to add exclude boolean to mdm_declaration_labels: %w", err)
}
return nil
}
func Down_20240703154849(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,64 @@
package tables
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestUp_20240703154849(t *testing.T) {
db := applyUpToPrev(t)
// create an MDM profile and an MDM declaration
profStmt := `
INSERT INTO
mdm_apple_configuration_profiles (team_id, identifier, name, mobileconfig, profile_uuid, checksum)
VALUES (?, ?, ?, ?, ?, ?)`
profUUID := uuid.NewString()
mcBytes := []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>
</dict>
</plist>
`)
_, err := db.Exec(profStmt, 0, "TestPayloadIdentifier", "TestPayloadName", mcBytes, profUUID, "ABCD")
require.NoError(t, err)
declStmt := `
INSERT INTO
mdm_apple_declarations (declaration_uuid, team_id, identifier, name, raw_json, checksum)
VALUES (?, ?, ?, ?, ?, ?)`
declUUID := uuid.NewString()
_, err = db.Exec(declStmt, declUUID, 0, "TestDecl", "TestDecl", `{}`, "abcd")
require.NoError(t, err)
// create a couple 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')`)
// finally we can create the MDM profile label and MDM declaration label entries
profLblID := execNoErrLastID(t, db, `INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)`,
profUUID, "LA", idlA)
declLblID := execNoErrLastID(t, db, `INSERT INTO mdm_declaration_labels (apple_declaration_uuid, label_name, label_id) VALUES (?, ?, ?)`,
declUUID, "LB", idlB)
// Apply current migration.
applyNext(t, db)
// check that the "exclude" flag is false in the DB (set it to true to verify
// that it did scan from the DB)
exclude := true
err = db.Get(&exclude, "SELECT exclude FROM mdm_configuration_profile_labels WHERE id = ?", profLblID)
require.NoError(t, err)
require.False(t, exclude)
exclude = true
err = db.Get(&exclude, "SELECT exclude FROM mdm_declaration_labels WHERE id = ?", declLblID)
require.NoError(t, err)
require.False(t, exclude)
}

File diff suppressed because one or more lines are too long

View file

@ -308,6 +308,18 @@ func (s MacOSSettings) ToMap() map[string]interface{} {
func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, error) {
set := make(map[string]bool)
extractLabelField := func(parentMap map[string]interface{}, fieldName string) []string {
var ret []string
if labels, ok := parentMap[fieldName].([]interface{}); ok {
for _, label := range labels {
if strLabel, ok := label.(string); ok {
ret = append(ret, strLabel)
}
}
}
return ret
}
if v, ok := m["custom_settings"]; ok {
set["custom_settings"] = true
@ -322,15 +334,9 @@ func (s *MacOSSettings) FromMap(m map[string]interface{}) (map[string]bool, erro
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)
}
}
}
spec.Labels = extractLabelField(m, "labels")
spec.LabelsIncludeAll = extractLabelField(m, "labels_include_all")
spec.LabelsExcludeAny = extractLabelField(m, "labels_exclude_any")
csSpecs = append(csSpecs, spec)
} else if m, ok := v.(string); ok { // for backwards compatibility with the old way to define profiles

View file

@ -195,11 +195,11 @@ 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"`
// Labels are the associated labels for this profile
Labels []ConfigurationProfileLabel `db:"labels" json:"labels,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
Checksum []byte `db:"checksum" json:"checksum,omitempty"`
LabelsIncludeAll []ConfigurationProfileLabel `db:"-" json:"labels_include_all,omitempty"`
LabelsExcludeAny []ConfigurationProfileLabel `db:"-" json:"labels_exclude_any,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
}
// ConfigurationProfileLabel represents the many-to-many relationship between
@ -212,6 +212,7 @@ type ConfigurationProfileLabel struct {
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
Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used to store the profile in LabelsIncludeAll or LabelsExcludeAny on the parent profile
}
func NewMDMAppleConfigProfile(raw []byte, teamID *uint) (*MDMAppleConfigProfile, error) {
@ -561,8 +562,9 @@ type MDMAppleDeclaration struct {
// Checksum is a checksum of the JSON contents
Checksum string `db:"checksum" json:"-"`
// Labels are the labels associated with this Declaration
Labels []ConfigurationProfileLabel `db:"labels" json:"labels,omitempty"`
// labels associated with this Declaration
LabelsIncludeAll []ConfigurationProfileLabel `db:"-" json:"labels_include_all,omitempty"`
LabelsExcludeAny []ConfigurationProfileLabel `db:"-" json:"labels_exclude_any,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`

View file

@ -129,6 +129,8 @@ type ExpectedMDMProfile struct {
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"`
// CountNonBrokenLabels is used to enable queries that filter based on profile <-> label mappings.
CountNonBrokenLabels uint `db:"count_non_broken_labels"`
}
// IsWithinGracePeriod returns true if the host is within the grace period for the profile.
@ -364,23 +366,29 @@ 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"`
UploadedAt time.Time `json:"updated_at" db:"uploaded_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
Labels []ConfigurationProfileLabel `json:"labels,omitempty" db:"-"`
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"`
UploadedAt time.Time `json:"updated_at" db:"uploaded_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
LabelsIncludeAll []ConfigurationProfileLabel `json:"labels_include_all,omitempty" db:"-"`
LabelsExcludeAny []ConfigurationProfileLabel `json:"labels_exclude_any,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"`
Name string `json:"name,omitempty"`
Contents []byte `json:"contents,omitempty"`
// Deprecated: Labels is the backwards-compatible way of specifying
// LabelsIncludeAll.
Labels []string `json:"labels,omitempty"`
LabelsIncludeAll []string `json:"labels_include_all,omitempty"`
LabelsExcludeAny []string `json:"labels_exclude_any,omitempty"`
}
func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConfigProfilePayload {
@ -389,13 +397,14 @@ func NewMDMConfigProfilePayloadFromWindows(cp *MDMWindowsConfigProfile) *MDMConf
tid = cp.TeamID
}
return &MDMConfigProfilePayload{
ProfileUUID: cp.ProfileUUID,
TeamID: tid,
Name: cp.Name,
Platform: "windows",
CreatedAt: cp.CreatedAt,
UploadedAt: cp.UploadedAt,
Labels: cp.Labels,
ProfileUUID: cp.ProfileUUID,
TeamID: tid,
Name: cp.Name,
Platform: "windows",
CreatedAt: cp.CreatedAt,
UploadedAt: cp.UploadedAt,
LabelsIncludeAll: cp.LabelsIncludeAll,
LabelsExcludeAny: cp.LabelsExcludeAny,
}
}
@ -405,15 +414,16 @@ func NewMDMConfigProfilePayloadFromApple(cp *MDMAppleConfigProfile) *MDMConfigPr
tid = cp.TeamID
}
return &MDMConfigProfilePayload{
ProfileUUID: cp.ProfileUUID,
TeamID: tid,
Name: cp.Name,
Identifier: cp.Identifier,
Platform: "darwin",
Checksum: cp.Checksum,
CreatedAt: cp.CreatedAt,
UploadedAt: cp.UploadedAt,
Labels: cp.Labels,
ProfileUUID: cp.ProfileUUID,
TeamID: tid,
Name: cp.Name,
Identifier: cp.Identifier,
Platform: "darwin",
Checksum: cp.Checksum,
CreatedAt: cp.CreatedAt,
UploadedAt: cp.UploadedAt,
LabelsIncludeAll: cp.LabelsIncludeAll,
LabelsExcludeAny: cp.LabelsExcludeAny,
}
}
@ -423,23 +433,37 @@ func NewMDMConfigProfilePayloadFromAppleDDM(decl *MDMAppleDeclaration) *MDMConfi
tid = decl.TeamID
}
return &MDMConfigProfilePayload{
ProfileUUID: decl.DeclarationUUID,
TeamID: tid,
Name: decl.Name,
Identifier: decl.Identifier,
Platform: "darwin",
Checksum: []byte(decl.Checksum),
CreatedAt: decl.CreatedAt,
UploadedAt: decl.UploadedAt,
Labels: decl.Labels,
ProfileUUID: decl.DeclarationUUID,
TeamID: tid,
Name: decl.Name,
Identifier: decl.Identifier,
Platform: "darwin",
Checksum: []byte(decl.Checksum),
CreatedAt: decl.CreatedAt,
UploadedAt: decl.UploadedAt,
LabelsIncludeAll: decl.LabelsIncludeAll,
LabelsExcludeAny: decl.LabelsExcludeAny,
}
}
// MDMProfileSpec represents the spec used to define configuration
// profiles via yaml files.
type MDMProfileSpec struct {
Path string `json:"path,omitempty"`
Path string `json:"path,omitempty"`
// Deprecated: the Labels field is now deprecated, it is superseded by
// LabelsIncludeAll, so any value set via this field will be transferred to
// LabelsIncludeAll.
Labels []string `json:"labels,omitempty"`
// LabelsIncludeAll is a list of label names that the host must be a member
// of in order to receive the profile. It must be a member of all listed
// labels.
LabelsIncludeAll []string `json:"labels_include_all,omitempty"`
// LabelsExcludeAll is a list of label names that the host must not be a
// member of in order to receive the profile. It must not be a member of any
// of the listed labels.
LabelsExcludeAny []string `json:"labels_exclude_any,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface to add backwards
@ -487,6 +511,14 @@ func (p *MDMProfileSpec) Copy() *MDMProfileSpec {
clone.Labels = make([]string, len(p.Labels))
copy(clone.Labels, p.Labels)
}
if len(p.LabelsIncludeAll) > 0 {
clone.LabelsIncludeAll = make([]string, len(p.LabelsIncludeAll))
copy(clone.LabelsIncludeAll, p.LabelsIncludeAll)
}
if len(p.LabelsExcludeAny) > 0 {
clone.LabelsExcludeAny = make([]string, len(p.LabelsExcludeAny))
copy(clone.LabelsExcludeAny, p.LabelsExcludeAny)
}
return &clone
}
@ -506,35 +538,64 @@ func MDMProfileSpecsMatch(a, b []MDMProfileSpec) bool {
return false
}
pathLabelCounts := make(map[string]map[string]int)
pathLabelIncludeCounts := make(map[string]map[string]int)
for _, v := range a {
pathLabelCounts[v.Path] = labelCountMap(v.Labels)
// the deprecated Labels field is only relevant if LabelsIncludeAll is
// empty.
if len(v.LabelsIncludeAll) > 0 {
pathLabelIncludeCounts[v.Path] = labelCountMap(v.LabelsIncludeAll)
} else {
pathLabelIncludeCounts[v.Path] = labelCountMap(v.Labels)
}
}
pathLabelExcludeCounts := make(map[string]map[string]int)
for _, v := range a {
pathLabelExcludeCounts[v.Path] = labelCountMap(v.LabelsExcludeAny)
}
for _, v := range b {
labels, ok := pathLabelCounts[v.Path]
if !ok {
includeLabels, okIncl := pathLabelIncludeCounts[v.Path]
excludeLabels, okExcl := pathLabelExcludeCounts[v.Path]
if !okIncl || !okExcl {
return false
}
bLabelCounts := labelCountMap(v.Labels)
for label, count := range bLabelCounts {
if labels[label] != count {
var bLabelIncludeCounts map[string]int
if len(v.LabelsIncludeAll) > 0 {
bLabelIncludeCounts = labelCountMap(v.LabelsIncludeAll)
} else {
bLabelIncludeCounts = labelCountMap(v.Labels)
}
for label, count := range bLabelIncludeCounts {
if includeLabels[label] != count {
return false
}
labels[label] -= count
includeLabels[label] -= count
}
for _, count := range labels {
for _, count := range includeLabels {
if count != 0 {
return false
}
}
delete(pathLabelCounts, v.Path)
bLabelExcludeCounts := labelCountMap(v.LabelsExcludeAny)
for label, count := range bLabelExcludeCounts {
if excludeLabels[label] != count {
return false
}
excludeLabels[label] -= count
}
for _, count := range excludeLabels {
if count != 0 {
return false
}
}
delete(pathLabelIncludeCounts, v.Path)
delete(pathLabelExcludeCounts, v.Path)
}
return len(pathLabelCounts) == 0
return len(pathLabelIncludeCounts) == 0 && len(pathLabelExcludeCounts) == 0
}
type MDMAssetName string

View file

@ -316,6 +316,66 @@ func TestMDMProfileSpecsMatch(t *testing.T) {
},
expected: false,
},
{
name: "Include Labels Match",
a: []fleet.MDMProfileSpec{
{Path: "path1", LabelsIncludeAll: []string{"label1", "label2"}},
{Path: "path2", LabelsIncludeAll: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", LabelsIncludeAll: []string{"label2", "label1"}},
{Path: "path2", LabelsIncludeAll: []string{"label3"}},
},
expected: true,
},
{
name: "Exclude Labels Match",
a: []fleet.MDMProfileSpec{
{Path: "path1", LabelsExcludeAny: []string{"label1", "label2"}},
{Path: "path2", LabelsExcludeAny: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", LabelsExcludeAny: []string{"label2", "label1"}},
{Path: "path2", LabelsExcludeAny: []string{"label3"}},
},
expected: true,
},
{
name: "Include Labels Mismatch",
a: []fleet.MDMProfileSpec{
{Path: "path1", LabelsIncludeAll: []string{"label1", "label2"}},
{Path: "path2", LabelsIncludeAll: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", LabelsIncludeAll: []string{"label2", "label1"}},
{Path: "path2", LabelsIncludeAll: []string{"label4"}},
},
expected: false,
},
{
name: "Exclude Labels Mismatch",
a: []fleet.MDMProfileSpec{
{Path: "path1", LabelsExcludeAny: []string{"label1", "label2"}},
{Path: "path2", LabelsExcludeAny: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", LabelsExcludeAny: []string{"label2", "label1"}},
{Path: "path3", LabelsExcludeAny: []string{"label3"}},
},
expected: false,
},
{
name: "Deprecated Labels Match IncludeAll",
a: []fleet.MDMProfileSpec{
{Path: "path1", Labels: []string{"label1", "label2"}},
{Path: "path2", LabelsExcludeAny: []string{"label3"}},
},
b: []fleet.MDMProfileSpec{
{Path: "path1", LabelsIncludeAll: []string{"label2", "label1"}},
{Path: "path2", LabelsExcludeAny: []string{"label3"}},
},
expected: true,
},
}
for _, tc := range tests {

View file

@ -710,9 +710,9 @@ 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, labels []string) (*MDMAppleConfigProfile, error)
NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader, labels []string, labelsExcludeMode bool) (*MDMAppleConfigProfile, error)
// NewMDMAppleConfigProfileWithPayload creates a new declaration for the specified team.
NewMDMAppleDeclaration(ctx context.Context, teamID uint, r io.Reader, labels []string, name string) (*MDMAppleDeclaration, error)
NewMDMAppleDeclaration(ctx context.Context, teamID uint, r io.Reader, labels []string, name string, labelsExcludeMode bool) (*MDMAppleDeclaration, error)
// GetMDMAppleConfigProfileByDeprecatedID retrieves the specified Apple
// configuration profile via its numeric ID. This method is deprecated and
@ -974,7 +974,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, labels []string) (*MDMWindowsConfigProfile, error)
NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader, labels []string, labelsExcludeMode bool) (*MDMWindowsConfigProfile, error)
// NewMDMUnsupportedConfigProfile is called when a profile with an
// unsupported extension is uploaded.

View file

@ -33,12 +33,10 @@ type TeamPayload struct {
// need to be able which part of the MDM config was provided in the request,
// so the fields are pointers to structs.
type TeamPayloadMDM struct {
EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"`
MacOSUpdates *MacOSUpdates `json:"macos_updates"`
WindowsUpdates *WindowsUpdates `json:"windows_updates"`
MacOSSettings *MacOSSettings `json:"macos_settings"`
MacOSSetup *MacOSSetup `json:"macos_setup"`
WindowsSettings *WindowsSettings `json:"windows_settings"`
EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"`
MacOSUpdates *MacOSUpdates `json:"macos_updates"`
WindowsUpdates *WindowsUpdates `json:"windows_updates"`
MacOSSetup *MacOSSetup `json:"macos_setup"`
}
// Team is the data representation for the "Team" concept (group of hosts and

View file

@ -32,13 +32,14 @@ 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:"-"`
Labels []ConfigurationProfileLabel `db:"labels" json:"labels,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
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:"-"`
LabelsIncludeAll []ConfigurationProfileLabel `db:"-" json:"labels_include_all,omitempty"`
LabelsExcludeAny []ConfigurationProfileLabel `db:"-" json:"labels_exclude_any,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"updated_at"` // NOTE: JSON field is still `updated_at` for historical reasons, would be an API breaking change
}
// ValidateUserProvided ensures that the SyncML content in the profile is valid
@ -124,9 +125,7 @@ func (m *MDMWindowsConfigProfile) ValidateUserProvided() error {
return err
}
}
}
}
return nil

View file

@ -743,6 +743,25 @@ func (svc *Service) validateMDM(
`Couldn't update macos_setup because MDM features aren't turned on in Fleet. Use fleetctl generate mdm-apple and then fleet serve with mdm configuration to turn on MDM features.`)
}
}
checkCustomSettings := func(prefix string, customSettings []fleet.MDMProfileSpec) {
for i, prof := range customSettings {
count := 0
for _, b := range []bool{len(prof.Labels) > 0, len(prof.LabelsIncludeAll) > 0, len(prof.LabelsExcludeAny) > 0} {
if b {
count++
}
}
if count > 1 {
invalid.Append(fmt.Sprintf("%s_settings.custom_settings", prefix),
fmt.Sprintf(`Couldn't edit %s_settings.custom_settings. For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included.`, prefix))
}
if len(prof.Labels) > 0 {
customSettings[i].LabelsIncludeAll = customSettings[i].Labels
customSettings[i].Labels = nil
}
}
}
checkCustomSettings("macos", mdm.MacOSSettings.CustomSettings)
if !mdm.WindowsEnabledAndConfigured {
if mdm.WindowsSettings.CustomSettings.Set &&
@ -752,6 +771,7 @@ func (svc *Service) validateMDM(
`Couldnt edit windows_settings.custom_settings. Windows MDM isnt turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)
}
}
checkCustomSettings("windows", mdm.WindowsSettings.CustomSettings.Value)
if name := mdm.AppleBMDefaultTeam; name != "" && name != oldMdm.AppleBMDefaultTeam {
if !license.IsPremium() {

View file

@ -306,7 +306,7 @@ func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{},
}
defer ff.Close()
// providing an empty set of labels since this endpoint is only maintained for backwards compat
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, nil)
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, nil, false)
if err != nil {
return &newMDMAppleConfigProfileResponse{Err: err}, nil
}
@ -315,7 +315,7 @@ func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{},
}, nil
}
func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader, labels []string) (*fleet.MDMAppleConfigProfile, error) {
func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader, labels []string, labelsExcludeMode bool) (*fleet.MDMAppleConfigProfile, error) {
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
@ -359,7 +359,11 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating labels")
}
cp.Labels = labelMap
if labelsExcludeMode {
cp.LabelsExcludeAny = labelMap
} else {
cp.LabelsIncludeAll = labelMap
}
newCP, err := svc.ds.NewMDMAppleConfigProfile(ctx, *cp)
if err != nil {
@ -401,7 +405,7 @@ func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r
return newCP, nil
}
func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r io.Reader, labels []string, name string) (*fleet.MDMAppleDeclaration, error) {
func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r io.Reader, labels []string, name string, labelsExcludeMode bool) (*fleet.MDMAppleDeclaration, error) {
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
@ -455,8 +459,11 @@ func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, r i
d := fleet.NewMDMAppleDeclaration(data, tmID, name, rawDecl.Type, rawDecl.Identifier)
// TODO(roberto): this should be part of fleet.NewMDMAppleDeclaration
d.Labels = validatedLabels
if labelsExcludeMode {
d.LabelsExcludeAny = validatedLabels
} else {
d.LabelsIncludeAll = validatedLabels
}
decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d)
if err != nil {

View file

@ -644,11 +644,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), nil)
_, err := svc.NewMDMAppleConfigProfile(ctx, 0, bytes.NewReader(mcBytes), nil, false)
checkShouldFail(err, tt.shouldFailGlobal)
// test authz create new profile (team 1)
_, err = svc.NewMDMAppleConfigProfile(ctx, 1, bytes.NewReader(mcBytes), nil)
_, err = svc.NewMDMAppleConfigProfile(ctx, 1, bytes.NewReader(mcBytes), nil, false)
checkShouldFail(err, tt.shouldFailTeam)
// test authz list profiles (no team)
@ -710,7 +710,7 @@ func TestNewMDMAppleConfigProfile(t *testing.T) {
return nil
}
cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil)
cp, err := svc.NewMDMAppleConfigProfile(ctx, 0, r, nil, false)
require.NoError(t, err)
require.Equal(t, "Foo", cp.Name)
require.Equal(t, "Bar", cp.Identifier)

View file

@ -375,9 +375,11 @@ func getProfilesContents(baseDir string, macProfiles []fleet.MDMProfileSpec, win
extByName[name] = ext
result = append(result, fleet.MDMProfileBatchPayload{
Name: name,
Contents: fileContents,
Labels: profile.Labels,
Name: name,
Contents: fileContents,
Labels: profile.Labels,
LabelsIncludeAll: profile.LabelsIncludeAll,
LabelsExcludeAny: profile.LabelsExcludeAny,
})
}
@ -828,6 +830,18 @@ func extractAppCfgCustomSettings(appCfg interface{}, platformKey string) []fleet
return []fleet.MDMProfileSpec{}
}
extractLabelField := func(parentMap map[string]interface{}, fieldName string) []string {
var ret []string
if labels, ok := parentMap[fieldName].([]interface{}); ok {
for _, label := range labels {
if strLabel, ok := label.(string); ok {
ret = append(ret, strLabel)
}
}
}
return ret
}
csSpecs := make([]fleet.MDMProfileSpec, 0, len(csAny))
for _, v := range csAny {
if m, ok := v.(map[string]interface{}); ok {
@ -838,14 +852,11 @@ func extractAppCfgCustomSettings(appCfg interface{}, platformKey string) []fleet
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)
}
}
}
// at this stage we extract and return all supported label fields, the
// validations are done later on in the Fleet API endpoint.
profSpec.Labels = extractLabelField(m, "labels")
profSpec.LabelsIncludeAll = extractLabelField(m, "labels_include_all")
profSpec.LabelsExcludeAny = extractLabelField(m, "labels_exclude_any")
if profSpec.Path != "" {
csSpecs = append(csSpecs, profSpec)

View file

@ -168,10 +168,10 @@ func (s *integrationMDMTestSuite) TestAppleDDMBatchUpload() {
require.Equal(t, "label_2", createResp.Label.Name)
lbl2 := createResp.Label.Label
// Add with labels
// Add with the deprecated "labels" and the new LabelsIncludeAll field
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N5", Contents: decls[5], Labels: []string{lbl1.Name, lbl2.Name}},
{Name: "N6", Contents: decls[6], Labels: []string{lbl1.Name}},
{Name: "N6", Contents: decls[6], LabelsIncludeAll: []string{lbl1.Name}},
}}, http.StatusNoContent)
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
@ -181,11 +181,11 @@ func (s *integrationMDMTestSuite) TestAppleDDMBatchUpload() {
require.Equal(t, "darwin", resp.Profiles[0].Platform)
require.Equal(t, "N6", resp.Profiles[1].Name)
require.Equal(t, "darwin", resp.Profiles[1].Platform)
require.Len(t, resp.Profiles[0].Labels, 2)
require.Equal(t, lbl1.Name, resp.Profiles[0].Labels[0].LabelName)
require.Equal(t, lbl2.Name, resp.Profiles[0].Labels[1].LabelName)
require.Len(t, resp.Profiles[1].Labels, 1)
require.Equal(t, lbl1.Name, resp.Profiles[1].Labels[0].LabelName)
require.Len(t, resp.Profiles[0].LabelsIncludeAll, 2)
require.Equal(t, lbl1.Name, resp.Profiles[0].LabelsIncludeAll[0].LabelName)
require.Equal(t, lbl2.Name, resp.Profiles[0].LabelsIncludeAll[1].LabelName)
require.Len(t, resp.Profiles[1].LabelsIncludeAll, 1)
require.Equal(t, lbl1.Name, resp.Profiles[1].LabelsIncludeAll[0].LabelName)
}
func (s *integrationMDMTestSuite) TestMDMAppleDeviceManagementRequests() {

View file

@ -519,14 +519,14 @@ func (s *integrationMDMTestSuite) recordAppleHostStatus(
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
// command uuid is a random value, we only care that's set
require.NotEmpty(t, fullCmd.CommandUUID)
fullCmd.CommandUUID = ""
require.NotEmpty(t, cmd.CommandUUID)
// strip the signature of the profiles so they can be easily compared
if fullCmd.Command.RequestType == "InstallProfile" {
if cmd.Command.RequestType == "InstallProfile" {
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
fullCmd.CommandUUID = ""
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
require.NoError(t, err)
fullCmd.Command.InstallProfile.Payload = p7.Content

File diff suppressed because it is too large Load diff

View file

@ -217,12 +217,20 @@ func (s *integrationMDMTestSuite) SetupSuite() {
schedule.WithLogger(logger),
schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error {
if s.onProfileJobDone != nil {
s.onProfileJobDone()
defer s.onProfileJobDone()
}
err = ReconcileAppleProfiles(ctx, ds, mdmCommander, logger)
require.NoError(s.T(), err)
return err
}),
schedule.WithJob("manage_apple_declarations", func(ctx context.Context) error {
if s.onProfileJobDone != nil {
defer s.onProfileJobDone()
}
err = ReconcileAppleDeclarations(ctx, ds, mdmCommander, logger)
require.NoError(s.T(), err)
return err
}),
schedule.WithJob("manage_windows_profiles", func(ctx context.Context) error {
if s.onProfileJobDone != nil {
defer s.onProfileJobDone()
@ -430,9 +438,9 @@ func (s *integrationMDMTestSuite) mockDEPResponse(handler http.Handler) {
}
func (s *integrationMDMTestSuite) awaitTriggerProfileSchedule(t *testing.T) {
// two jobs running sequentially (macOS then Windows) on the same schedule
// three jobs running sequentially (macOS profiles and declarations, then Windows) on the same schedule
var wg sync.WaitGroup
wg.Add(2)
wg.Add(3)
s.onProfileJobDone = wg.Done
_, err := s.profileSchedule.Trigger()
require.NoError(t, err)
@ -658,13 +666,13 @@ func checkNextPayloads(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, forc
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
switch cmd.Command.RequestType {
case "InstallProfile":
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
installs = append(installs, fullCmd.Command.InstallProfile.Payload)
case "RemoveProfile":
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
removes = append(removes, fullCmd.Command.RemoveProfile.Identifier)
}
if forceDeviceErr {
@ -2114,7 +2122,6 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
MDM: &fleet.TeamPayloadMDM{
EnableDiskEncryption: optjson.SetBool(true),
MacOSSettings: &fleet.MacOSSettings{},
},
}, http.StatusOK, &modResp)
require.True(t, modResp.Team.Config.MDM.EnableDiskEncryption)
@ -3958,7 +3965,7 @@ func (s *integrationMDMTestSuite) TestMacosSetupAssistant() {
}
// only asserts the profile identifier, status and operation (per host)
func (s *integrationMDMTestSuite) assertHostConfigProfiles(want map[*fleet.Host][]fleet.HostMDMAppleProfile) {
func (s *integrationMDMTestSuite) assertHostAppleConfigProfiles(want map[*fleet.Host][]fleet.HostMDMAppleProfile) {
t := s.T()
ds := s.ds
ctx := context.Background()
@ -3966,7 +3973,11 @@ func (s *integrationMDMTestSuite) assertHostConfigProfiles(want map[*fleet.Host]
for h, wantProfs := range want {
gotProfs, err := ds.GetHostMDMAppleProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(gotProfs), "host uuid: %s", h.UUID)
idents := make([]string, 0, len(gotProfs))
for _, gp := range gotProfs {
idents = append(idents, gp.Identifier)
}
require.Equal(t, len(wantProfs), len(gotProfs), "apple host uuid: %s, profiles: %v", h.UUID, idents)
sort.Slice(gotProfs, func(i, j int) bool {
l, r := gotProfs[i], gotProfs[j]
@ -3985,6 +3996,34 @@ func (s *integrationMDMTestSuite) assertHostConfigProfiles(want map[*fleet.Host]
}
}
// only asserts the profile name, status and operation (per host)
func (s *integrationMDMTestSuite) assertHostWindowsConfigProfiles(want map[*fleet.Host][]fleet.HostMDMWindowsProfile) {
t := s.T()
ds := s.ds
ctx := context.Background()
for h, wantProfs := range want {
gotProfs, err := ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(gotProfs), "host uuid: %s", h.UUID)
sort.Slice(gotProfs, func(i, j int) bool {
l, r := gotProfs[i], gotProfs[j]
return l.Name < r.Name
})
sort.Slice(wantProfs, func(i, j int) bool {
l, r := wantProfs[i], wantProfs[j]
return l.Name < r.Name
})
for i, wp := range wantProfs {
gp := gotProfs[i]
require.Equal(t, wp.Name, gp.Name, "host uuid: %s, prof id: %s", h.UUID, gp.Name)
require.Equal(t, wp.OperationType, gp.OperationType, "host uuid: %s, prof id: %s", h.UUID, gp.Name)
require.Equal(t, wp.Status, gp.Status, "host uuid: %s, prof id: %s", h.UUID, gp.Name)
}
}
}
func (s *integrationMDMTestSuite) assertConfigProfilesByIdentifier(teamID *uint, profileIdent string, exists bool) (profile *fleet.MDMAppleConfigProfile) {
t := s.T()
if teamID == nil {

View file

@ -1194,9 +1194,10 @@ func isAppleDeclarationUUID(profileUUID string) bool {
////////////////////////////////////////////////////////////////////////////////
type newMDMConfigProfileRequest struct {
TeamID uint
Profile *multipart.FileHeader
Labels []string
TeamID uint
Profile *multipart.FileHeader
LabelsIncludeAll []string
LabelsExcludeAny []string
}
func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
@ -1235,7 +1236,25 @@ func (newMDMConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Req
}
// add labels
decoded.Labels = r.MultipartForm.Value["labels"]
var existsIncl, existsExcl, existsDepr bool
var deprecatedLabels []string
decoded.LabelsIncludeAll, existsIncl = r.MultipartForm.Value["labels_include_all"]
decoded.LabelsExcludeAny, existsExcl = r.MultipartForm.Value["labels_exclude_any"]
deprecatedLabels, existsDepr = r.MultipartForm.Value["labels"]
// validate that only one of the labels type is provided
var count int
for _, b := range []bool{existsIncl, existsExcl, existsDepr} {
if b {
count++
}
}
if count > 1 {
return nil, &fleet.BadRequestError{Message: `Only one of "labels_exclude_any", "labels_include_all" or "labels" can be included.`}
}
if existsDepr {
decoded.LabelsIncludeAll = deprecatedLabels
}
return &decoded, nil
}
@ -1260,10 +1279,18 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
profileName := strings.TrimSuffix(filepath.Base(req.Profile.Filename), fileExt)
isMobileConfig := strings.EqualFold(fileExt, ".mobileconfig")
isJSON := strings.EqualFold(fileExt, ".json")
labels := req.LabelsIncludeAll
excludeMode := false
if len(req.LabelsExcludeAny) > 0 {
labels = req.LabelsExcludeAny
excludeMode = true
}
if isMobileConfig || isJSON {
// Then it's an Apple configuration file
if isJSON {
decl, err := svc.NewMDMAppleDeclaration(ctx, req.TeamID, ff, req.Labels, profileName)
decl, err := svc.NewMDMAppleDeclaration(ctx, req.TeamID, ff, labels, profileName, excludeMode)
if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil
}
@ -1274,7 +1301,7 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
}
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, req.Labels)
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, labels, excludeMode)
if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil
}
@ -1284,7 +1311,7 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
}
if isWindows := strings.EqualFold(fileExt, ".xml"); isWindows {
cp, err := svc.NewMDMWindowsConfigProfile(ctx, req.TeamID, profileName, ff, req.Labels)
cp, err := svc.NewMDMWindowsConfigProfile(ctx, req.TeamID, profileName, ff, labels, excludeMode)
if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil
}
@ -1308,7 +1335,7 @@ func (svc *Service) NewMDMUnsupportedConfigProfile(ctx context.Context, teamID u
return &fleet.BadRequestError{Message: "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file."}
}
func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader, labels []string) (*fleet.MDMWindowsConfigProfile, error) {
func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint, profileName string, r io.Reader, labels []string, labelsExcludeMode bool) (*fleet.MDMWindowsConfigProfile, error) {
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
@ -1357,7 +1384,11 @@ func (svc *Service) NewMDMWindowsConfigProfile(ctx context.Context, teamID uint,
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating labels")
}
cp.Labels = labelMap
if labelsExcludeMode {
cp.LabelsExcludeAny = labelMap
} else {
cp.LabelsIncludeAll = labelMap
}
newCP, err := svc.ds.NewMDMWindowsConfigProfile(ctx, cp)
if err != nil {
@ -1522,8 +1553,17 @@ func (svc *Service) BatchSetMDMProfiles(
}
labels := []string{}
for _, prof := range profiles {
labels = append(labels, prof.Labels...)
for i := range profiles {
// from this point on (after this condition), only LabelsIncludeAll or
// LabelsExcludeAny need to be checked.
if len(profiles[i].Labels) > 0 {
// must update the struct in the slice directly, because we don't have a
// pointer to it (it is a slice of structs, not of pointer to structs)
profiles[i].LabelsIncludeAll = profiles[i].Labels
profiles[i].Labels = nil
}
labels = append(labels, profiles[i].LabelsIncludeAll...)
labels = append(labels, profiles[i].LabelsExcludeAny...)
}
labelMap, err := svc.batchValidateProfileLabels(ctx, labels)
if err != nil {
@ -1732,13 +1772,23 @@ func getAppleProfiles(
}
mdmDecl := fleet.NewMDMAppleDeclaration(prof.Contents, tmID, prof.Name, rawDecl.Type, rawDecl.Identifier)
for _, labelName := range prof.Labels {
for _, labelName := range prof.LabelsIncludeAll {
if lbl, ok := labelMap[labelName]; ok {
declLabel := fleet.ConfigurationProfileLabel{
LabelName: lbl.LabelName,
LabelID: lbl.LabelID,
}
mdmDecl.Labels = append(mdmDecl.Labels, declLabel)
mdmDecl.LabelsIncludeAll = append(mdmDecl.LabelsIncludeAll, declLabel)
}
}
for _, labelName := range prof.LabelsExcludeAny {
if lbl, ok := labelMap[labelName]; ok {
declLabel := fleet.ConfigurationProfileLabel{
LabelName: lbl.LabelName,
LabelID: lbl.LabelID,
Exclude: true,
}
mdmDecl.LabelsExcludeAny = append(mdmDecl.LabelsExcludeAny, declLabel)
}
}
@ -1773,9 +1823,14 @@ func getAppleProfiles(
"invalid mobileconfig profile")
}
for _, labelName := range prof.Labels {
for _, labelName := range prof.LabelsIncludeAll {
if lbl, ok := labelMap[labelName]; ok {
mdmProf.Labels = append(mdmProf.Labels, lbl)
mdmProf.LabelsIncludeAll = append(mdmProf.LabelsIncludeAll, lbl)
}
}
for _, labelName := range prof.LabelsExcludeAny {
if lbl, ok := labelMap[labelName]; ok {
mdmProf.LabelsExcludeAny = append(mdmProf.LabelsExcludeAny, lbl)
}
}
@ -1844,9 +1899,14 @@ func getWindowsProfiles(
Name: profile.Name,
SyncML: profile.Contents,
}
for _, labelName := range profile.Labels {
for _, labelName := range profile.LabelsIncludeAll {
if lbl, ok := labelMap[labelName]; ok {
mdmProf.Labels = append(mdmProf.Labels, lbl)
mdmProf.LabelsIncludeAll = append(mdmProf.LabelsIncludeAll, lbl)
}
}
for _, labelName := range profile.LabelsExcludeAny {
if lbl, ok := labelMap[labelName]; ok {
mdmProf.LabelsExcludeAny = append(mdmProf.LabelsExcludeAny, lbl)
}
}
@ -1876,6 +1936,21 @@ func getWindowsProfiles(
func validateProfiles(profiles []fleet.MDMProfileBatchPayload) error {
for _, profile := range profiles {
// validate that only one of labels, labels_include_all and labels_exclude_any is provided.
var count int
for _, b := range []bool{
len(profile.LabelsIncludeAll) > 0,
len(profile.LabelsExcludeAny) > 0,
len(profile.Labels) > 0,
} {
if b {
count++
}
}
if count > 1 {
return fleet.NewInvalidArgumentError("mdm", `Couldn't edit custom_settings. For each profile, only one of "labels_exclude_any", "labels_include_all" or "labels" can be included.`)
}
if len(profile.Contents) > 1024*1024 {
return fleet.NewInvalidArgumentError("mdm", "maximum configuration profile file size is 1 MB")
}

View file

@ -1088,11 +1088,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), nil)
_, err = svc.NewMDMWindowsConfigProfile(ctx, 0, "prof", strings.NewReader(winProfContent), nil, false)
checkShouldFail(t, err, tt.shouldFailGlobalWrite)
// test authz create new profile (team 1)
_, err = svc.NewMDMWindowsConfigProfile(ctx, 1, "prof", strings.NewReader(winProfContent), nil)
_, err = svc.NewMDMWindowsConfigProfile(ctx, 1, "prof", strings.NewReader(winProfContent), nil, false)
checkShouldFail(t, err, tt.shouldFailTeamWrite)
// test authz delete config profile (no team)
@ -1174,7 +1174,7 @@ func TestUploadWindowsMDMConfigProfileValidations(t *testing.T) {
}, nil
}
ctx = test.UserContext(ctx, test.UserAdmin)
_, err := svc.NewMDMWindowsConfigProfile(ctx, c.tmID, "foo", strings.NewReader(c.profile), nil)
_, err := svc.NewMDMWindowsConfigProfile(ctx, c.tmID, "foo", strings.NewReader(c.profile), nil, false)
if c.wantErr != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.wantErr)
@ -1567,6 +1567,34 @@ func TestValidateProfiles(t *testing.T) {
},
wantErr: true,
},
{
name: "Windows Profile With Deprecated Labels",
profiles: []fleet.MDMProfileBatchPayload{
{Name: "windowsProfile", Labels: []string{"a"}, Contents: []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>")},
},
wantErr: false,
},
{
name: "Windows Profile With Excluded Labels",
profiles: []fleet.MDMProfileBatchPayload{
{Name: "windowsProfile", LabelsExcludeAny: []string{"a"}, Contents: []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>")},
},
wantErr: false,
},
{
name: "Windows Profile With Included Labels",
profiles: []fleet.MDMProfileBatchPayload{
{Name: "windowsProfile", LabelsIncludeAll: []string{"a"}, Contents: []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>")},
},
wantErr: false,
},
{
name: "Windows Profile With Mixed Labels",
profiles: []fleet.MDMProfileBatchPayload{
{Name: "windowsProfile", Labels: []string{"z"}, LabelsIncludeAll: []string{"a"}, Contents: []byte("<replace><Target><LocURI>Custom/URI</LocURI></Target></replace>")},
},
wantErr: true,
},
{
name: "Too large profile",
profiles: []fleet.MDMProfileBatchPayload{

View file

@ -116,6 +116,8 @@ github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSettings fleet.MacOSSettings
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/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsExcludeAny []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

View file

@ -1,2 +1,4 @@
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/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsExcludeAny []string

View file

@ -15,6 +15,8 @@ github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettin
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/MDMProfileSpec LabelsIncludeAll []string
github.com/fleetdm/fleet/v4/server/fleet/MDMProfileSpec LabelsExcludeAny []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