Merge branch 'main' into mna-17401-puppet-related-integration-tests

This commit is contained in:
Martin Angers 2024-03-27 12:42:31 -04:00 committed by GitHub
commit 9d878f1fd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 7946 additions and 1126 deletions

View file

@ -1,3 +1,9 @@
## Fleet 4.47.3 (Mar 26, 2024)
### Bug fixes
* Fixed a bug where valid Windows MDM enrollments would show up as unmanaged (EnrollmentState 3).
## Fleet 4.47.2 (Mar 22, 2024)
### Bug fixes

View file

@ -0,0 +1 @@
* UI fix of sql result sort for both string and numerical columns on live query results, live policy results, and query report

View file

@ -0,0 +1 @@
- Adds API functionality for creating DDM declarations, both individually and as a batch.

View file

@ -0,0 +1 @@
- add ddm activities to the fleet UI

View file

@ -0,0 +1 @@
- update UI to support macos DDM profiles.

View file

@ -8,7 +8,7 @@ version: v6.0.2
home: https://github.com/fleetdm/fleet
sources:
- https://github.com/fleetdm/fleet.git
appVersion: v4.47.2
appVersion: v4.47.3
dependencies:
- name: mysql
condition: mysql.enabled

View file

@ -2,7 +2,7 @@
# All settings related to how Fleet is deployed in Kubernetes
hostName: fleet.localhost
replicas: 3 # The number of Fleet instances to deploy
imageTag: v4.47.2 # Version of Fleet to deploy
imageTag: v4.47.3 # Version of Fleet to deploy
podAnnotations: {} # Additional annotations to add to the Fleet pod
serviceAccountAnnotations: {} # Additional annotations to add to the Fleet service account
resources:

View file

@ -1033,6 +1033,9 @@ func newMDMProfileManager(
schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error {
return service.ReconcileAppleProfiles(ctx, ds, commander, logger)
}),
schedule.WithJob("manage_apple_declarations", func(ctx context.Context) error {
return service.ReconcileAppleDeclarations(ctx, ds, commander, logger)
}),
schedule.WithJob("manage_windows_profiles", func(ctx context.Context) error {
return service.ReconcileWindowsProfiles(ctx, ds, logger)
}),

View file

@ -462,6 +462,7 @@ the way that the Fleet server works.
mdmStorage *mysql.NanoMDMStorage
mdmPushService push.Pusher
mdmCheckinAndCommandService *service.MDMAppleCheckinAndCommandService
ddmService *service.MDMAppleDDMService
mdmPushCertTopic string
)
@ -546,6 +547,7 @@ the way that the Fleet server works.
}
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
mdmCheckinAndCommandService = service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
ddmService = service.NewMDMAppleDDMService(ds, logger)
appCfg.MDM.EnabledAndConfigured = true
}
@ -882,6 +884,7 @@ the way that the Fleet server works.
scepStorage,
logger,
mdmCheckinAndCommandService,
ddmService,
); err != nil {
initFatal(err, "setup mdm apple services")
}

View file

@ -165,7 +165,7 @@ func TestApplyTeamSpecs(t *testing.T) {
return nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error {
return nil
}
@ -1110,7 +1110,7 @@ func TestApplyAsGitOps(t *testing.T) {
teamEnrollSecrets = secrets
return nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, hostUUIDs []string) error {

View file

@ -2207,7 +2207,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) {
}
return nil, fmt.Errorf("team not found: %s", name)
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error {
return nil
}
ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs, teamIDs []uint, profileUUIDs, uuids []string) error {

View file

@ -25,6 +25,7 @@ func TestBasicGlobalGitOps(t *testing.T) {
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
macDecls []*fleet.MDMAppleDeclaration,
) error {
return nil
}
@ -133,7 +134,7 @@ func TestBasicTeamGitOps(t *testing.T) {
ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil }
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
) error {
return nil
}
@ -246,7 +247,7 @@ func TestFullGlobalGitOps(t *testing.T) {
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
) error {
appliedMacProfiles = macProfiles
appliedWinProfiles = winProfiles
@ -408,7 +409,7 @@ func TestFullTeamGitOps(t *testing.T) {
var appliedMacProfiles []*fleet.MDMAppleConfigProfile
var appliedWinProfiles []*fleet.MDMWindowsConfigProfile
ds.BatchSetMDMProfilesFunc = func(
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile,
ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration,
) error {
appliedMacProfiles = macProfiles
appliedWinProfiles = winProfiles

View file

@ -1,12 +1,12 @@
{
"name": "fleetd-for-chrome",
"version": "1.2.0",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fleetd-for-chrome",
"version": "1.2.0",
"version": "1.2.1",
"dependencies": {
"dotenv": "^16.0.3",
"wa-sqlite": "github:rhashimoto/wa-sqlite#v0.9.11"

View file

@ -1,7 +1,7 @@
{
"name": "fleetd-for-chrome",
"description": "Extension for Fleetd on ChromeOS",
"version": "1.2.0",
"version": "1.2.1",
"dependencies": {
"dotenv": "^16.0.3",
"wa-sqlite": "github:rhashimoto/wa-sqlite#v0.9.11"

View file

@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?>
<gupdate xmlns='http://www.google.com/update2/response' protocol='2.0'>
<app appid='bfleegjcoffelppfmadimianphbcdjkb'>
<updatecheck codebase='https://chrome-beta.fleetdm.com/fleetd.crx' version='1.2.0' />
<updatecheck codebase='https://chrome-beta.fleetdm.com/fleetd.crx' version='1.2.1' />
</app>
</gupdate>
</gupdate>

View file

@ -10,7 +10,7 @@ const DEFAULT_HOST_PROFILE_MOCK: IHostMdmProfile = {
detail: "This is verified",
};
export const createMockHostMacMdmProfile = (
export const createMockHostMdmProfile = (
overrides?: Partial<IHostMdmProfile>
): IHostMdmProfile => {
return { ...DEFAULT_HOST_PROFILE_MOCK, ...overrides };

View file

@ -67,6 +67,9 @@ export enum ActivityType {
LockedHost = "locked_host",
UnlockedHost = "unlocked_host",
WipedHost = "wiped_host",
CreatedDeclarationProfile = "created_declaration_profile",
DeletedDeclarationProfile = "deleted_declaration_profile",
EditedDeclarationProfile = "edited_declaration_profile",
}
// This is a subset of ActivityType that are shown only for the host past activities

View file

@ -81,6 +81,11 @@ export interface IMdmProfile {
}
export type MdmProfileStatus = "verified" | "verifying" | "pending" | "failed";
export type MdmDDMProfileStatus =
| "success"
| "pending"
| "failed"
| "acknowledged";
export type ProfileOperationType = "remove" | "install";
@ -89,7 +94,7 @@ export interface IHostMdmProfile {
name: string;
operation_type: ProfileOperationType | null;
platform: ProfilePlatform;
status: MdmProfileStatus;
status: MdmProfileStatus | MdmDDMProfileStatus;
detail: string;
}

View file

@ -763,6 +763,55 @@ const TAGGED_TEMPLATES = {
</>
);
},
createdDeclarationProfile: (activity: IActivity, isPremiumTier: boolean) => {
return (
<>
{" "}
added declaration (DDM) profile <b>
{activity.details?.profile_name}
</b>{" "}
to{" "}
{getProfileMessageSuffix(
isPremiumTier,
"darwin",
activity.details?.team_name
)}
.
</>
);
},
deletedDeclarationProfile: (activity: IActivity, isPremiumTier: boolean) => {
return (
<>
{" "}
removed declaration (DDM) profile{" "}
<b>{activity.details?.profile_name}</b> from{" "}
{getProfileMessageSuffix(
isPremiumTier,
"darwin",
activity.details?.team_name
)}
.
</>
);
},
editedDeclarationProfile: (activity: IActivity, isPremiumTier: boolean) => {
return (
<>
{" "}
edited declaration (DDM) profile <b>
{activity.details?.profile_name}
</b>{" "}
for{" "}
{getProfileMessageSuffix(
isPremiumTier,
"darwin",
activity.details?.team_name
)}{" "}
via fleetctl.
</>
);
},
};
const getDetail = (
@ -918,6 +967,22 @@ const getDetail = (
case ActivityType.WipedHost: {
return TAGGED_TEMPLATES.wipedHost(activity);
}
case ActivityType.CreatedDeclarationProfile: {
return TAGGED_TEMPLATES.createdDeclarationProfile(
activity,
isPremiumTier
);
}
case ActivityType.DeletedDeclarationProfile: {
return TAGGED_TEMPLATES.deletedDeclarationProfile(
activity,
isPremiumTier
);
}
case ActivityType.EditedDeclarationProfile: {
return TAGGED_TEMPLATES.editedDeclarationProfile(activity, isPremiumTier);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}

View file

@ -14,7 +14,8 @@ const AGGREGATE_STATUS_DISPLAY_OPTIONS: IAggregateDisplayOption[] = [
text: "Verified",
iconName: "success",
tooltipText:
"These hosts applied all OS settings. Fleet verified with osquery.",
"These hosts applied all OS settings. Fleet verified with osquery. " +
"Declaration profiles are verified with DDM.",
},
{
value: "verifying",

View file

@ -186,7 +186,7 @@
flex-direction: column;
border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10;
overflow-y: scroll;
overflow-y: auto;
.loading-spinner {
margin: 69.5px auto;

View file

@ -4,7 +4,7 @@ import FileSaver from "file-saver";
import classnames from "classnames";
import { IMdmProfile } from "interfaces/mdm";
import mdmAPI from "services/entities/mdm";
import mdmAPI, { isDDMProfile } from "services/entities/mdm";
import Button from "components/buttons/Button";
import Graphic from "components/Graphic";
@ -29,11 +29,17 @@ const LabelCount = ({
interface IProfileDetailsProps {
platform: string;
createdAt: string;
isDDM?: boolean;
}
const ProfileDetails = ({ platform, createdAt }: IProfileDetailsProps) => {
const ProfileDetails = ({
platform,
createdAt,
isDDM,
}: IProfileDetailsProps) => {
const getPlatformName = () => {
return platform === "darwin" ? "macOS" : "Windows";
if (platform === "windows") return "Windows";
return isDDM ? "macOS (declaration)" : "macOS";
};
return (
@ -47,6 +53,21 @@ const ProfileDetails = ({ platform, createdAt }: IProfileDetailsProps) => {
);
};
const createProfileExtension = (profile: IMdmProfile) => {
if (isDDMProfile(profile)) {
return "json";
}
return profile.platform === "darwin" ? "mobileconfig" : "xml";
};
const createFileContent = async (profile: IMdmProfile) => {
const content = await mdmAPI.downloadProfile(profile.profile_uuid);
if (isDDMProfile(profile)) {
return JSON.stringify(content, null, 2);
}
return content;
};
interface IProfileListItemProps {
isPremium: boolean;
profile: IMdmProfile;
@ -62,13 +83,13 @@ const ProfileListItem = ({
onDelete,
setProfileLabelsModalData,
}: IProfileListItemProps) => {
const { created_at, labels, name, platform, profile_uuid } = profile;
const { created_at, labels, name, platform } = profile;
const subClass = "list-item";
const onClickDownload = async () => {
const fileContent = await mdmAPI.downloadProfile(profile_uuid);
const fileContent = await createFileContent(profile);
const formatDate = format(new Date(), "yyyy-MM-dd");
const extension = platform === "darwin" ? "mobileconfig" : "xml";
const extension = createProfileExtension(profile);
const filename = `${formatDate}_${name}.${extension}`;
const file = new File([fileContent], filename);
FileSaver.saveAs(file);
@ -81,7 +102,11 @@ const ProfileListItem = ({
<div className={`${subClass}__info`}>
<span className={`${subClass}__title`}>{name}</span>
<div className={`${subClass}__details`}>
<ProfileDetails platform={platform} createdAt={created_at} />
<ProfileDetails
platform={platform}
createdAt={created_at}
isDDM={isDDMProfile(profile)}
/>
</div>
</div>
</div>

View file

@ -3,7 +3,7 @@ import React from "react";
import Graphic from "components/Graphic";
const ALLOWED_FILE_TYPES_MESSAGE =
"Configuration profile (.mobileconfig for macOS or .xml for Windows)";
"Configuration profile (.mobileconfig and .json for macOS or .xml for Windows)";
const ProfileGraphic = ({
baseClass,

View file

@ -52,7 +52,7 @@ const FileChooser = ({
</label>
</Button>
<input
accept=".mobileconfig,application/x-apple-aspen-config,.xml"
accept=".json,.mobileconfig,application/x-apple-aspen-config,.xml"
id="upload-profile"
type="file"
onChange={(e) => {

View file

@ -78,6 +78,9 @@ export const parseFile = async (file: File): Promise<[string, string]> => {
// }
return [name, "macOS"];
}
case "json": {
return [name, "macOS"];
}
default: {
throw new Error(`Invalid file type: ${ext}`);
}

View file

@ -52,6 +52,7 @@ import {
HOST_ABOUT_DATA,
HOST_OSQUERY_DATA,
} from "utilities/constants";
import { createMockHostMdmProfile } from "__mocks__/hostMock";
import Spinner from "components/Spinner";
import TabsWrapper from "components/TabsWrapper";

View file

@ -22,28 +22,35 @@ type OperationTypeOption = Record<
type ProfileDisplayConfig = Record<ProfileOperationType, OperationTypeOption>;
const MAC_PROFILE_VERIFIED_DISPLAY_CONFIG: ProfileDisplayOption = {
statusText: "Verified",
iconName: "success",
tooltip: (innerProps) =>
innerProps.isDiskEncryptionProfile
? "The host turned disk encryption on and sent the key to Fleet. " +
"Fleet verified with osquery."
: "The host applied the setting. Fleet verified with osquery. " +
"Declaration profiles are verified with DDM.",
} as const;
const MAC_PROFILE_VERIFYING_DISPLAY_CONFIG: ProfileDisplayOption = {
statusText: "Verifying",
iconName: "success-outline",
tooltip: (innerProps) =>
innerProps.isDiskEncryptionProfile
? "The host acknowledged the MDM command to turn on disk encryption. " +
"Fleet is verifying with osquery and retrieving the disk encryption key. " +
"This may take up to one hour."
: "The host acknowledged the MDM command to apply the setting. Fleet is " +
"verifying with osquery.",
} as const;
export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = {
install: {
verified: {
statusText: "Verified",
iconName: "success",
tooltip: (innerProps) =>
innerProps.isDiskEncryptionProfile
? "The host turned disk encryption on and sent the key to Fleet. " +
"Fleet verified with osquery."
: "The host applied the setting. Fleet verified with osquery.",
},
verifying: {
statusText: "Verifying",
iconName: "success-outline",
tooltip: (innerProps) =>
innerProps.isDiskEncryptionProfile
? "The host acknowledged the MDM command to turn on disk encryption. " +
"Fleet is verifying with osquery and retrieving the disk encryption key. " +
"This may take up to one hour."
: "The host acknowledged the MDM command to apply the setting. Fleet is " +
"verifying with osquery.",
},
verified: MAC_PROFILE_VERIFIED_DISPLAY_CONFIG,
success: MAC_PROFILE_VERIFIED_DISPLAY_CONFIG,
verifying: MAC_PROFILE_VERIFYING_DISPLAY_CONFIG,
acknowledged: MAC_PROFILE_VERIFYING_DISPLAY_CONFIG,
pending: {
statusText: "Enforcing (pending)",
iconName: "pending-outline",
@ -79,6 +86,8 @@ export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = {
action_required: null, // should not be reached
verified: null, // should not be reached
verifying: null, // should not be reached
success: null, // should not be reached
acknowledged: null, // should not be reached
failed: {
statusText: "Failed",
iconName: "error",
@ -89,7 +98,8 @@ export const PROFILE_DISPLAY_CONFIG: ProfileDisplayConfig = {
type WindowsDiskEncryptionDisplayConfig = Omit<
OperationTypeOption,
"action_required"
// windows disk encryption does not have these states
"action_required" | "success" | "acknowledged"
>;
export const WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG: WindowsDiskEncryptionDisplayConfig = {

View file

@ -1,12 +1,14 @@
import React from "react";
import TableContainer from "components/TableContainer";
import tableHeaders, { ITableRowOsSettings } from "./OSSettingsTableConfig";
import tableHeaders, {
IHostMdmProfileWithAddedStatus,
} from "./OSSettingsTableConfig";
const baseClass = "os-settings-table";
interface IOSSettingsTableProps {
tableData?: ITableRowOsSettings[];
tableData?: IHostMdmProfileWithAddedStatus[];
}
const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => {

View file

@ -1,58 +1,44 @@
import TextCell from "components/TableContainer/DataTable/TextCell";
import React from "react";
import { Column } from "react-table";
import { IStringCellProps } from "interfaces/datatable_config";
import { IHostMdmData } from "interfaces/host";
import {
FLEET_FILEVAULT_PROFILE_DISPLAY_NAME,
// FLEET_FILEVAULT_PROFILE_IDENTIFIER,
IHostMdmProfile,
MdmDDMProfileStatus,
MdmProfileStatus,
ProfilePlatform,
isWindowsDiskEncryptionStatus,
} from "interfaces/mdm";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import TextCell from "components/TableContainer/DataTable/TextCell";
import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell";
import OSSettingStatusCell from "./OSSettingStatusCell";
import { generateWinDiskEncryptionProfile } from "../../helpers";
export interface ITableRowOsSettings extends Omit<IHostMdmProfile, "status"> {
status: OsSettingsTableStatusValue;
}
export type OsSettingsTableStatusValue = MdmProfileStatus | "action_required";
export const isMdmProfileStatus = (
status: string
): status is MdmProfileStatus => {
return status !== "action_required";
};
interface IHeaderProps {
column: {
title: string;
isSortedDesc: boolean;
};
export interface IHostMdmProfileWithAddedStatus
extends Omit<IHostMdmProfile, "status"> {
status: OsSettingsTableStatusValue;
}
interface ICellProps {
cell: {
value: string;
};
row: {
original: ITableRowOsSettings;
};
}
type ITableColumnConfig = Column<IHostMdmProfileWithAddedStatus>;
type ITableStringCellProps = IStringCellProps<IHostMdmProfileWithAddedStatus>;
interface IDataColumn {
Header: ((props: IHeaderProps) => JSX.Element) | string;
Cell: (props: ICellProps) => JSX.Element;
id?: string;
title?: string;
accessor?: string;
disableHidden?: boolean;
disableSortBy?: boolean;
sortType?: string;
}
export type INonDDMProfileStatus = MdmProfileStatus | "action_required";
export type OsSettingsTableStatusValue =
| MdmDDMProfileStatus
| INonDDMProfileStatus;
/**
* generates the formatted tooltip for the error column.
@ -107,22 +93,20 @@ const generateErrorTooltip = (
return generateFormattedTooltip(detail);
};
const tableHeaders: IDataColumn[] = [
const tableHeaders: ITableColumnConfig[] = [
{
title: "Name",
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: (cellProps: ICellProps): JSX.Element => (
<TextCell value={cellProps.cell.value} />
),
Cell: (cellProps: ITableStringCellProps) => {
return <TextCell value={cellProps.cell.value} />;
},
},
{
title: "Status",
Header: "Status",
disableSortBy: true,
accessor: "statusText",
Cell: (cellProps: ICellProps) => {
accessor: "status",
Cell: (cellProps: ITableStringCellProps) => {
return (
<OSSettingStatusCell
status={cellProps.row.original.status}
@ -133,11 +117,10 @@ const tableHeaders: IDataColumn[] = [
},
},
{
title: "Error",
Header: "Error",
disableSortBy: true,
accessor: "detail",
Cell: (cellProps: ICellProps): JSX.Element => {
Cell: (cellProps: ITableStringCellProps): JSX.Element => {
const profile = cellProps.row.original;
const value =
@ -165,7 +148,7 @@ const tableHeaders: IDataColumn[] = [
];
const makeWindowsRows = ({ profiles, os_settings }: IHostMdmData) => {
const rows: ITableRowOsSettings[] = [];
const rows: IHostMdmProfileWithAddedStatus[] = [];
if (profiles) {
rows.push(...profiles);
@ -190,15 +173,12 @@ const makeWindowsRows = ({ profiles, os_settings }: IHostMdmData) => {
return rows;
};
const makeDarwinRows = ({
profiles,
macos_settings,
}: IHostMdmData): ITableRowOsSettings[] | null => {
const makeDarwinRows = ({ profiles, macos_settings }: IHostMdmData) => {
if (!profiles) {
return null;
}
let rows: ITableRowOsSettings[] = profiles;
let rows: IHostMdmProfileWithAddedStatus[] = profiles;
if (macos_settings?.disk_encryption === "action_required") {
rows = profiles.map((p) => {
// TODO: this is a brittle check for the filevault profile

View file

@ -32,7 +32,8 @@ const STATUS_DISPLAY_OPTIONS: StatusDisplayOptions = {
Verified: {
iconName: "success",
tooltipText:
"The host applied all OS settings. Fleet verified with osquery.",
"The host applied all OS settings. Fleet verified with osquery. " +
"Declaration profiles are verified with DDM.",
},
Verifying: {
iconName: "success-outline",

View file

@ -8,6 +8,7 @@ import {
generateCSVFilename,
generateCSVQueryResults,
} from "utilities/generate_csv";
import { getTableColumnsFromSql } from "utilities/helpers";
import { IQueryReport, IQueryReportResultRow } from "interfaces/query_report";
import Button from "components/buttons/Button";
@ -52,9 +53,13 @@ const QueryReport = ({
useEffect(() => {
if (queryReport && queryReport.results && queryReport.results.length > 0) {
const tableColumns = getTableColumnsFromSql(lastEditedQueryBody);
const newColumnConfigs = generateReportColumnConfigsFromResults(
flattenResults(queryReport.results)
flattenResults(queryReport.results),
tableColumns
);
// Update tableHeaders if new headers are found
if (newColumnConfigs !== columnConfigs) {
setColumnConfigs(newColumnConfigs);

View file

@ -9,10 +9,12 @@ import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColu
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import {
getSortTypeFromColumnType,
getUniqueColumnNamesFromRows,
humanHostLastSeen,
internallyTruncateText,
} from "utilities/helpers";
import { IQueryTableColumn } from "interfaces/osquery_table";
import { IHeaderProps, IWebSocketData } from "interfaces/datatable_config";
type IQueryReportTableColumnConfig = Column<IWebSocketData>;
@ -40,7 +42,8 @@ const _unshiftHostname = (headers: IQueryReportTableColumnConfig[]) => {
};
const generateReportColumnConfigsFromResults = (
results: IWebSocketData[]
results: IWebSocketData[],
tableColumns?: IQueryTableColumn[] | []
): IQueryReportTableColumnConfig[] => {
/* Results include an array of objects, each representing a table row
Each key value pair in an object represents a column name and value
@ -79,7 +82,7 @@ const generateReportColumnConfigsFromResults = (
Filter: DefaultColumnFilter, // Component hides filter for last_fetched
filterType: "text",
disableSortBy: false,
sortType: "caseInsensitive",
sortType: getSortTypeFromColumnType(key, tableColumns),
};
}
);

View file

@ -5,16 +5,14 @@ import classnames from "classnames";
import FileSaver from "file-saver";
import { QueryContext } from "context/query";
import { useDebouncedCallback } from "use-debounce";
import { find } from "lodash";
import {
generateCSVFilename,
generateCSVQueryResults,
} from "utilities/generate_csv";
import { osqueryTables } from "utilities/osquery_tables";
import { getTableColumnsFromSql } from "utilities/helpers";
import { ICampaign, ICampaignError } from "interfaces/campaign";
import { ITarget } from "interfaces/target";
import { IQueryTableColumn } from "interfaces/osquery_table";
import Button from "components/buttons/Button";
import Icon from "components/Icon/Icon";
@ -25,7 +23,6 @@ import QueryResultsHeading from "components/queries/queryResults/QueryResultsHea
import AwaitingResults from "components/queries/queryResults/AwaitingResults";
import InfoBanner from "components/InfoBanner";
import CustomLink from "components/CustomLink";
import { checkTable } from "utilities/sql_tools";
import generateColumnConfigsFromRows from "./QueryResultsTableConfig";
@ -77,9 +74,6 @@ const QueryResults = ({
const [queryResultsForTableRender, setQueryResultsForTableRender] = useState(
queryResults
);
const [osqueryTableColumns, setOsqueryTableColumns] = useState<
IQueryTableColumn[] | []
>([]);
// immediately reset results
const onRunAgain = useCallback(() => {
@ -98,32 +92,20 @@ const QueryResults = ({
debounceQueryResults(queryResults);
}, [queryResults, debounceQueryResults]);
// Set table/s columns from SQL
useEffect(() => {
const tableNames =
(lastEditedQueryBody && checkTable(lastEditedQueryBody).tables) || [];
let columns: IQueryTableColumn[] | [] = [];
tableNames.forEach((tableName: string) => {
const tableColumns =
find(osqueryTables, { name: tableName })?.columns || [];
columns = [...columns, ...tableColumns];
});
setOsqueryTableColumns(columns);
}, [lastEditedQueryBody]);
useEffect(() => {
if (queryResults && queryResults.length > 0) {
const tableColumns = getTableColumnsFromSql(lastEditedQueryBody);
const newResultsColumnConfigs = generateColumnConfigsFromRows(
queryResults,
osqueryTableColumns
tableColumns
);
// Update tableHeaders if new headers are found
if (newResultsColumnConfigs !== resultsColumnConfigs) {
setResultsColumnConfigs(newResultsColumnConfigs);
}
}
}, [queryResults]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders
}, [queryResults, lastEditedQueryBody]); // Cannot use tableHeaders as it will cause infinite loop with setTableHeaders
useEffect(() => {
if (errorColumnConfigs?.length === 0 && !!errors?.length) {

View file

@ -4,11 +4,11 @@
import React from "react";
import { CellProps, Column, HeaderProps } from "react-table";
import { find } from "lodash";
import DefaultColumnFilter from "components/TableContainer/DataTable/DefaultColumnFilter";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import {
getSortTypeFromColumnType,
getUniqueColumnNamesFromRows,
internallyTruncateText,
} from "utilities/helpers";
@ -34,28 +34,11 @@ const _unshiftHostname = <T extends object>(columns: Column<T>[]) => {
return newHeaders;
};
// Sorts numerical columns correctly while perserving case insensitive sort for text columns
const sortType = (
colName: string | number | symbol,
osqueryTableColumns?: IQueryTableColumn[] | []
) => {
if (typeof colName === "string" && !!osqueryTableColumns) {
const numberTypes = ["integer", "bigint", "unsigned_bigint", "double"];
const type = find(osqueryTableColumns, { name: colName })?.type;
if (type && numberTypes.includes(type)) {
return "alphanumeric";
}
}
return "caseInsensitive";
};
const generateColumnConfigsFromRows = <T extends Record<keyof T, unknown>>(
// TODO - narrow typing down this entire chain of logic
// typed as any[] to accomodate loose typing of websocket API
results: T[], // {col:val, ...} for each row of query results
osqueryTableColumns?: IQueryTableColumn[] | []
tableColumns?: IQueryTableColumn[] | []
): Column<T>[] => {
const uniqueColumnNames = getUniqueColumnNamesFromRows(results);
const columnsConfigs = uniqueColumnNames.map<Column<T>>((colName) => {
@ -76,7 +59,7 @@ const generateColumnConfigsFromRows = <T extends Record<keyof T, unknown>>(
},
Filter: DefaultColumnFilter,
disableSortBy: false,
sortType: sortType(colName, osqueryTableColumns),
sortType: getSortTypeFromColumnType(colName, tableColumns),
};
});
return _unshiftHostname(columnsConfigs);

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import {
DiskEncryptionStatus,
IHostMdmProfile,
IMdmProfile,
MdmProfileStatus,
} from "interfaces/mdm";
@ -47,6 +48,10 @@ export interface IUploadProfileApiParams {
labels?: string[];
}
export const isDDMProfile = (profile: IMdmProfile | IHostMdmProfile) => {
return profile.profile_uuid.startsWith("d");
};
interface IUpdateSetupExperienceBody {
team_id?: number;
enable_release_device_manually: boolean;

View file

@ -2,6 +2,7 @@ import React from "react";
import {
isEmpty,
flatMap,
find,
omit,
pick,
size,
@ -25,6 +26,7 @@ import { buildQueryStringFromParams } from "utilities/url";
import { IHost } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IPack } from "interfaces/pack";
import { IQueryTableColumn } from "interfaces/osquery_table";
import {
IScheduledQuery,
IPackQueryFormData,
@ -39,6 +41,8 @@ import { UserRole } from "interfaces/user";
import stringUtils from "utilities/strings";
import sortUtils from "utilities/sort";
import { checkTable } from "utilities/sql_tools";
import { osqueryTables } from "utilities/osquery_tables";
import {
DEFAULT_EMPTY_CELL_VALUE,
DEFAULT_GRAVATAR_LINK,
@ -887,6 +891,40 @@ export const getUniqueColumnNamesFromRows = <
// can allow additional dropdown value types in the future
type DropdownOptionValue = IDropdownOption["value"];
/** Generates the column schema for a sql query */
export const getTableColumnsFromSql = (
sql: string
): IQueryTableColumn[] | [] => {
const tableNames = (sql && checkTable(sql).tables) || [];
let sqlColumns: IQueryTableColumn[] | [] = [];
tableNames.forEach((tableName: string) => {
const tableColumns =
find(osqueryTables, { name: tableName })?.columns || [];
sqlColumns = [...sqlColumns, ...tableColumns];
});
// TODO: Edge case of tables sharing column names with different typing not considered
return sqlColumns;
};
/** Sorts sql results numerical columns correctly while perserving case insensitive sort for text columns */
export const getSortTypeFromColumnType = (
colName: string | number | symbol,
tableColumns?: IQueryTableColumn[] | []
) => {
if (typeof colName === "string") {
const numberTypes = ["integer", "bigint", "unsigned_bigint", "double"];
const type = find(tableColumns, { name: colName })?.type;
if (type && numberTypes.includes(type)) {
return "alphanumeric";
}
}
return "caseInsensitive";
};
export function getCustomDropdownOptions(
defaultOptions: IDropdownOption[],
customValue: DropdownOptionValue,
@ -918,6 +956,8 @@ export default {
generateRole,
generateTeam,
getUniqueColumnNamesFromRows,
getTableColumnsFromSql,
getSortTypeFromColumnType,
getCustomDropdownOptions,
greyCell,
humanHostLastSeen,

View file

@ -56,7 +56,7 @@ variable "database_name" {
variable "fleet_image" {
description = "the name of the container image to run"
default = "fleetdm/fleet:v4.47.2"
default = "fleetdm/fleet:v4.47.3"
}
variable "software_inventory" {

View file

@ -68,5 +68,5 @@ variable "redis_mem" {
}
variable "image" {
default = "fleet:v4.47.2"
default = "fleet:v4.47.3"
}

View file

@ -222,7 +222,7 @@ func checkWine(wineChecked bool) error {
cmd := exec.Command(wix.WineCmd, "--version")
if err := cmd.Run(); err != nil {
return fmt.Errorf(
"%s failed. Is Wine installed? Creating a fleetd agent for Windows (.msi) requires Wine. To install Wine see the script here: https://github.com/fleetdm/fleet/blob/fleet-v4.44.0/scripts/macos-install-wine.sh %w",
"%s failed. Is Wine installed? Creating a fleetd agent for Windows (.msi) requires Wine. To install Wine see the script here: https://fleetdm.com/install-wine %w",
wix.WineCmd, err,
)
}

View file

@ -9,6 +9,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@ -21,6 +22,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/scep/cryptoutil/x509util"
@ -31,7 +33,6 @@ import (
httptransport "github.com/go-kit/kit/transport/http"
"github.com/google/uuid"
"github.com/groob/plist"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"go.mozilla.org/pkcs7"
)
@ -386,6 +387,30 @@ func (c *TestAppleMDMClient) TokenUpdate() error {
return err
}
// DeclarativeManagement sends a DeclarativeManagement checkin request to the server.
//
// The endpoint argument is used as the value for the `Endpoint` key in the request payload.
//
// For more details check https://developer.apple.com/documentation/devicemanagement/declarativemanagementrequest
func (c *TestAppleMDMClient) DeclarativeManagement(endpoint string, data ...fleet.MDMAppleDDMStatusReport) (*http.Response, error) {
payload := map[string]any{
"MessageType": "DeclarativeManagement",
"UDID": c.UUID,
"Topic": "com.apple.mgmt.External." + c.UUID,
"EnrollmentID": "testenrollmentid-" + c.UUID,
"Endpoint": endpoint,
}
if len(data) != 0 {
rawData, err := json.Marshal(data[0])
if err != nil {
return nil, fmt.Errorf("marshaling status report: %w", err)
}
payload["Data"] = rawData
}
r, err := c.request("application/x-apple-aspen-mdm-checkin", payload)
return r, err
}
// Checkout sends the CheckOut message to the MDM server.
func (c *TestAppleMDMClient) Checkout() error {
payload := map[string]any{
@ -404,7 +429,7 @@ func (c *TestAppleMDMClient) Checkout() error {
// receive commands. The server can signal back with either a command to run
// or an empty (nil, nil) response body to end the communication
// (i.e. no commands to run).
func (c *TestAppleMDMClient) Idle() (*micromdm.CommandPayload, error) {
func (c *TestAppleMDMClient) Idle() (*mdm.Command, error) {
payload := map[string]any{
"Status": "Idle",
"Topic": "com.apple.mgmt.External." + c.UUID,
@ -420,7 +445,7 @@ func (c *TestAppleMDMClient) Idle() (*micromdm.CommandPayload, error) {
// The server can signal back with either a command to run
// or an empty (nil, nil) response body to end the communication
// (i.e. no commands to run).
func (c *TestAppleMDMClient) Acknowledge(cmdUUID string) (*micromdm.CommandPayload, error) {
func (c *TestAppleMDMClient) Acknowledge(cmdUUID string) (*mdm.Command, error) {
payload := map[string]any{
"Status": "Acknowledged",
"Topic": "com.apple.mgmt.External." + c.UUID,
@ -473,7 +498,7 @@ func (c *TestAppleMDMClient) GetBootstrapToken() ([]byte, error) {
// The server can signal back with either a command to run
// or an empty (nil, nil) response body to end the communication
// (i.e. no commands to run).
func (c *TestAppleMDMClient) Err(cmdUUID string, errChain []mdm.ErrorChain) (*micromdm.CommandPayload, error) {
func (c *TestAppleMDMClient) Err(cmdUUID string, errChain []mdm.ErrorChain) (*mdm.Command, error) {
payload := map[string]any{
"Status": "Error",
"Topic": "com.apple.mgmt.External." + c.UUID,
@ -485,7 +510,7 @@ func (c *TestAppleMDMClient) Err(cmdUUID string, errChain []mdm.ErrorChain) (*mi
return c.sendAndDecodeCommandResponse(payload)
}
func (c *TestAppleMDMClient) sendAndDecodeCommandResponse(payload map[string]any) (*micromdm.CommandPayload, error) {
func (c *TestAppleMDMClient) sendAndDecodeCommandResponse(payload map[string]any) (*mdm.Command, error) {
res, err := c.request("", payload)
if err != nil {
return nil, fmt.Errorf("request error: %w", err)
@ -510,11 +535,12 @@ func (c *TestAppleMDMClient) sendAndDecodeCommandResponse(payload map[string]any
if err != nil {
return nil, fmt.Errorf("decode command: %w", err)
}
var p micromdm.CommandPayload
var p mdm.Command
err = plist.Unmarshal(cmd.Raw, &p)
if err != nil {
return nil, fmt.Errorf("unmarshal command payload: %w", err)
}
p.Raw = cmd.Raw
return &p, nil
}

View file

@ -2,8 +2,9 @@
set -eo pipefail
# Run this script in user context (not root).
# Reference: https://wiki.winehq.org/MacOS
# NOTE: Wine can be installed without brew via a distribution such as https://github.com/Gcenx/macOS_Wine_builds/releases/tag/9.0, or by building from source.
# Wine can be installed without brew via a distribution such as https://github.com/Gcenx/macOS_Wine_builds/releases/tag/9.0, or by building from source.
# Check if brew is installed
if ! command -v brew >/dev/null 2>&1 ; then

File diff suppressed because it is too large Load diff

View file

@ -992,6 +992,80 @@ func expectAppleProfiles(
return m
}
func expectAppleDeclarations(
t *testing.T,
ds *Datastore,
tmID *uint,
want []*fleet.MDMAppleDeclaration,
) map[string]string {
if tmID == nil {
tmID = ptr.Uint(0)
}
var got []*fleet.MDMAppleDeclaration
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
ctx := context.Background()
return sqlx.SelectContext(ctx, q, &got, `SELECT * FROM mdm_apple_declarations WHERE team_id = ?`, tmID)
})
// create map of expected declarations keyed by identifier
wantMap := make(map[string]*fleet.MDMAppleDeclaration, len(want))
for _, cp := range want {
wantMap[cp.Identifier] = cp
}
JSONRemarshal := func(bytes []byte) ([]byte, error) {
var ifce interface{}
err := json.Unmarshal(bytes, &ifce)
if err != nil {
return nil, err
}
return json.Marshal(ifce)
}
// compare only the fields we care about, and build the resulting map of
// declaration identifier as key to declaration UUID as value
m := make(map[string]string)
for _, gotD := range got {
wantD := wantMap[gotD.Identifier]
m[gotD.Identifier] = gotD.DeclarationUUID
if gotD.TeamID != nil && *gotD.TeamID == 0 {
gotD.TeamID = nil
}
// DeclarationUUID is non-empty and starts with "d", but otherwise we don't
// care about it for test assertions.
require.NotEmpty(t, gotD.DeclarationUUID)
require.True(t, strings.HasPrefix(gotD.DeclarationUUID, fleet.MDMAppleDeclarationUUIDPrefix))
gotD.DeclarationUUID = ""
gotD.Checksum = "" // don't care about md5checksum here
gotD.CreatedAt = time.Time{}
gotBytes, err := JSONRemarshal(gotD.RawJSON)
require.NoError(t, err)
wantBytes, err := JSONRemarshal(wantD.RawJSON)
require.NoError(t, err)
require.Equal(t, wantBytes, gotBytes)
// if an expected uploaded_at timestamp is provided for this declaration, keep
// its value, otherwise clear it as we don't care about asserting its
// value.
if wantD == nil || wantD.UploadedAt.IsZero() {
gotD.UploadedAt = time.Time{}
}
require.Equal(t, wantD.Name, gotD.Name)
require.Equal(t, wantD.Identifier, gotD.Identifier)
require.Equal(t, wantD.Labels, gotD.Labels)
}
return m
}
func testBatchSetMDMAppleProfiles(t *testing.T, ds *Datastore) {
ctx := context.Background()
applyAndExpect := func(newSet []*fleet.MDMAppleConfigProfile, tmID *uint, want []*fleet.MDMAppleConfigProfile) map[string]string {
@ -1170,6 +1244,30 @@ func configProfileForTest(t *testing.T, name, identifier, uuid string, labels ..
return cp
}
func declForTest(name, identifier, payloadContent string, labels ...*fleet.Label) *fleet.MDMAppleDeclaration {
tmpl := `{
"Type": "com.apple.configuration.decl%s",
"Identifier": "com.fleet.config%s",
"Payload": {
"ServiceType": "com.apple.service%s"
}
}`
declBytes := []byte(fmt.Sprintf(tmpl, identifier, identifier, payloadContent))
decl := &fleet.MDMAppleDeclaration{
RawJSON: declBytes,
Identifier: fmt.Sprintf("com.fleet.config%s", identifier),
Name: name,
}
for _, l := range labels {
decl.Labels = append(decl.Labels, fleet.ConfigurationProfileLabel{LabelName: l.Name, LabelID: l.ID})
}
return decl
}
func teamConfigProfileForTest(t *testing.T, name, identifier, uuid string, teamID uint) *fleet.MDMAppleConfigProfile {
prof := configProfileBytesForTest(name, identifier, uuid)
cp, err := fleet.NewMDMAppleConfigProfile(configProfileBytesForTest(name, identifier, uuid), &teamID)
@ -1689,13 +1787,19 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore)
expectedIDs = append(expectedIDs, h.ID)
}
gotHosts, err := ds.ListHosts(ctx, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String("admin")}}, fleet.HostListOptions{MacOSSettingsFilter: status, TeamFilter: teamID})
gotHosts, err := ds.ListHosts(
ctx,
fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String("admin")}},
fleet.HostListOptions{MacOSSettingsFilter: status, TeamFilter: teamID},
)
gotIDs := []uint{}
for _, h := range gotHosts {
gotIDs = append(gotIDs, h.ID)
}
return assert.NoError(t, err) && assert.Len(t, gotHosts, len(expected)) && assert.ElementsMatch(t, expectedIDs, gotIDs)
return assert.NoError(t, err) &&
assert.Len(t, gotHosts, len(expected)) &&
assert.ElementsMatch(t, expectedIDs, gotIDs)
}
var hosts []*fleet.Host
@ -2517,7 +2621,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, nil)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(2), allProfilesSummary.Pending)
require.Equal(t, uint(0), allProfilesSummary.Failed)
require.Equal(t, uint(1), allProfilesSummary.Verifying)
@ -2543,7 +2647,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, nil)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(2), allProfilesSummary.Pending)
require.Equal(t, uint(0), allProfilesSummary.Failed)
require.Equal(t, uint(1), allProfilesSummary.Verifying)
@ -2571,7 +2675,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, nil)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(2), allProfilesSummary.Pending)
require.Equal(t, uint(0), allProfilesSummary.Failed)
require.Equal(t, uint(1), allProfilesSummary.Verifying)
@ -2593,7 +2697,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, nil)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(2), allProfilesSummary.Pending)
require.Equal(t, uint(1), allProfilesSummary.Failed)
require.Equal(t, uint(1), allProfilesSummary.Verifying)
@ -2615,7 +2719,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, nil)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(3), allProfilesSummary.Pending)
require.Equal(t, uint(1), allProfilesSummary.Failed)
require.Equal(t, uint(1), allProfilesSummary.Verifying)
@ -2645,7 +2749,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, &tm.ID)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(0), allProfilesSummary.Pending)
require.Equal(t, uint(0), allProfilesSummary.Failed)
require.Equal(t, uint(1), allProfilesSummary.Verifying)
@ -2671,7 +2775,7 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
allProfilesSummary, err = ds.GetMDMAppleProfilesSummary(ctx, &tm.ID)
require.NoError(t, err)
require.NotNil(t, fvProfileSummary)
require.NotNil(t, allProfilesSummary)
require.Equal(t, uint(0), allProfilesSummary.Pending)
require.Equal(t, uint(0), allProfilesSummary.Failed)
require.Equal(t, uint(0), allProfilesSummary.Verifying)

View file

@ -1211,34 +1211,22 @@ func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, par
return sql, params, nil
}
newSQL := ""
whereStatus := ""
// macOS settings filter is not compatible with the "all teams" option so append the "no
// team" filter here (note that filterHostsByTeam applies the "no team" filter if TeamFilter == 0)
if opt.TeamFilter == nil {
// macOS settings filter is not compatible with the "all teams" option so append the "no
// team" filter here (note that filterHostsByTeam applies the "no team" filter if TeamFilter == 0)
newSQL += ` AND h.team_id IS NULL`
whereStatus += ` AND h.team_id IS NULL`
}
var subquery string
var subqueryParams []any
var err error
switch opt.MacOSSettingsFilter {
case fleet.OSSettingsFailed:
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryFailed)
case fleet.OSSettingsPending:
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryPending)
case fleet.OSSettingsVerifying:
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying)
case fleet.OSSettingsVerified:
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerified)
}
subqueryStatus, paramsStatus, err := subqueryOSSettingsStatusMac()
if err != nil {
return "", nil, fmt.Errorf("building subquery for %s filter: %w", opt.MacOSSettingsFilter, err)
}
if subquery != "" {
newSQL += fmt.Sprintf(` AND EXISTS (%s)`, subquery)
return "", nil, err
}
return sql + newSQL, append(params, subqueryParams...), nil
whereStatus += fmt.Sprintf(` AND %s = ?`, subqueryStatus)
paramsStatus = append(paramsStatus, opt.MacOSSettingsFilter)
return sql + whereStatus, append(params, paramsStatus...), nil
}
func filterHostsByMacOSDiskEncryptionStatus(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) {
@ -1286,30 +1274,16 @@ func (ds *Datastore) filterHostsByOSSettingsStatus(sql string, opt fleet.HostLis
sqlFmt += ` AND h.team_id IS NULL`
}
var whereMacOS, whereWindows string
sqlFmt += ` AND ((h.platform = 'windows' AND (%s)) OR (h.platform = 'darwin' AND (%s)))`
sqlFmt += `
AND ((h.platform = 'windows' AND (%s))
OR (h.platform = 'darwin' AND (%s)))`
// construct the WHERE for macOS
var subqueryMacOS string
var paramsMacOS []interface{}
var err error
switch opt.OSSettingsFilter {
case fleet.OSSettingsFailed:
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryFailed)
case fleet.OSSettingsPending:
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryPending)
case fleet.OSSettingsVerifying:
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying)
case fleet.OSSettingsVerified:
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerified)
}
whereMacOS, paramsMacOS, err := subqueryOSSettingsStatusMac()
if err != nil {
return "", nil, fmt.Errorf("building subquery for %s filter: %w", opt.OSSettingsFilter, err)
}
if subqueryMacOS != "" {
whereMacOS = "EXISTS (" + subqueryMacOS + ")"
} else {
whereMacOS = "FALSE"
return "", nil, err
}
whereMacOS += ` = ?`
paramsMacOS = append(paramsMacOS, opt.OSSettingsFilter)
// construct the WHERE for windows
whereWindows = `hmdm.name = ? AND hmdm.enrolled = 1 AND hmdm.is_server = 0`

View file

@ -364,6 +364,7 @@ func testLabelsListHostsInLabelAndStatus(t *testing.T, db *Datastore) {
NodeKey: ptr.String("1"),
UUID: "1",
Hostname: "foo.local",
Platform: "darwin",
})
require.NoError(t, err)
@ -377,6 +378,7 @@ func testLabelsListHostsInLabelAndStatus(t *testing.T, db *Datastore) {
NodeKey: ptr.String("2"),
UUID: "2",
Hostname: "bar.local",
Platform: "darwin",
})
require.NoError(t, err)
h3, err := db.NewHost(context.Background(), &fleet.Host{
@ -388,6 +390,7 @@ func testLabelsListHostsInLabelAndStatus(t *testing.T, db *Datastore) {
NodeKey: ptr.String("3"),
UUID: "3",
Hostname: "baz.local",
Platform: "darwin",
})
require.NoError(t, err)
@ -427,6 +430,7 @@ func testLabelsListHostsInLabelAndTeamFilter(deferred bool, t *testing.T, db *Da
NodeKey: ptr.String("1"),
UUID: "1",
Hostname: "foo.local",
Platform: "darwin",
})
require.Nil(t, err)
@ -440,6 +444,7 @@ func testLabelsListHostsInLabelAndTeamFilter(deferred bool, t *testing.T, db *Da
NodeKey: ptr.String("2"),
UUID: "2",
Hostname: "bar.local",
Platform: "darwin",
})
require.Nil(t, err)

View file

@ -82,7 +82,6 @@ func (ds *Datastore) ListMDMCommands(
tmFilter fleet.TeamFilter,
listOpts *fleet.MDMCommandListOptions,
) ([]*fleet.MDMCommand, error) {
jointStmt := getCombinedMDMCommandsQuery() + ds.whereFilterHostsByTeams(tmFilter, "h")
jointStmt, params := appendListOptionsWithCursorToSQL(jointStmt, nil, &listOpts.ListOptions)
var results []*fleet.MDMCommand
@ -102,7 +101,7 @@ func (ds *Datastore) getMDMCommand(ctx context.Context, q sqlx.QueryerContext, c
return &cmd, nil
}
func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error {
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
if err := ds.batchSetMDMWindowsProfilesDB(ctx, tx, tmID, winProfiles); err != nil {
return ctxerr.Wrap(ctx, err, "batch set windows profiles")
@ -112,6 +111,10 @@ func (ds *Datastore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macPro
return ctxerr.Wrap(ctx, err, "batch set apple profiles")
}
if _, err := ds.batchSetMDMAppleDeclarations(ctx, tx, tmID, macDeclarations); err != nil {
return ctxerr.Wrap(ctx, err, "batch set apple declarations")
}
return nil
})
}
@ -122,6 +125,7 @@ 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,
@ -164,6 +168,20 @@ FROM (
WHERE
team_id = ? AND
name NOT IN (?)
UNION
SELECT
declaration_uuid AS profile_uuid,
team_id,
name,
'darwin' AS platform,
identifier,
checksum AS checksum,
created_at,
uploaded_at
FROM mdm_apple_declarations
WHERE team_id = ?
) as combined_profiles
`
@ -183,7 +201,7 @@ FROM (
fleetNames = append(fleetNames, k)
}
args := []any{globalOrTeamID, fleetIdentifiers, globalOrTeamID, fleetNames}
args := []any{globalOrTeamID, fleetIdentifiers, globalOrTeamID, fleetNames, globalOrTeamID}
stmt, args := appendListOptionsWithCursorToSQL(selectStmt, args, &opt)
stmt, args, err := sqlx.In(stmt, args...)
@ -205,15 +223,20 @@ FROM (
}
// load the labels associated with those profiles
var winProfUUIDs, macProfUUIDs []string
var winProfUUIDs, macProfUUIDs, macDeclUUIDs []string
for _, prof := range profs {
if prof.Platform == "windows" {
winProfUUIDs = append(winProfUUIDs, prof.ProfileUUID)
} else {
if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
macDeclUUIDs = append(macDeclUUIDs, prof.ProfileUUID)
continue
}
macProfUUIDs = append(macProfUUIDs, prof.ProfileUUID)
}
}
labels, err := ds.listProfileLabelsForProfiles(ctx, winProfUUIDs, macProfUUIDs)
labels, err := ds.listProfileLabelsForProfiles(ctx, winProfUUIDs, macProfUUIDs, macDeclUUIDs)
if err != nil {
return nil, nil, err
}
@ -232,7 +255,7 @@ FROM (
return profs, metaData, nil
}
func (ds *Datastore) listProfileLabelsForProfiles(ctx context.Context, winProfUUIDs, macProfUUIDs []string) ([]fleet.ConfigurationProfileLabel, error) {
func (ds *Datastore) listProfileLabelsForProfiles(ctx context.Context, winProfUUIDs, macProfUUIDs, macDeclUUIDs []string) ([]fleet.ConfigurationProfileLabel, error) {
// load the labels associated with those profiles
const labelsStmt = `
SELECT
@ -245,6 +268,16 @@ FROM
WHERE
mcpl.apple_profile_uuid IN (?) OR
mcpl.windows_profile_uuid IN (?)
UNION ALL
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
FROM
mdm_declaration_labels mdl
WHERE
mdl.apple_declaration_uuid IN (?)
ORDER BY
profile_uuid, label_name
`
@ -257,8 +290,11 @@ ORDER BY
if len(macProfUUIDs) == 0 {
macProfUUIDs = []string{"-"}
}
if len(macDeclUUIDs) == 0 {
macDeclUUIDs = []string{"-"}
}
stmt, args, err := sqlx.In(labelsStmt, macProfUUIDs, winProfUUIDs)
stmt, args, err := sqlx.In(labelsStmt, macProfUUIDs, winProfUUIDs, macDeclUUIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "sqlx.In to list labels for profiles")
}
@ -415,6 +451,20 @@ WHERE
return ctxerr.Wrap(ctx, err, "bulk set pending windows host profiles")
}
const defaultBatchSize = 1000
batchSize := defaultBatchSize
if ds.testUpsertMDMDesiredProfilesBatchSize > 0 {
batchSize = ds.testUpsertMDMDesiredProfilesBatchSize
}
// TODO(roberto): this method currently sets the state of all
// declarations for all hosts. I don't see an immediate concern
// (and my hunch is that we could even do the same for
// profiles) but this could be optimized to use only a provided
// set of host uuids.
if _, err := mdmAppleBatchSetHostDeclarationStateDB(ctx, tx, batchSize, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending apple declarations")
}
return nil
})
}

File diff suppressed because it is too large Load diff

View file

@ -734,7 +734,7 @@ WHERE
return nil, ctxerr.Wrap(ctx, err, "get mdm windows config profile")
}
labels, err := ds.listProfileLabelsForProfiles(ctx, []string{res.ProfileUUID}, nil)
labels, err := ds.listProfileLabelsForProfiles(ctx, []string{res.ProfileUUID}, nil, nil)
if err != nil {
return nil, err
}
@ -1495,6 +1495,8 @@ INSERT INTO
(SELECT ?, ?, ?, ?, CURRENT_TIMESTAMP() FROM DUAL WHERE
NOT EXISTS (
SELECT 1 FROM mdm_apple_configuration_profiles WHERE name = ? AND team_id = ?
) AND NOT EXISTS (
SELECT 1 FROM mdm_apple_declarations WHERE name = ? AND team_id = ?
)
)`
@ -1504,7 +1506,7 @@ INSERT INTO
}
err := ds.withTx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID)
res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):
@ -1556,6 +1558,8 @@ INSERT INTO
(SELECT ?, ?, ?, ?, CURRENT_TIMESTAMP() FROM DUAL WHERE
NOT EXISTS (
SELECT 1 FROM mdm_apple_configuration_profiles WHERE name = ? AND team_id = ?
) AND NOT EXISTS (
SELECT 1 FROM mdm_apple_declarations WHERE name = ? AND team_id = ?
)
)
ON DUPLICATE KEY UPDATE
@ -1568,7 +1572,7 @@ ON DUPLICATE KEY UPDATE
teamID = *cp.TeamID
}
res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID)
res, err := ds.writer(ctx).ExecContext(ctx, stmt, profileUUID, teamID, cp.Name, cp.SyncML, cp.Name, teamID, cp.Name, teamID)
if err != nil {
switch {
case isDuplicate(err):

View file

@ -0,0 +1,135 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20240327115530, Down_20240327115530)
}
func Up_20240327115530(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE mdm_apple_declarations (
-- declaration_uuid is used as the primary key of the declaration
declaration_uuid varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
-- team_id references the team that owns this declaration
team_id int(10) unsigned NOT NULL DEFAULT '0',
-- identifier is the "Identifier" field in the declaration, surfaced for convenience.
identifier varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
-- name is the name of the declaration
name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
-- raw_json contains a JSON blob with the declaration contents
raw_json json NOT NULL,
-- checksum is an MD5 checksum of the declaration, in binary form
checksum binary(16) NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
uploaded_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (declaration_uuid),
UNIQUE KEY idx_mdm_apple_declaration_team_identifier (team_id, identifier),
UNIQUE KEY idx_mdm_apple_declaration_team_name (team_id, name)
)
`)
if err != nil {
return fmt.Errorf("creating mdm_apple_declarations table: %w", err)
}
_, err = tx.Exec(`
CREATE TABLE mdm_declaration_labels (
-- id is used as the primary key of this table
id int(10) unsigned NOT NULL AUTO_INCREMENT,
-- apple_declaration_uuid references a declaration
apple_declaration_uuid varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
-- label name is stored here because we need to list the labels in the UI
-- even if it has been deleted from the labels table.
label_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
-- label id is nullable in case it gets deleted from the labels table.
-- A row in this table with label_id = null indicates the "broken" state
label_id int(10) unsigned DEFAULT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
uploaded_at timestamp NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY idx_mdm_declaration_labels_label_name (apple_declaration_uuid, label_name),
KEY label_id (label_id),
CONSTRAINT mdm_declaration_labels_ibfk_1 FOREIGN KEY (apple_declaration_uuid) REFERENCES mdm_apple_declarations (declaration_uuid) ON DELETE CASCADE,
CONSTRAINT mdm_declaration_labels_ibfk_3 FOREIGN KEY (label_id) REFERENCES labels (id) ON DELETE SET NULL
)
`)
if err != nil {
return fmt.Errorf("creating mdm_declaration_labels table: %w", err)
}
_, err = tx.Exec(`
CREATE TABLE mdm_apple_declaration_activation_references (
-- declaration_uuid is the declaration that contains the references
declaration_uuid varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
-- reference is the declaration_uuid of another declaration
reference varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
PRIMARY KEY (declaration_uuid, reference),
CONSTRAINT FOREIGN KEY (declaration_uuid) REFERENCES mdm_apple_declarations (declaration_uuid) ON UPDATE CASCADE,
CONSTRAINT FOREIGN KEY (reference) REFERENCES mdm_apple_declarations (declaration_uuid) ON UPDATE CASCADE
)
`)
if err != nil {
return fmt.Errorf("creating mdm_apple_declaration_activation_references table: %w", err)
}
_, err = tx.Exec(`
CREATE TABLE host_mdm_apple_declarations (
-- host_uuid references a host in the hosts table
host_uuid varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
-- status represents the status of the declaration in the host
status varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- operation_type is used to signal if the declaration is being added, removed, etc
operation_type varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
-- detail contains any messages or errors from the protocol or Fleet
detail text COLLATE utf8mb4_unicode_ci,
-- checksum of the currently implemented declaration
checksum binary(16) NOT NULL,
-- declaration_uuid references the declaration assigned to the host's team
declaration_uuid varchar(37) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
-- declaration_identifier is the identifier of the declaration
declaration_identifier varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
-- declaration_name is the name of the declaration
declaration_name varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
PRIMARY KEY (host_uuid, declaration_uuid),
KEY status (status),
KEY operation_type (operation_type),
CONSTRAINT host_mdm_apple_declarations_ibfk_1 FOREIGN KEY (status) REFERENCES mdm_delivery_status (status) ON UPDATE CASCADE,
CONSTRAINT host_mdm_apple_declarations_ibfk_2 FOREIGN KEY (operation_type) REFERENCES mdm_operation_types (operation_type) ON UPDATE CASCADE
)
`)
if err != nil {
return fmt.Errorf("creatign host_mdm_apple_declarations table %w", err)
}
return nil
}
func Down_20240327115530(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,36 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20240327115617, Down_20240327115617)
}
func Up_20240327115617(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE mdm_apple_declarative_requests (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
enrollment_id VARCHAR(255) NOT NULL,
-- Should be one of "tokens", "declaration-items", "status", or "declaration/…/…" where the ellipses reference a declaration on the server
message_type VARCHAR(255) NOT NULL,
-- json payload
raw_json TEXT,
PRIMARY KEY (id),
CONSTRAINT mdm_apple_declarative_requests_enrollment_id FOREIGN KEY (enrollment_id) REFERENCES nano_enrollments (id) ON DELETE CASCADE
)
`)
if err != nil {
return fmt.Errorf("creating mdm_apple_declarative_requsts: %w", err)
}
return nil
}
func Down_20240327115617(tx *sql.Tx) error {
return nil
}

View file

@ -1281,3 +1281,57 @@ func (ds *Datastore) optimisticGetOrInsert(ctx context.Context, readStmt, insert
}
return id, nil
}
// batchProcessDB abstracts the batch processing logic, for a given payload:
//
// - generateValueArgs will get called for each item, the expected return values are:
// - a string containing the placeholders for each item in the batch
// - a slice of arguments containing one item for each placeholder
//
// - executeBatch will get called on each batch to perform the operation in the db
//
// TODO(roberto): use this function in all the functions where we do ad-hoc
// batch processing.
func batchProcessDB[T any](
payload []T,
batchSize int,
generateValueArgs func(T) (string, []any),
executeBatch func(string, []any) error,
) error {
if len(payload) == 0 {
return nil
}
var (
args []any
sb strings.Builder
batchCount int
)
resetBatch := func() {
batchCount = 0
args = args[:0]
sb.Reset()
}
for _, item := range payload {
valuePart, itemArgs := generateValueArgs(item)
args = append(args, itemArgs...)
sb.WriteString(valuePart)
batchCount++
if batchCount >= batchSize {
if err := executeBatch(sb.String(), args); err != nil {
return err
}
resetBatch()
}
}
if batchCount > 0 {
if err := executeBatch(sb.String(), args); err != nil {
return err
}
}
return nil
}

View file

@ -1229,3 +1229,55 @@ func TestWhereFilterGlobalOrTeamIDByTeams(t *testing.T) {
})
}
}
func TestBatchProcessDB(t *testing.T) {
type testData struct {
id int
value string
}
payload := []interface{}{
&testData{id: 1, value: "a"},
&testData{id: 2, value: "b"},
&testData{id: 3, value: "c"},
}
generateValueArgs := func(item interface{}) (string, []any) {
p := item.(*testData)
valuePart := "(?, ?),"
args := []any{p.id, p.value}
return valuePart, args
}
t.Run("TestEmptyPayload", func(t *testing.T) {
executeBatch := func(valuePart string, args []any) error {
return errors.New("execute shouldn't be called for an empty payload")
}
err := batchProcessDB([]interface{}{}, 1000, generateValueArgs, executeBatch)
require.NoError(t, err)
})
t.Run("TestSingleBatch", func(t *testing.T) {
callCount := 0
executeBatch := func(valuePart string, args []any) error {
callCount++
require.Equal(t, 2, len(args)/2) // each item adds 2 args
return nil
}
err := batchProcessDB(payload[:2], 2, generateValueArgs, executeBatch)
require.NoError(t, err)
require.Equal(t, 1, callCount)
})
t.Run("TestMultipleBatches", func(t *testing.T) {
callCount := 0
executeBatch := func(valuePart string, args []any) error {
callCount++
require.Equal(t, 2/callCount, len(args)/2) // each item adds 2 args
return nil
}
err := batchProcessDB(payload, 2, generateValueArgs, executeBatch)
require.NoError(t, err)
require.Equal(t, 2, callCount)
})
}

File diff suppressed because one or more lines are too long

View file

@ -313,11 +313,12 @@ func TruncateTables(t testing.TB, ds *Datastore, tables ...string) {
// be truncated - a more precise approach must be used for those, e.g.
// delete where id > max before test, or something like that.
nonEmptyTables := map[string]bool{
"app_config_json": true,
"migration_status_tables": true,
"osquery_options": true,
"mdm_delivery_status": true,
"mdm_operation_types": true,
"app_config_json": true,
"migration_status_tables": true,
"osquery_options": true,
"mdm_delivery_status": true,
"mdm_operation_types": true,
"mdm_apple_declaration_categories": true,
}
ctx := context.Background()

View file

@ -84,6 +84,9 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeLockedHost{},
ActivityTypeUnlockedHost{},
ActivityTypeWipedHost{},
ActivityTypeCreatedDeclarationProfile{},
ActivityTypeDeletedDeclarationProfile{},
}
type ActivityDetails interface {
@ -1317,6 +1320,56 @@ func (a ActivityTypeWipedHost) Documentation() (activity, details, detailsExampl
}`
}
type ActivityTypeCreatedDeclarationProfile struct {
ProfileName string `json:"profile_name"`
Identifier string `json:"identifier"`
TeamID *uint `json:"team_id"`
TeamName *string `json:"team_name"`
}
func (a ActivityTypeCreatedDeclarationProfile) ActivityName() string {
return "created_declaration_profile"
}
func (a ActivityTypeCreatedDeclarationProfile) Documentation() (activity string, details string, detailsExample string) {
return `Generated when a user adds a new macOS declaration to a team (or no team).`,
`This activity contains the following fields:
- "profile_name": Name of the declaration.
- "identifier": Identifier of the declaration.
- "team_id": The ID of the team that the declaration applies to, ` + "`null`" + ` if it applies to devices that are not in a team.
- "team_name": The name of the team that the declaration applies to, ` + "`null`" + ` if it applies to devices that are not in a team.`, `{
"profile_name": "Passcode requirements",
"profile_identifier": "com.my.declaration",
"team_id": 123,
"team_name": "Workstations"
}`
}
type ActivityTypeDeletedDeclarationProfile struct {
ProfileName string `json:"profile_name"`
Identifier string `json:"identifier"`
TeamID *uint `json:"team_id"`
TeamName *string `json:"team_name"`
}
func (a ActivityTypeDeletedDeclarationProfile) ActivityName() string {
return "deleted_declaration_profile"
}
func (a ActivityTypeDeletedDeclarationProfile) Documentation() (activity string, details string, detailsExample string) {
return `Generated when a user removes a macOS declaration from a team (or no team).`,
`This activity contains the following fields:
- "profile_name": Name of the declaration.
- "identifier": Identifier of the declaration.
- "team_id": The ID of the team that the declaration applies to, ` + "`null`" + ` if it applies to devices that are not in a team.
- "team_name": The name of the team that the declaration applies to, ` + "`null`" + ` if it applies to devices that are not in a team.`, `{
"profile_name": "Passcode requirements",
"profile_identifier": "com.my.declaration",
"team_id": 123,
"team_name": "Workstations"
}`
}
// LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams.
func LogRoleChangeActivities(ctx context.Context, ds Datastore, adminUser *User, oldGlobalRole *string, oldTeamRoles []UserTeam, user *User) error {
if user.GlobalRole != nil && (oldGlobalRole == nil || *oldGlobalRole != *user.GlobalRole) {

View file

@ -451,6 +451,8 @@ const (
DEPAssignProfileResponseFailed DEPAssignProfileResponseStatus = "FAILED"
)
const MDMAppleDeclarationUUIDPrefix = "d"
// NanoEnrollment represents a row in the nano_enrollments table managed by
// nanomdm. It is meant to be used internally by the server, not to be returned
// as part of endpoints, and as a precaution its json-encoding is explicitly
@ -533,3 +535,287 @@ type SCEPIdentityAssociation struct {
EnrollReference string `db:"enroll_reference"`
RenewCommandUUID string `db:"renew_command_uuid"`
}
// MDMAppleDeclaration represents a DDM JSON declaration.
type MDMAppleDeclaration struct {
// DeclarationUUID is the unique identifier of the declaration in
// Fleet. Since we use the same endpoints for declarations and profiles:
// - This is marshalled as profile_uuid
// - The value has a prefix (TODO: @jahzielv to determine and document this)
DeclarationUUID string `db:"declaration_uuid" json:"profile_uuid"`
// TeamID is the id of the team with which the declaration is associated. A nil team id
// represents a declaration that is not associated with any team.
TeamID *uint `db:"team_id" json:"team_id"`
// Identifier corresponds to the "Identifier" key of the associated declaration.
// Fleet requires that Identifier must be unique in combination with the Name and TeamID.
Identifier string `db:"identifier" json:"identifier"`
// Name corresponds to the file name of the associated JSON declaration payload.
// Fleet requires that Name must be unique in combination with the Identifier and TeamID.
Name string `db:"name" json:"name"`
// RawJSON is the raw JSON content of the declaration
RawJSON json.RawMessage `db:"raw_json" json:"-"`
// 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"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
}
type MDMAppleRawDeclaration struct {
// Type is the "Type" field on the raw declaration JSON.
Type string `json:"Type"`
Identifier string `json:"Identifier"`
}
// ForbiddenDeclTypes is a set of declaration types that are not allowed to be
// added by users into Fleet.
var ForbiddenDeclTypes = map[string]struct{}{
"com.apple.configuration.account.caldav": {},
"com.apple.configuration.account.carddav": {},
"com.apple.configuration.account.exchange": {},
"com.apple.configuration.account.google": {},
"com.apple.configuration.account.ldap": {},
"com.apple.configuration.account.mail": {},
"com.apple.configuration.screensharing.connection": {},
"com.apple.configuration.security.certificate": {},
"com.apple.configuration.security.identity": {},
"com.apple.configuration.security.passkey.attestation": {},
"com.apple.configuration.services.configuration-files": {},
"com.apple.configuration.watch.enrollment": {},
}
func (r *MDMAppleRawDeclaration) ValidateUserProvided() error {
var err error
// Check against types we don't allow
if r.Type == `com.apple.configuration.softwareupdate.enforcement.specific` {
return NewInvalidArgumentError(r.Type, "Declaration profile cant include OS updates settings. To control these settings, go to OS updates.")
}
if _, forbidden := ForbiddenDeclTypes[r.Type]; forbidden {
return NewInvalidArgumentError(r.Type, "Only configuration declarations that dont require an asset reference are supported.")
}
if r.Type == "com.apple.configuration.management.status-subscriptions" {
return NewInvalidArgumentError(r.Type, "Declaration profile cant include status subscription type. To get hosts vitals, please use queries and policies.")
}
if !strings.HasPrefix(r.Type, "com.apple.configuration") {
return NewInvalidArgumentError(r.Type, "Only configuration declarations (com.apple.configuration) are supported.")
}
return err
}
func GetRawDeclarationValues(raw []byte) (*MDMAppleRawDeclaration, error) {
var rawDecl MDMAppleRawDeclaration
if err := json.Unmarshal(raw, &rawDecl); err != nil {
return nil, NewInvalidArgumentError("declaration", fmt.Sprintf("Couldn't upload. The file should include valid JSON: %s", err)).WithStatus(http.StatusBadRequest)
}
return &rawDecl, nil
}
// MDMAppleHostDeclaration represents the state of a declaration on a host
type MDMAppleHostDeclaration struct {
// HostUUID is the uuid of the host affected by this declaration
HostUUID string `db:"host_uuid" json:"-"`
// DeclarationUUID is the unique identifier of the declaration in
// Fleet. Since we use the same endpoints for declarations and profiles:
// - This is marshalled as profile_uuid
// - The value has a prefix (TODO: @jahzielv to determine and document this)
DeclarationUUID string `db:"declaration_uuid" json:"profile_uuid"`
// Name corresponds to the file name of the associated JSON declaration payload.
Name string `db:"declaration_name" json:"name"`
// Identifier corresponds to the "Identifier" key of the associated declaration.
Identifier string `db:"declaration_identifier" json:"-"`
// Status represent the current state of the declaration, as known by the Fleet server.
Status *MDMDeliveryStatus `db:"status" json:"status"`
// Operation type represents the operation being performed.
OperationType MDMOperationType `db:"operation_type" json:"operation_type"`
// Detail contains any messages that must be surfaced to the user,
// either by the MDM protocol or the Fleet server.
Detail string `db:"detail" json:"detail"`
// Checksum contains the MD5 checksum of the declaration JSON uploaded
// by the IT admin. Fleet uses this value as the ServerToken.
Checksum string `db:"checksum" json:"-"`
}
func NewMDMAppleDeclaration(raw []byte, teamID *uint, name string, declType, ident string) *MDMAppleDeclaration {
var decl MDMAppleDeclaration
decl.Identifier = ident
decl.Name = name
decl.RawJSON = raw
decl.TeamID = teamID
return &decl
}
// MDMAppleDDMTokensResponse is the response from the DDM tokens endpoint.
//
// https://developer.apple.com/documentation/devicemanagement/tokensresponse
type MDMAppleDDMTokensResponse struct {
SyncTokens MDMAppleDDMDeclarationsToken
}
// MDMAppleDDMDeclarationsToken is dictionary describes the state of declarations on the server.
//
// https://developer.apple.com/documentation/devicemanagement/synchronizationtokens
type MDMAppleDDMDeclarationsToken struct {
DeclarationsToken string `db:"checksum"`
Timestamp time.Time `db:"latest_created_timestamp"`
}
// MDMAppleDDMDeclarationItemsResponse is the response from the DDM declaration items endpoint.
//
// https://developer.apple.com/documentation/devicemanagement/declarationitemsresponse
type MDMAppleDDMDeclarationItemsResponse struct {
Declarations MDMAppleDDMManifestItems
DeclarationsToken string
}
// MDMAppleDDMManifestItems is a dictionary that contains the lists of declarations available on the
// server.
//
// https://developer.apple.com/documentation/devicemanagement/declarationitemsresponse/manifestdeclarationitems
type MDMAppleDDMManifestItems struct {
Activations []MDMAppleDDMManifest
Assets []MDMAppleDDMManifest
Configurations []MDMAppleDDMManifest
Management []MDMAppleDDMManifest
}
// MDMAppleDDMManifest is a dictionary that describes a declaration.
//
// https://developer.apple.com/documentation/devicemanagement/declarationitemsresponse/manifestdeclarationitems
type MDMAppleDDMManifest struct {
Identifier string
ServerToken string
}
// MDMAppleDDMDeclarationItem represents a declaration item in the datastore. It is used to
// construct the DDM `declaration-items` endpoint response.
//
// https://developer.apple.com/documentation/devicemanagement/declarationitemsresponse
type MDMAppleDDMDeclarationItem struct {
Identifier string `db:"identifier"`
ServerToken string `db:"checksum"`
}
// MDMAppleDDMDeclarationResponse represents a declaration in the datastore. It is used for the DDM
// `declaration/.../...` enpoint response.
//
// https://developer.apple.com/documentation/devicemanagement/declarationresponse
type MDMAppleDDMDeclarationResponse struct {
Identifier string `db:"identifier"`
Type string `db:"type"`
Payload json.RawMessage `db:"payload"`
ServerToken string `db:"server_token"`
}
// MDMAppleDDMStatusReport represents a report of the device's current state.
//
// https://developer.apple.com/documentation/devicemanagement/statusreport
type MDMAppleDDMStatusReport struct {
StatusItems MDMAppleDDMStatusItems `json:"StatusItems"`
Errors []MDMAppleDDMErrors `json:"Errors"`
}
// MDMAppleDDMStatusItems are the status items for a report.
//
// https://developer.apple.com/documentation/devicemanagement/statusreport/statusitems
type MDMAppleDDMStatusItems struct {
Management MDMAppleDDMStatusManagement `json:"management"`
}
// MDMAppleDDMStatusManagement represents status report of the client's
// processed declarations.
//
// https://developer.apple.com/documentation/devicemanagement/statusmanagementdeclarations
type MDMAppleDDMStatusManagement struct {
Declarations MDMAppleDDMStatusDeclarations `json:"declarations"`
}
// MDMAppleDDMStatusDeclarations represents a collection of the client's
// processed declarations.
//
// https://developer.apple.com/documentation/devicemanagement/statusmanagementdeclarationsdeclarationsobject
type MDMAppleDDMStatusDeclarations struct {
// Activations is an array of declarations that represent the client's
// processed activation types.
Activations []MDMAppleDDMStatusDeclaration `json:"activations"`
// Configurations is an array of declarations that represent the
// client's processed configuration types.
Configurations []MDMAppleDDMStatusDeclaration `json:"configurations"`
// Assets is an array of declarations that represent the client's
// processed assets.
Assets []MDMAppleDDMStatusDeclaration `json:"assets"`
// Management is an array of declarations that represent the client's
// processed declaration types.
Management []MDMAppleDDMStatusDeclaration `json:"management"`
}
type MDMAppleDeclarationValidity string
const (
MDMAppleDeclarationValid MDMAppleDeclarationValidity = "valid"
MDMAppleDeclarationInvalid MDMAppleDeclarationValidity = "invalid"
MDMAppleDeclarationUnknown MDMAppleDeclarationValidity = "valid"
)
// MDMAppleDDMStatusDeclaration represents a processed declaration for the client.
//
// https://developer.apple.com/documentation/devicemanagement/statusmanagementdeclarationsdeclarationobject
type MDMAppleDDMStatusDeclaration struct {
// Active signals if the declaration is active on the device.
Active bool `json:"active"`
// Identifier is the identifier of the declaration this status report refers to.
Identifier string `json:"identifier"`
// Valid defines the validity of the declaration. If it's invalid, the
// reasons property contains more details.
Valid MDMAppleDeclarationValidity `json:"valid"`
// ServerToken of the declaration this status report refers to.
ServerToken string `json:"server-token"`
// Reasons are the details of any client errors.
Reasons []MDMAppleDDMStatusErrorReason `json:"reasons,omitempty"`
}
// A status report's error that contains the status item and the reasons for
// the error.
//
// https://developer.apple.com/documentation/devicemanagement/statusreport/error
type MDMAppleDDMErrors struct {
// StatusItem is the status item that this error pertains to.
StatusItem string `json:"StatusItem"`
// Reasons is an array of reasons for the error.
Reasons []MDMAppleDDMStatusErrorReason `json:"Reasons"`
}
// A status report that contains details about an error.
//
// https://developer.apple.com/documentation/devicemanagement/statusreason
type MDMAppleDDMStatusErrorReason struct {
// Code is the error code for this error.
Code string `json:"Code"`
// Description is a short error description.
Description string `json:"Description"`
// Details is a dictionary that contains further details about this
// error.
Details map[string]any `json:"Details"`
}

View file

@ -927,6 +927,9 @@ type Datastore interface {
// profile uuid.
GetMDMAppleConfigProfile(ctx context.Context, profileUUID string) (*MDMAppleConfigProfile, error)
// GetMDMAppleDeclaration returns the declaration corresponding to the specified uuid.
GetMDMAppleDeclaration(ctx context.Context, declUUID string) (*MDMAppleDeclaration, error)
// ListMDMAppleConfigProfiles lists mdm config profiles associated with the specified team id.
// For global config profiles, specify nil as the team id.
ListMDMAppleConfigProfiles(ctx context.Context, teamID *uint) ([]*MDMAppleConfigProfile, error)
@ -1169,6 +1172,29 @@ type Datastore interface {
// serials.
UpdateDEPAssignProfileRetryPending(ctx context.Context, jobID uint, serials []string) error
// InsertMDMAppleDDMRequest inserts a DDM request.
InsertMDMAppleDDMRequest(ctx context.Context, hostUUID, messageType, rawJSON string) error
// MDMAppleDDMDeclarationsToken returns the token used to synchronize declarations for the
// specified host UUID.
MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*MDMAppleDDMDeclarationsToken, error)
// MDMAppleDDMDeclarationItems returns the declaration items for the specified host UUID.
MDMAppleDDMDeclarationItems(ctx context.Context, hostUUID string) ([]MDMAppleDDMDeclarationItem, error)
// MDMAppleDDMDeclarationPayload returns the declaration payload for the specified identifier and team.
MDMAppleDDMDeclarationsResponse(ctx context.Context, identifier string, hostUUID string) (*MDMAppleDeclaration, error)
//MDMAppleBatchSetHostDeclarationState
MDMAppleBatchSetHostDeclarationState(ctx context.Context) ([]string, error)
// MDMAppleStoreDDMStatusReport receives a host.uuid and a slice
// of declarations, and updates the tracked host declaration status for
// matching declarations.
//
// It also takes care of cleaning up all host declarations that are
// pending removal.
MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID string, updates []*MDMAppleHostDeclaration) error
// MDMAppleSetDeclarationsAsVerifying updates all
// ("pending", "install") declarations for a host to be ("verifying", "install")
MDMAppleSetDeclarationsAsVerifying(ctx context.Context, hostUUID string) error
///////////////////////////////////////////////////////////////////////////////
// Microsoft MDM
@ -1287,7 +1313,10 @@ type Datastore interface {
// BatchSetMDMProfiles sets the MDM Apple or Windows profiles for the given team or
// no team in a single transaction.
BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile) error
BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*MDMAppleConfigProfile, winProfiles []*MDMWindowsConfigProfile, macDeclarations []*MDMAppleDeclaration) error
// NewMDMAppleDeclaration creates and returns a new MDM Apple declaration.
NewMDMAppleDeclaration(ctx context.Context, declaration *MDMAppleDeclaration) (*MDMAppleDeclaration, error)
///////////////////////////////////////////////////////////////////////////////
// Host Script Results

View file

@ -413,6 +413,24 @@ func NewMDMConfigProfilePayloadFromApple(cp *MDMAppleConfigProfile) *MDMConfigPr
}
}
func NewMDMConfigProfilePayloadFromAppleDDM(decl *MDMAppleDeclaration) *MDMConfigProfilePayload {
var tid *uint
if decl.TeamID != nil && *decl.TeamID > 0 {
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,
}
}
// MDMProfileSpec represents the spec used to define configuration
// profiles via yaml files.
type MDMProfileSpec struct {

View file

@ -652,18 +652,29 @@ type Service interface {
// NewMDMAppleConfigProfile creates a new configuration profile for the specified team.
NewMDMAppleConfigProfile(ctx context.Context, teamID uint, r io.Reader, labels []string) (*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)
// GetMDMAppleConfigProfileByDeprecatedID retrieves the specified Apple
// configuration profile via its numeric ID. This method is deprecated and
// should not be used for new endpoints.
GetMDMAppleConfigProfileByDeprecatedID(ctx context.Context, profileID uint) (*MDMAppleConfigProfile, error)
// GetMDMAppleConfigProfile retrieves the specified configuration profile.
GetMDMAppleConfigProfile(ctx context.Context, profileUUID string) (*MDMAppleConfigProfile, error)
// GetMDMAppleDeclaration retrieves the specified declaration.
GetMDMAppleDeclaration(ctx context.Context, declarationUUID string) (*MDMAppleDeclaration, error)
// DeleteMDMAppleConfigProfileByDeprecatedID deletes the specified Apple
// configuration profile via its numeric ID. This method is deprecated and
// should not be used for new endpoints.
DeleteMDMAppleConfigProfileByDeprecatedID(ctx context.Context, profileID uint) error
// DeleteMDMAppleConfigProfile deletes the specified configuration profile.
DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID string) error
// DeleteMDMAppleDeclaration deletes the specified declaration.
DeleteMDMAppleDeclaration(ctx context.Context, declarationUUID string) error
// ListMDMAppleConfigProfiles returns the list of all the configuration profiles for the
// specified team.
ListMDMAppleConfigProfiles(ctx context.Context, teamID uint) ([]*MDMAppleConfigProfile, error)

View file

@ -226,6 +226,28 @@ func (svc *MDMAppleCommander) AccountConfiguration(ctx context.Context, hostUUID
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
}
// DeclarativeManagement sends the homonym [command][1] to the device to enable DDM or start a new DDM session.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/declarativemanagementcommand
func (svc *MDMAppleCommander) DeclarativeManagement(ctx context.Context, hostUUIDs []string, uuid string) error {
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Command</key>
<dict>
<key>RequestType</key>
<string>DeclarativeManagement</string>
</dict>
<key>CommandUUID</key>
<string>%s</string>
</dict>
</plist>`, uuid)
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
}
func (svc *MDMAppleCommander) DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error {
raw := fmt.Sprintf(`<?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">

View file

@ -101,6 +101,7 @@ func GenerateRandomPin(length int) string {
return fmt.Sprintf(f, v)
}
// FmtErrorChain formats Command error message for macOS MDM v1
func FmtErrorChain(chain []mdm.ErrorChain) string {
var sb strings.Builder
for _, mdmErr := range chain {
@ -113,6 +114,15 @@ func FmtErrorChain(chain []mdm.ErrorChain) string {
return sb.String()
}
// FmtDDMError formats a DDM error message
func FmtDDMError(reasons []fleet.MDMAppleDDMStatusErrorReason) string {
var errMsg strings.Builder
for _, r := range reasons {
errMsg.WriteString(fmt.Sprintf("%s: %s %+v\n", r.Code, r.Description, r.Details))
}
return errMsg.String()
}
func EnrollURL(token string, appConfig *fleet.AppConfig) (string, error) {
enrollURL, err := url.Parse(appConfig.ServerSettings.ServerURL)
if err != nil {

View file

@ -50,7 +50,7 @@ func GetRawProfilePlatform(profile []byte) string {
bytes.EqualFold(prefix, trimmedProfile[:len(prefix)])
}
if prefixMatches([]byte("<?xml")) {
if prefixMatches([]byte("<?xml")) || prefixMatches([]byte(`{`)) {
return "darwin"
}

View file

@ -179,6 +179,16 @@ func TestGetRawProfilePlatform(t *testing.T) {
input: []byte("<?x"),
expected: "",
},
{
name: "DDM JSON",
input: []byte(`{"foo": "bar"}`),
expected: "darwin",
},
{
name: "DDM JSON with whitespace",
input: []byte(` {"foo": "bar"}`),
expected: "darwin",
},
}
for _, tt := range testCases {

View file

@ -642,6 +642,8 @@ type GetMDMAppleConfigProfileByDeprecatedIDFunc func(ctx context.Context, profil
type GetMDMAppleConfigProfileFunc func(ctx context.Context, profileUUID string) (*fleet.MDMAppleConfigProfile, error)
type GetMDMAppleDeclarationFunc func(ctx context.Context, declUUID string) (*fleet.MDMAppleDeclaration, error)
type ListMDMAppleConfigProfilesFunc func(ctx context.Context, teamID *uint) ([]*fleet.MDMAppleConfigProfile, error)
type DeleteMDMAppleConfigProfileByDeprecatedIDFunc func(ctx context.Context, profileID uint) error
@ -778,6 +780,20 @@ type GetDEPAssignProfileExpiredCooldownsFunc func(ctx context.Context) (map[uint
type UpdateDEPAssignProfileRetryPendingFunc func(ctx context.Context, jobID uint, serials []string) error
type InsertMDMAppleDDMRequestFunc func(ctx context.Context, hostUUID string, messageType string, rawJSON string) error
type MDMAppleDDMDeclarationsTokenFunc func(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error)
type MDMAppleDDMDeclarationItemsFunc func(ctx context.Context, hostUUID string) ([]fleet.MDMAppleDDMDeclarationItem, error)
type MDMAppleDDMDeclarationsResponseFunc func(ctx context.Context, identifier string, hostUUID string) (*fleet.MDMAppleDeclaration, error)
type MDMAppleBatchSetHostDeclarationStateFunc func(ctx context.Context) ([]string, error)
type MDMAppleStoreDDMStatusReportFunc func(ctx context.Context, hostUUID string, updates []*fleet.MDMAppleHostDeclaration) error
type MDMAppleSetDeclarationsAsVerifyingFunc func(ctx context.Context, hostUUID string) error
type WSTEPStoreCertificateFunc func(ctx context.Context, name string, crt *x509.Certificate) error
type WSTEPNewSerialFunc func(ctx context.Context) (*big.Int, error)
@ -836,7 +852,9 @@ type NewMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindow
type SetOrUpdateMDMWindowsConfigProfileFunc func(ctx context.Context, cp fleet.MDMWindowsConfigProfile) error
type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error
type BatchSetMDMProfilesFunc func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error
type NewMDMAppleDeclarationFunc func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error)
type NewHostScriptExecutionRequestFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error)
@ -1817,6 +1835,9 @@ type DataStore struct {
GetMDMAppleConfigProfileFunc GetMDMAppleConfigProfileFunc
GetMDMAppleConfigProfileFuncInvoked bool
GetMDMAppleDeclarationFunc GetMDMAppleDeclarationFunc
GetMDMAppleDeclarationFuncInvoked bool
ListMDMAppleConfigProfilesFunc ListMDMAppleConfigProfilesFunc
ListMDMAppleConfigProfilesFuncInvoked bool
@ -2021,6 +2042,27 @@ type DataStore struct {
UpdateDEPAssignProfileRetryPendingFunc UpdateDEPAssignProfileRetryPendingFunc
UpdateDEPAssignProfileRetryPendingFuncInvoked bool
InsertMDMAppleDDMRequestFunc InsertMDMAppleDDMRequestFunc
InsertMDMAppleDDMRequestFuncInvoked bool
MDMAppleDDMDeclarationsTokenFunc MDMAppleDDMDeclarationsTokenFunc
MDMAppleDDMDeclarationsTokenFuncInvoked bool
MDMAppleDDMDeclarationItemsFunc MDMAppleDDMDeclarationItemsFunc
MDMAppleDDMDeclarationItemsFuncInvoked bool
MDMAppleDDMDeclarationsResponseFunc MDMAppleDDMDeclarationsResponseFunc
MDMAppleDDMDeclarationsResponseFuncInvoked bool
MDMAppleBatchSetHostDeclarationStateFunc MDMAppleBatchSetHostDeclarationStateFunc
MDMAppleBatchSetHostDeclarationStateFuncInvoked bool
MDMAppleStoreDDMStatusReportFunc MDMAppleStoreDDMStatusReportFunc
MDMAppleStoreDDMStatusReportFuncInvoked bool
MDMAppleSetDeclarationsAsVerifyingFunc MDMAppleSetDeclarationsAsVerifyingFunc
MDMAppleSetDeclarationsAsVerifyingFuncInvoked bool
WSTEPStoreCertificateFunc WSTEPStoreCertificateFunc
WSTEPStoreCertificateFuncInvoked bool
@ -2111,6 +2153,9 @@ type DataStore struct {
BatchSetMDMProfilesFunc BatchSetMDMProfilesFunc
BatchSetMDMProfilesFuncInvoked bool
NewMDMAppleDeclarationFunc NewMDMAppleDeclarationFunc
NewMDMAppleDeclarationFuncInvoked bool
NewHostScriptExecutionRequestFunc NewHostScriptExecutionRequestFunc
NewHostScriptExecutionRequestFuncInvoked bool
@ -4361,6 +4406,13 @@ func (s *DataStore) GetMDMAppleConfigProfile(ctx context.Context, profileUUID st
return s.GetMDMAppleConfigProfileFunc(ctx, profileUUID)
}
func (s *DataStore) GetMDMAppleDeclaration(ctx context.Context, declUUID string) (*fleet.MDMAppleDeclaration, error) {
s.mu.Lock()
s.GetMDMAppleDeclarationFuncInvoked = true
s.mu.Unlock()
return s.GetMDMAppleDeclarationFunc(ctx, declUUID)
}
func (s *DataStore) ListMDMAppleConfigProfiles(ctx context.Context, teamID *uint) ([]*fleet.MDMAppleConfigProfile, error) {
s.mu.Lock()
s.ListMDMAppleConfigProfilesFuncInvoked = true
@ -4837,6 +4889,55 @@ func (s *DataStore) UpdateDEPAssignProfileRetryPending(ctx context.Context, jobI
return s.UpdateDEPAssignProfileRetryPendingFunc(ctx, jobID, serials)
}
func (s *DataStore) InsertMDMAppleDDMRequest(ctx context.Context, hostUUID string, messageType string, rawJSON string) error {
s.mu.Lock()
s.InsertMDMAppleDDMRequestFuncInvoked = true
s.mu.Unlock()
return s.InsertMDMAppleDDMRequestFunc(ctx, hostUUID, messageType, rawJSON)
}
func (s *DataStore) MDMAppleDDMDeclarationsToken(ctx context.Context, hostUUID string) (*fleet.MDMAppleDDMDeclarationsToken, error) {
s.mu.Lock()
s.MDMAppleDDMDeclarationsTokenFuncInvoked = true
s.mu.Unlock()
return s.MDMAppleDDMDeclarationsTokenFunc(ctx, hostUUID)
}
func (s *DataStore) MDMAppleDDMDeclarationItems(ctx context.Context, hostUUID string) ([]fleet.MDMAppleDDMDeclarationItem, error) {
s.mu.Lock()
s.MDMAppleDDMDeclarationItemsFuncInvoked = true
s.mu.Unlock()
return s.MDMAppleDDMDeclarationItemsFunc(ctx, hostUUID)
}
func (s *DataStore) MDMAppleDDMDeclarationsResponse(ctx context.Context, identifier string, hostUUID string) (*fleet.MDMAppleDeclaration, error) {
s.mu.Lock()
s.MDMAppleDDMDeclarationsResponseFuncInvoked = true
s.mu.Unlock()
return s.MDMAppleDDMDeclarationsResponseFunc(ctx, identifier, hostUUID)
}
func (s *DataStore) MDMAppleBatchSetHostDeclarationState(ctx context.Context) ([]string, error) {
s.mu.Lock()
s.MDMAppleBatchSetHostDeclarationStateFuncInvoked = true
s.mu.Unlock()
return s.MDMAppleBatchSetHostDeclarationStateFunc(ctx)
}
func (s *DataStore) MDMAppleStoreDDMStatusReport(ctx context.Context, hostUUID string, updates []*fleet.MDMAppleHostDeclaration) error {
s.mu.Lock()
s.MDMAppleStoreDDMStatusReportFuncInvoked = true
s.mu.Unlock()
return s.MDMAppleStoreDDMStatusReportFunc(ctx, hostUUID, updates)
}
func (s *DataStore) MDMAppleSetDeclarationsAsVerifying(ctx context.Context, hostUUID string) error {
s.mu.Lock()
s.MDMAppleSetDeclarationsAsVerifyingFuncInvoked = true
s.mu.Unlock()
return s.MDMAppleSetDeclarationsAsVerifyingFunc(ctx, hostUUID)
}
func (s *DataStore) WSTEPStoreCertificate(ctx context.Context, name string, crt *x509.Certificate) error {
s.mu.Lock()
s.WSTEPStoreCertificateFuncInvoked = true
@ -5040,11 +5141,18 @@ func (s *DataStore) SetOrUpdateMDMWindowsConfigProfile(ctx context.Context, cp f
return s.SetOrUpdateMDMWindowsConfigProfileFunc(ctx, cp)
}
func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
func (s *DataStore) BatchSetMDMProfiles(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDeclarations []*fleet.MDMAppleDeclaration) error {
s.mu.Lock()
s.BatchSetMDMProfilesFuncInvoked = true
s.mu.Unlock()
return s.BatchSetMDMProfilesFunc(ctx, tmID, macProfiles, winProfiles)
return s.BatchSetMDMProfilesFunc(ctx, tmID, macProfiles, winProfiles, macDeclarations)
}
func (s *DataStore) NewMDMAppleDeclaration(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) {
s.mu.Lock()
s.NewMDMAppleDeclarationFuncInvoked = true
s.mu.Unlock()
return s.NewMDMAppleDeclarationFunc(ctx, declaration)
}
func (s *DataStore) NewHostScriptExecutionRequest(ctx context.Context, request *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) {

View file

@ -31,6 +31,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/log"
@ -387,6 +388,136 @@ 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) {
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
// check that Apple MDM is enabled - the middleware of that endpoint checks
// only that any MDM is enabled, maybe it's just Windows
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
err := fleet.NewInvalidArgumentError("declaration", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
return nil, ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
}
fleetNames := mdm_types.FleetReservedProfileNames()
if _, ok := fleetNames[name]; ok {
err := fleet.NewInvalidArgumentError("declaration", fmt.Sprintf("Profile name %q is not allowed.", name)).WithStatus(http.StatusBadRequest)
return nil, err
}
var teamName string
if teamID >= 1 {
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
teamName = tm.Name
}
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
var tmID *uint
if teamID >= 1 {
tmID = &teamID
}
validatedLabels, err := svc.validateDeclarationLabels(ctx, labels)
if err != nil {
return nil, err
}
// TODO(roberto): Maybe GetRawDeclarationValues belongs inside NewMDMAppleDeclaration? We can refactor this in a follow up.
rawDecl, err := fleet.GetRawDeclarationValues(data)
if err != nil {
return nil, err
}
if err := rawDecl.ValidateUserProvided(); err != nil {
return nil, err
}
d := fleet.NewMDMAppleDeclaration(data, tmID, name, rawDecl.Type, rawDecl.Identifier)
// TODO(roberto): Is this already handled in NewMDMAppleDeclaration? Could we add the labels as well?
d.Labels = validatedLabels
d.TeamID = tmID
decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d)
if err != nil {
return nil, err
}
var (
actTeamID *uint
actTeamName *string
)
if teamID > 0 {
actTeamID = &teamID
actTeamName = &teamName
}
if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeCreatedDeclarationProfile{
TeamID: actTeamID,
TeamName: actTeamName,
ProfileName: decl.Name,
Identifier: decl.Identifier,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm apple declaration")
}
return decl, nil
}
func (svc *Service) batchValidateDeclarationLabels(ctx context.Context, labelNames []string) (map[string]fleet.ConfigurationProfileLabel, error) {
if len(labelNames) == 0 {
return nil, nil
}
labels, err := svc.ds.LabelIDsByName(ctx, labelNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
}
uniqueNames := make(map[string]bool)
for _, entry := range labelNames {
if _, value := uniqueNames[entry]; !value {
uniqueNames[entry] = true
}
}
if len(labels) != len(uniqueNames) {
return nil, &fleet.BadRequestError{
Message: "some or all the labels provided don't exist",
InternalErr: fmt.Errorf("names provided: %v", labelNames),
}
}
profLabels := make(map[string]fleet.ConfigurationProfileLabel)
for labelName, labelID := range labels {
profLabels[labelName] = fleet.ConfigurationProfileLabel{
LabelName: labelName,
LabelID: labelID,
}
}
return profLabels, nil
}
func (svc *Service) validateDeclarationLabels(ctx context.Context, labelNames []string) ([]fleet.ConfigurationProfileLabel, error) {
labelMap, err := svc.batchValidateDeclarationLabels(ctx, labelNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "validating declaration labels")
}
var declLabels []fleet.ConfigurationProfileLabel
for _, label := range labelMap {
declLabels = append(declLabels, label)
}
return declLabels, nil
}
type listMDMAppleConfigProfilesRequest struct {
TeamID uint `query:"team_id,optional"`
}
@ -515,6 +646,25 @@ func (svc *Service) GetMDMAppleConfigProfile(ctx context.Context, profileUUID st
return cp, nil
}
func (svc *Service) GetMDMAppleDeclaration(ctx context.Context, profileUUID string) (*fleet.MDMAppleDeclaration, error) {
// first we perform a perform basic authz check
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return nil, err
}
cp, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
// now we can do a specific authz check based on team id of profile before we return the profile
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: cp.TeamID}, fleet.ActionRead); err != nil {
return nil, err
}
return cp, nil
}
type deleteMDMAppleConfigProfileRequest struct {
ProfileID uint `url:"profile_id"`
}
@ -623,6 +773,85 @@ func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID
return nil
}
func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID string) error {
// first we perform a perform basic authz check
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
return ctxerr.Wrap(ctx, err)
}
// check that Apple MDM is enabled - the middleware of that endpoint checks
// only that any MDM is enabled, maybe it's just Windows
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
err := fleet.NewInvalidArgumentError("profile_uuid", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
return ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
}
decl, err := svc.ds.GetMDMAppleDeclaration(ctx, declUUID)
if err != nil {
return ctxerr.Wrap(ctx, err)
}
if _, ok := mdm_types.FleetReservedProfileNames()[decl.Name]; ok {
return &fleet.BadRequestError{
Message: "profiles managed by Fleet can't be deleted using this endpoint.",
InternalErr: fmt.Errorf("deleting profile %s is not allowed because it's managed by Fleet", decl.Name),
}
}
// TODO: refine our approach to deleting restricted/forbidden types of declarations so that we
// can check that Fleet-managed aren't being deleted; this can be addressed once we add support
// for more types of declarations
var d fleet.MDMAppleRawDeclaration
if err := json.Unmarshal(decl.RawJSON, &d); err != nil {
return ctxerr.Wrap(ctx, err, "unmarshalling declaration")
}
if err := d.ValidateUserProvided(); err != nil {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
}
var teamName string
teamID := *decl.TeamID
if teamID >= 1 {
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
if err != nil {
return ctxerr.Wrap(ctx, err)
}
teamName = tm.Name
}
// now we can do a specific authz check based on team id of profile before we delete the profile
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: decl.TeamID}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err)
}
if err := svc.ds.DeleteMDMAppleConfigProfile(ctx, declUUID); err != nil {
return ctxerr.Wrap(ctx, err)
}
if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{teamID}, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
var (
actTeamID *uint
actTeamName *string
)
if teamID > 0 {
actTeamID = &teamID
actTeamName = &teamName
}
if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeDeletedDeclarationProfile{
TeamID: actTeamID,
TeamName: actTeamName,
ProfileName: decl.Name,
Identifier: decl.Identifier,
}); err != nil {
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm apple declaration")
}
return nil
}
type getMDMAppleFileVaultSummaryRequest struct {
TeamID *uint `query:"team_id,optional"`
}
@ -2225,7 +2454,6 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm
InstalledFromDEP: info.DEPAssignedToFleet,
MDMPlatform: fleet.MDMPlatformApple,
})
}
// TokenUpdate handles MDM [TokenUpdate][1] requests.
@ -2354,7 +2582,8 @@ func (svc *MDMAppleCheckinAndCommandService) UserAuthenticate(*mdm.Request, *mdm
// This method is executed after the request has been handled by nanomdm.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
func (svc *MDMAppleCheckinAndCommandService) DeclarativeManagement(*mdm.Request, *mdm.DeclarativeManagement) ([]byte, error) {
func (svc *MDMAppleCheckinAndCommandService) DeclarativeManagement(r *mdm.Request, dm *mdm.DeclarativeManagement) ([]byte, error) {
// DeclarativeManagement is handled by the MDMAppleDDMService.
return nil, nil
}
@ -2409,7 +2638,13 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.UDID, cmdResult.CommandUUID, requestType, cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
}
case "DeclarativeManagement":
// set "pending-install" profiles to "verifying"
err := svc.ds.MDMAppleSetDeclarationsAsVerifying(r.Context, cmdResult.UDID)
return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack")
}
return nil, nil
}
@ -2504,6 +2739,34 @@ func ensureFleetdConfig(ctx context.Context, ds fleet.Datastore, logger kitlog.L
return nil
}
func ReconcileAppleDeclarations(
ctx context.Context,
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
) error {
// batch set declarations as pending
changedHosts, err := ds.MDMAppleBatchSetHostDeclarationState(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating host declaration state")
}
if len(changedHosts) == 0 {
logger.Log("msg", "no hosts with changed declarations")
return nil
}
// send a DeclarativeManagement command to start a sync
if err := commander.DeclarativeManagement(ctx, changedHosts, uuid.NewString()); err != nil {
return ctxerr.Wrap(ctx, err, "issuing DeclarativeManagement command")
}
logger.Log("msg", "sent DeclarativeManagement command", "host_number", len(changedHosts))
return nil
}
func ReconcileAppleProfiles(
ctx context.Context,
ds fleet.Datastore,
@ -2946,3 +3209,231 @@ func RenewSCEPCertificates(
return nil
}
// MDMAppleDDMService is the service that handles MDM [DeclarativeManagement][1] requests.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
type MDMAppleDDMService struct {
ds fleet.Datastore
logger kitlog.Logger
}
func NewMDMAppleDDMService(ds fleet.Datastore, logger kitlog.Logger) *MDMAppleDDMService {
return &MDMAppleDDMService{
ds: ds,
logger: logger,
}
}
// DeclarativeManagement handles MDM [DeclarativeManagement][1] requests.
//
// This method is when the request has been handled by nanomdm.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
func (svc *MDMAppleDDMService) DeclarativeManagement(r *mdm.Request, dm *mdm.DeclarativeManagement) ([]byte, error) {
if dm == nil {
level.Debug(svc.logger).Log("msg", "ddm request received with nil payload")
return nil, nil
}
level.Debug(svc.logger).Log("msg", "ddm request received", "endpoint", dm.Endpoint)
if err := svc.ds.InsertMDMAppleDDMRequest(r.Context, dm.UDID, dm.Endpoint, string(dm.Data)); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "insert ddm request history")
}
if dm.UDID == "" {
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(r.Context, "missing UDID in request"))
}
switch {
case dm.Endpoint == "tokens":
level.Debug(svc.logger).Log("msg", "received tokens request")
return svc.handleTokens(r.Context, dm.UDID)
case dm.Endpoint == "declaration-items":
level.Debug(svc.logger).Log("msg", "received declaration-items request")
return svc.handleDeclarationItems(r.Context, dm.UDID)
case dm.Endpoint == "status":
level.Debug(svc.logger).Log("msg", "received status request")
return nil, svc.handleDeclarationStatus(r.Context, dm)
case strings.HasPrefix(dm.Endpoint, "declaration/"):
level.Debug(svc.logger).Log("msg", "received declarations request")
return svc.handleDeclarationsResponse(r.Context, dm.Endpoint, dm.UDID)
default:
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(r.Context, fmt.Sprintf("unrecognized declarations endpoint: %s", dm.Endpoint)))
}
}
func (svc *MDMAppleDDMService) handleTokens(ctx context.Context, hostUUID string) ([]byte, error) {
tok, err := svc.ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting synchronization tokens")
}
b, err := json.Marshal(fleet.MDMAppleDDMTokensResponse{
SyncTokens: *tok,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshaling synchronization tokens")
}
return b, nil
}
func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostUUID string) ([]byte, error) {
di, err := svc.ds.MDMAppleDDMDeclarationItems(ctx, hostUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting synchronization tokens")
}
activations := []fleet.MDMAppleDDMManifest{}
configurations := []fleet.MDMAppleDDMManifest{}
for _, d := range di {
configurations = append(configurations, fleet.MDMAppleDDMManifest(d))
activations = append(activations, fleet.MDMAppleDDMManifest{
Identifier: fmt.Sprintf("%s.activation", d.Identifier),
ServerToken: d.ServerToken,
})
}
// TODO: Look for ways to optimize the declaration item query so that we don't have to get the declarations token separately.
dTok, err := svc.ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting declarations token")
}
b, err := json.Marshal(fleet.MDMAppleDDMDeclarationItemsResponse{
Declarations: fleet.MDMAppleDDMManifestItems{
Activations: activations,
Configurations: configurations,
Assets: []fleet.MDMAppleDDMManifest{},
Management: []fleet.MDMAppleDDMManifest{},
},
DeclarationsToken: dTok.DeclarationsToken,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshaling synchronization tokens")
}
return b, nil
}
func (svc *MDMAppleDDMService) handleDeclarationsResponse(ctx context.Context, endpoint string, hostUUID string) ([]byte, error) {
parts := strings.Split(endpoint, "/")
if len(parts) != 3 {
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(ctx, fmt.Sprintf("unrecognized declarations endpoint: %s", endpoint)))
}
level.Debug(svc.logger).Log("msg", "parsed declarations request", "type", parts[1], "identifier", parts[2])
switch parts[1] {
case "activation":
return svc.handleActivationDeclaration(ctx, parts, hostUUID)
case "configuration":
return svc.handleConfigurationDeclaration(ctx, parts, hostUUID)
default:
return nil, newNotFoundError()
}
}
func (svc *MDMAppleDDMService) handleActivationDeclaration(ctx context.Context, parts []string, hostUUID string) ([]byte, error) {
references := strings.TrimSuffix(parts[2], ".activation")
// ensure the declaration for the requested activation stil exists
d, err := svc.ds.MDMAppleDDMDeclarationsResponse(ctx, references, hostUUID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
}
return nil, ctxerr.Wrap(ctx, err, "getting linked configuration for activation declaration")
}
response := fmt.Sprintf(`
{
"Identifier": "%s",
"Payload": {
"StandardConfigurations": ["%s"]
},
"ServerToken": "%s",
"Type": "com.apple.activation.simple"
}`, parts[2], references, d.Checksum)
return []byte(response), nil
}
func (svc *MDMAppleDDMService) handleConfigurationDeclaration(ctx context.Context, parts []string, hostUUID string) ([]byte, error) {
d, err := svc.ds.MDMAppleDDMDeclarationsResponse(ctx, parts[2], hostUUID)
if err != nil {
if fleet.IsNotFound(err) {
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
}
return nil, ctxerr.Wrap(ctx, err, "getting declaration response")
}
var tempd map[string]any
if err := json.Unmarshal(d.RawJSON, &tempd); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration")
}
tempd["ServerToken"] = d.Checksum
b, err := json.Marshal(tempd)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "marshaling declaration")
}
return b, nil
}
func (svc *MDMAppleDDMService) handleDeclarationStatus(ctx context.Context, dm *mdm.DeclarativeManagement) error {
var status fleet.MDMAppleDDMStatusReport
if err := json.Unmarshal(dm.Data, &status); err != nil {
return ctxerr.Wrap(ctx, err, "unmarshalling response")
}
configurationReports := status.StatusItems.Management.Declarations.Configurations
updates := make([]*fleet.MDMAppleHostDeclaration, len(configurationReports))
for i, r := range configurationReports {
var status fleet.MDMDeliveryStatus
var detail string
switch {
case r.Active && r.Valid == fleet.MDMAppleDeclarationValid:
status = fleet.MDMDeliveryVerified
case r.Valid == fleet.MDMAppleDeclarationInvalid:
status = fleet.MDMDeliveryFailed
detail = apple_mdm.FmtDDMError(r.Reasons)
default:
status = fleet.MDMDeliveryVerifying
}
updates[i] = &fleet.MDMAppleHostDeclaration{
Status: &status,
OperationType: fleet.MDMOperationTypeInstall,
Detail: detail,
Checksum: r.ServerToken,
}
}
if len(updates) == 0 {
return nil
}
// MDMAppleStoreDDMStatusReport takes care of cleaning ("pending", "remove")
// pairs for the host.
//
// TODO(roberto): in the DDM documentation, it's mentioned that status
// report will give you a "remove" status so the server can track
// removals. In my testing, I never saw this (after spending
// considerable time trying to make it work.)
//
// My current guess is that the documentation is implicitly referring
// to asset declarations (which deliver tangible "assets" to the host)
//
// The best indication I found so far, is that if the declaration is
// not in the report, then it's implicitly removed.
if err := svc.ds.MDMAppleStoreDDMStatusReport(ctx, dm.UDID, updates); err != nil {
return ctxerr.Wrap(ctx, err, "updating host declaration status with reports")
}
return nil
}

View file

@ -2727,6 +2727,19 @@ func mobileconfigForTest(name, identifier string) []byte {
`, name, identifier, uuid.New().String()))
}
func declBytesForTest(identifier string, payloadContent string) []byte {
tmpl := `{
"Type": "com.apple.configuration.decl%s",
"Identifier": "com.fleet.config%s",
"Payload": {
"ServiceType": "com.apple.service%s"
}
}`
declBytes := []byte(fmt.Sprintf(tmpl, identifier, identifier, payloadContent))
return declBytes
}
func mobileconfigForTestWithContent(outerName, outerIdentifier, innerIdentifier, innerType, innerName string) []byte {
if innerName == "" {
innerName = outerName + ".inner"

View file

@ -294,8 +294,9 @@ func getProfilesContents(baseDir string, profiles []fleet.MDMProfileSpec) ([]fle
}
// by default, use the file name. macOS profiles use their PayloadDisplayName
name := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
if mdm.GetRawProfilePlatform(fileContents) == "darwin" {
ext := filepath.Ext(filePath)
name := strings.TrimSuffix(filepath.Base(filePath), ext)
if mdm.GetRawProfilePlatform(fileContents) == "darwin" && ext == ".mobileconfig" {
mc, err := fleet.NewMDMAppleConfigProfile(fileContents, nil)
if err != nil {
return nil, fmt.Errorf("applying fleet config: %w", err)

View file

@ -1003,6 +1003,7 @@ func RegisterAppleMDMProtocolServices(
scepStorage scep_depot.Depot,
logger kitlog.Logger,
checkinAndCommandService nanomdm_service.CheckinAndCommandService,
ddmService nanomdm_service.DeclarativeManagement,
) error {
scepCACerts, scepCAKey, err := scepStorage.CA([]byte{})
if err != nil {
@ -1011,7 +1012,7 @@ func RegisterAppleMDMProtocolServices(
if err := registerSCEP(mux, scepConfig, scepCACerts[0], scepCAKey, scepStorage, logger); err != nil {
return fmt.Errorf("scep: %w", err)
}
if err := registerMDM(mux, scepCACerts[0], mdmStorage, checkinAndCommandService, logger); err != nil {
if err := registerMDM(mux, scepCACerts[0], mdmStorage, checkinAndCommandService, ddmService, logger); err != nil {
return fmt.Errorf("mdm: %w", err)
}
return nil
@ -1085,6 +1086,7 @@ func registerMDM(
scepCACert *x509.Certificate,
mdmStorage nanomdm_storage.AllStorage,
checkinAndCommandService nanomdm_service.CheckinAndCommandService,
ddmService nanomdm_service.DeclarativeManagement,
logger kitlog.Logger,
) error {
certVerifier, err := certverify.NewPoolVerifier(
@ -1104,7 +1106,7 @@ func registerMDM(
// enrollments and updates the Fleet hosts table accordingly with the UDID and serial number of
// the device.
// 5. Run actual MDM service operation (checkin handler or command and results handler).
coreMDMService := nanomdm.New(mdmStorage, nanomdm.WithLogger(mdmLogger))
coreMDMService := nanomdm.New(mdmStorage, nanomdm.WithLogger(mdmLogger), nanomdm.WithDeclarativeManagement(ddmService))
// NOTE: it is critical that the coreMDMService runs first, as the first
// service in the multi-service feature is run to completion _before_ running
// the other ones in parallel. This way, subsequent services have access to

View file

@ -0,0 +1,909 @@
package service
import (
"bytes"
"context"
"crypto/md5" // nolint:gosec // used only for tests
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func (s *integrationMDMTestSuite) TestAppleDDMBatchUpload() {
t := s.T()
tmpl := `
{
"Type": "com.apple.configuration.decl%d",
"Identifier": "com.fleet.config%d",
"Payload": {
"ServiceType": "com.apple.bash",
"DataAssetReference": "com.fleet.asset.bash" %s
}
}`
// TODO: figure out the best way to do this. We might even consider
// starting a different test suite.
t.Cleanup(func() { s.cleanupDeclarations(t) })
newDeclBytes := func(i int, payload ...string) []byte {
var p string
if len(payload) > 0 {
p = "," + strings.Join(payload, ",")
}
return []byte(fmt.Sprintf(tmpl, i, i, p))
}
var decls [][]byte
for i := 0; i < 7; i++ {
decls = append(decls, newDeclBytes(i))
}
// Non-configuration type should fail
res := s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "bad", Contents: []byte(`{"Type": "com.apple.activation"}`)},
}}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Only configuration declarations (com.apple.configuration) are supported")
// "com.apple.configuration.softwareupdate.enforcement.specific" type should fail
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "bad2", Contents: []byte(`{"Type": "com.apple.configuration.softwareupdate.enforcement.specific"}`)},
}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Declaration profile cant include OS updates settings. To control these settings, go to OS updates.")
// Types from our list of forbidden types should fail
for ft := range fleet.ForbiddenDeclTypes {
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "bad2", Contents: []byte(fmt.Sprintf(`{"Type": "%s"}`, ft))},
}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Only configuration declarations that dont require an asset reference are supported.")
}
// "com.apple.configuration.management.status-subscriptions" type should fail
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "bad2", Contents: []byte(`{"Type": "com.apple.configuration.management.status-subscriptions"}`)},
}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Declaration profile cant include status subscription type. To get hosts vitals, please use queries and policies.")
// Two different payloads with the same name should fail
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "bad2", Contents: newDeclBytes(1, `"foo": "bar"`)},
{Name: "bad2", Contents: newDeclBytes(2, `"baz": "bing"`)},
}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A declaration profile with this name already exists.")
// Same identifier should fail
res = s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: decls[0]},
{Name: "N2", Contents: decls[0]},
}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "A declaration profile with this identifier already exists.")
// Create 2 declarations
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: decls[0]},
{Name: "N2", Contents: decls[1]},
}}, http.StatusNoContent)
var resp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
require.Len(t, resp.Profiles, 2)
require.Equal(t, "N1", resp.Profiles[0].Name)
require.Equal(t, "darwin", resp.Profiles[0].Platform)
require.Equal(t, "N2", resp.Profiles[1].Name)
require.Equal(t, "darwin", resp.Profiles[1].Platform)
// Create 2 new declarations. These should take the place of the first two.
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: decls[2]},
{Name: "N4", Contents: decls[3]},
}}, http.StatusNoContent)
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
require.Len(t, resp.Profiles, 2)
require.Equal(t, "N3", resp.Profiles[0].Name)
require.Equal(t, "darwin", resp.Profiles[0].Platform)
require.Equal(t, "N4", resp.Profiles[1].Name)
require.Equal(t, "darwin", resp.Profiles[1].Platform)
// replace only 1 declaration, the other one should be the same
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: decls[2]},
{Name: "N5", Contents: decls[4]},
}}, http.StatusNoContent)
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
require.Len(t, resp.Profiles, 2)
require.Equal(t, "N3", resp.Profiles[0].Name)
require.Equal(t, "darwin", resp.Profiles[0].Platform)
require.Equal(t, "N5", resp.Profiles[1].Name)
require.Equal(t, "darwin", resp.Profiles[1].Platform)
// update the declarations
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: newDeclBytes(2, `"foo": "bar"`)},
{Name: "N5", Contents: newDeclBytes(4, `"bing": "baz"`)},
}}, http.StatusNoContent)
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
require.Len(t, resp.Profiles, 2)
require.Equal(t, "N3", resp.Profiles[0].Name)
require.Equal(t, "darwin", resp.Profiles[0].Platform)
require.Equal(t, "N5", resp.Profiles[1].Name)
require.Equal(t, "darwin", resp.Profiles[1].Platform)
var createResp createLabelResponse
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String("label_1"), Query: ptr.String("select 1")}, http.StatusOK, &createResp)
require.NotZero(t, createResp.Label.ID)
require.Equal(t, "label_1", createResp.Label.Name)
lbl1 := createResp.Label.Label
s.DoJSON("POST", "/api/latest/fleet/labels", &fleet.LabelPayload{Name: ptr.String("label_2"), Query: ptr.String("select 1")}, http.StatusOK, &createResp)
require.NotZero(t, createResp.Label.ID)
require.Equal(t, "label_2", createResp.Label.Name)
lbl2 := createResp.Label.Label
// Add with labels
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}},
}}, http.StatusNoContent)
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &resp)
require.Len(t, resp.Profiles, 2)
require.Equal(t, "N5", resp.Profiles[0].Name)
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)
}
func (s *integrationMDMTestSuite) TestMDMAppleDeviceManagementRequests() {
t := s.T()
_, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
calcChecksum := func(source []byte) string {
csum := fmt.Sprintf("%x", md5.Sum(source)) //nolint:gosec
return strings.ToUpper(csum)
}
t.Cleanup(func() { s.cleanupDeclarations(t) })
insertDeclaration := func(t *testing.T, decl fleet.MDMAppleDeclaration) {
stmt := `
INSERT INTO mdm_apple_declarations (
declaration_uuid,
team_id,
identifier,
name,
raw_json,
checksum,
created_at,
uploaded_at
) VALUES (?,?,?,?,?,UNHEX(?),?,?)`
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), stmt,
decl.DeclarationUUID,
decl.TeamID,
decl.Identifier,
decl.Name,
decl.RawJSON,
calcChecksum(decl.RawJSON),
decl.CreatedAt,
decl.UploadedAt,
)
return err
})
}
insertHostDeclaration := func(t *testing.T, hostUUID string, decl fleet.MDMAppleDeclaration) {
stmt := `
INSERT INTO host_mdm_apple_declarations (
host_uuid,
status,
operation_type,
checksum,
declaration_uuid
) VALUES (?,?,?,UNHEX(?),?)`
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), stmt,
hostUUID,
fleet.MDMDeliveryPending,
fleet.MDMOperationTypeInstall,
calcChecksum(decl.RawJSON),
decl.DeclarationUUID,
)
return err
})
}
// initialize a time to use for our first declaration, subsequent declarations will be
// incremented by a minute
then := time.Now().UTC().Truncate(time.Second).Add(-1 * time.Hour)
// insert a declaration with no team
noTeamDeclsByUUID := map[string]fleet.MDMAppleDeclaration{
"123": {
DeclarationUUID: "123",
TeamID: ptr.Uint(0),
Identifier: "com.example",
Name: "Example",
RawJSON: json.RawMessage(`{
"Type": "com.apple.configuration.declaration-items.test",
"Payload": {"foo":"bar"},
"Identifier": "com.example"
}`),
CreatedAt: then,
UploadedAt: then,
},
}
insertDeclaration(t, noTeamDeclsByUUID["123"])
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["123"])
mapDeclsByChecksum := func(byUUID map[string]fleet.MDMAppleDeclaration) map[string]fleet.MDMAppleDeclaration {
byChecksum := make(map[string]fleet.MDMAppleDeclaration)
for _, d := range byUUID {
byChecksum[calcChecksum(d.RawJSON)] = byUUID[d.DeclarationUUID]
}
return byChecksum
}
parseTokensResp := func(r *http.Response) fleet.MDMAppleDDMTokensResponse {
require.NotNil(t, r)
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
r.Body = io.NopCloser(bytes.NewBuffer(b))
// t.Log("body", string(b))
// unmarsal the response to make sure it's valid
var tok fleet.MDMAppleDDMTokensResponse
err = json.NewDecoder(r.Body).Decode(&tok)
require.NoError(t, err)
// t.Log("decoded", tok)
return tok
}
parseDeclarationItemsResp := func(r *http.Response) fleet.MDMAppleDDMDeclarationItemsResponse {
require.NotNil(t, r)
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
defer r.Body.Close()
r.Body = io.NopCloser(bytes.NewBuffer(b))
// t.Log("body", string(b))
// unmarsal the response to make sure it's valid
var di fleet.MDMAppleDDMDeclarationItemsResponse
err = json.NewDecoder(r.Body).Decode(&di)
require.NoError(t, err)
// t.Log("decoded", di)
return di
}
assertDeclarationResponse := func(r *http.Response, expected fleet.MDMAppleDeclaration) {
require.NotNil(t, r)
// unmarsal the response and assert it's valid
var wantParsed fleet.MDMAppleDDMDeclarationResponse
require.NoError(t, json.Unmarshal(expected.RawJSON, &wantParsed))
var gotParsed fleet.MDMAppleDDMDeclarationResponse
require.NoError(t, json.NewDecoder(r.Body).Decode(&gotParsed))
require.EqualValues(t, wantParsed.Payload, gotParsed.Payload)
require.Equal(t, calcChecksum(expected.RawJSON), gotParsed.ServerToken)
require.Equal(t, expected.Identifier, gotParsed.Identifier)
// t.Logf("decoded: %+v", gotParsed)
}
checkTokensResp := func(t *testing.T, r fleet.MDMAppleDDMTokensResponse, expectedTimestamp time.Time, prevToken string) {
require.Equal(t, expectedTimestamp, r.SyncTokens.Timestamp)
require.NotEmpty(t, r.SyncTokens.DeclarationsToken)
require.NotEqual(t, prevToken, r.SyncTokens.DeclarationsToken)
}
checkDeclarationItemsResp := func(t *testing.T, r fleet.MDMAppleDDMDeclarationItemsResponse, expectedDeclTok string, expectedDeclsByChecksum map[string]fleet.MDMAppleDeclaration) {
require.Equal(t, expectedDeclTok, r.DeclarationsToken)
// TODO(roberto): better assertions
require.NotEmpty(t, r.Declarations.Activations)
require.Empty(t, r.Declarations.Assets)
require.Empty(t, r.Declarations.Management)
require.Len(t, r.Declarations.Configurations, len(expectedDeclsByChecksum))
for _, m := range r.Declarations.Configurations {
d, ok := expectedDeclsByChecksum[m.ServerToken]
require.True(t, ok)
require.Equal(t, d.Identifier, m.Identifier)
}
}
checkRequestsDatabase := func(t *testing.T, messageType, enrollmentID string, expectedCount int) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var count int
if err := sqlx.GetContext(
context.Background(),
q,
&count,
"SELECT count(*) AS count FROM mdm_apple_declarative_requests WHERE enrollment_id = ? AND message_type = ?",
enrollmentID,
messageType,
); err != nil {
return err
}
require.Equal(t, expectedCount, count, "unexpected db row count for declaration requests")
return nil
})
}
var currDeclToken string // we'll use this to track the expected token across tests
t.Run("Tokens", func(t *testing.T) {
checkRequestsDatabase(t, "tokens", mdmDevice.UUID, 0)
// get tokens, timestamp should be the same as the declaration and token should be non-empty
r, err := mdmDevice.DeclarativeManagement("tokens")
require.NoError(t, err)
parsed := parseTokensResp(r)
checkTokensResp(t, parsed, then, "")
currDeclToken = parsed.SyncTokens.DeclarationsToken
// insert a new declaration
noTeamDeclsByUUID["456"] = fleet.MDMAppleDeclaration{
DeclarationUUID: "456",
TeamID: ptr.Uint(0),
Identifier: "com.example2",
Name: "Example2",
RawJSON: json.RawMessage(`{
"Type": "com.apple.configuration.declaration-items.test",
"Payload": {"foo":"baz"},
"Identifier": "com.example2"
}`),
CreatedAt: then.Add(1 * time.Minute),
UploadedAt: then.Add(1 * time.Minute),
}
insertDeclaration(t, noTeamDeclsByUUID["456"])
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["456"])
checkRequestsDatabase(t, "tokens", mdmDevice.UUID, 1)
// get tokens again, timestamp and token should have changed
r, err = mdmDevice.DeclarativeManagement("tokens")
require.NoError(t, err)
parsed = parseTokensResp(r)
checkTokensResp(t, parsed, then.Add(1*time.Minute), currDeclToken)
currDeclToken = parsed.SyncTokens.DeclarationsToken
checkRequestsDatabase(t, "tokens", mdmDevice.UUID, 2)
})
t.Run("DeclarationItems", func(t *testing.T) {
checkRequestsDatabase(t, "declaration-items", mdmDevice.UUID, 0)
r, err := mdmDevice.DeclarativeManagement("declaration-items")
require.NoError(t, err)
checkDeclarationItemsResp(t, parseDeclarationItemsResp(r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
// insert a new declaration
noTeamDeclsByUUID["789"] = fleet.MDMAppleDeclaration{
DeclarationUUID: "789",
TeamID: ptr.Uint(0),
Identifier: "com.example3",
Name: "Example3",
RawJSON: json.RawMessage(`{
"Type": "com.apple.configuration.declaration-items.test",
"Payload": {"foo":"bang"},
"Identifier": "com.example3"
}`),
CreatedAt: then.Add(2 * time.Minute),
UploadedAt: then.Add(2 * time.Minute),
}
insertDeclaration(t, noTeamDeclsByUUID["789"])
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["789"])
checkRequestsDatabase(t, "declaration-items", mdmDevice.UUID, 1)
// get tokens again, timestamp and token should have changed
r, err = mdmDevice.DeclarativeManagement("tokens")
require.NoError(t, err)
toks := parseTokensResp(r)
checkTokensResp(t, toks, then.Add(2*time.Minute), currDeclToken)
currDeclToken = toks.SyncTokens.DeclarationsToken
r, err = mdmDevice.DeclarativeManagement("declaration-items")
require.NoError(t, err)
checkDeclarationItemsResp(t, parseDeclarationItemsResp(r), currDeclToken, mapDeclsByChecksum(noTeamDeclsByUUID))
checkRequestsDatabase(t, "declaration-items", mdmDevice.UUID, 2)
})
t.Run("Status", func(t *testing.T) {
checkRequestsDatabase(t, "status", mdmDevice.UUID, 0)
_, err := mdmDevice.DeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
require.NoError(t, err)
checkRequestsDatabase(t, "status", mdmDevice.UUID, 1)
})
t.Run("Declaration", func(t *testing.T) {
want := noTeamDeclsByUUID["123"]
declarationPath := fmt.Sprintf("declaration/%s/%s", "configuration", want.Identifier)
checkRequestsDatabase(t, declarationPath, mdmDevice.UUID, 0)
r, err := mdmDevice.DeclarativeManagement(declarationPath)
require.NoError(t, err)
assertDeclarationResponse(r, want)
// insert a new declaration
noTeamDeclsByUUID["abc"] = fleet.MDMAppleDeclaration{
DeclarationUUID: "abc",
TeamID: ptr.Uint(0),
Identifier: "com.example4",
Name: "Example4",
RawJSON: json.RawMessage(`{
"Type": "com.apple.configuration.test",
"Payload": {"foo":"bar"},
"Identifier": "com.example4"
}`),
CreatedAt: then.Add(3 * time.Minute),
UploadedAt: then.Add(3 * time.Minute),
}
insertDeclaration(t, noTeamDeclsByUUID["abc"])
insertHostDeclaration(t, mdmDevice.UUID, noTeamDeclsByUUID["abc"])
want = noTeamDeclsByUUID["abc"]
r, err = mdmDevice.DeclarativeManagement(fmt.Sprintf("declaration/%s/%s", "configuration", want.Identifier))
require.NoError(t, err)
checkRequestsDatabase(t, declarationPath, mdmDevice.UUID, 1)
// try getting a non-existent declaration, should fail 404
nonExistantDeclarationPath := fmt.Sprintf("declaration/%s/%s", "configuration", "nonexistent")
checkRequestsDatabase(t, nonExistantDeclarationPath, mdmDevice.UUID, 0)
_, err = mdmDevice.DeclarativeManagement(nonExistantDeclarationPath)
require.Error(t, err)
require.ErrorContains(t, err, "404 Not Found")
checkRequestsDatabase(t, nonExistantDeclarationPath, mdmDevice.UUID, 1)
// typo should fail as bad request
typoDeclarationPath := fmt.Sprintf("declarations/%s/%s", "configurations", want.Identifier)
checkRequestsDatabase(t, typoDeclarationPath, mdmDevice.UUID, 0)
_, err = mdmDevice.DeclarativeManagement(typoDeclarationPath)
require.Error(t, err)
require.ErrorContains(t, err, "400 Bad Request")
checkRequestsDatabase(t, typoDeclarationPath, mdmDevice.UUID, 1)
assertDeclarationResponse(r, want)
})
}
func (s *integrationMDMTestSuite) TestAppleDDMReconciliation() {
t := s.T()
ctx := context.Background()
// TODO: use config logger or take into account FLEET_INTEGRATION_TESTS_DISABLE_LOG
logger := kitlog.NewJSONLogger(os.Stdout)
// TODO: use endpoints once those are available.
addDeclaration := func(identifier string, teamID uint) {
stmt := `
INSERT INTO mdm_apple_declarations
(declaration_uuid, team_id, identifier, name, raw_json, checksum)
VALUES
(UUID(), ?, ?, UUID(), ?, HEX(MD5(raw_json)) )`
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, stmt, teamID, identifier, declarationForTest(identifier))
return err
})
}
deleteDeclaration := func(identifier string, teamID uint) {
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM mdm_apple_declarations WHERE team_id = ? AND identifier = ?", teamID, identifier)
return err
})
}
// create a team
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
// TODO: figure out the best way to do this. We might even consider
// starting a different test suite.
t.Cleanup(func() { s.cleanupDeclarations(t) })
checkNoCommands := func(d *mdmtest.TestAppleMDMClient) {
cmd, err := d.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
}
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
cmd, err := d.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
cmd, err = d.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd)
_, err = d.DeclarativeManagement("tokens")
require.NoError(t, err)
}
// create a windows host
_, err := s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("non-macos-host"),
NodeKey: ptr.String("non-macos-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
Platform: "windows",
})
require.NoError(t, err)
// create a windows host that's enrolled in MDM
_, _ = createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// create a linux host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("linux-host"),
NodeKey: ptr.String("linux-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.linux", t.Name()),
Platform: "linux",
})
require.NoError(t, err)
// create a host that's not enrolled into MDM
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// create a host and then enroll in MDM.
mdmHost, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// trigger the reconciler, no error
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// declarativeManagement command is not sent.
checkNoCommands(device)
// add global declarations
addDeclaration("I1", 0)
addDeclaration("I2", 0)
// reconcile again, this time new declarations were added
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// TODO: check command is pending
// declarativeManagement command is sent
checkDDMSync(device)
// reconcile again, commands for the uploaded declarations are already sent
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// no new commands are sent
checkNoCommands(device)
// delete a declaration
deleteDeclaration("I1", 0)
// reconcile again
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// a DDM sync is triggered
checkDDMSync(device)
// add a new host
_, deviceTwo := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// reconcile again
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// DDM sync is triggered only for the new host
checkNoCommands(device)
checkDDMSync(deviceTwo)
// add device to the team
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{mdmHost.ID}}, http.StatusOK)
// reconcile
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// DDM sync is triggered only for the transferred host
// because the team doesn't have any declarations
checkDDMSync(device)
checkNoCommands(deviceTwo)
// reconcile
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// nobody receives commands this time
checkNoCommands(device)
checkNoCommands(deviceTwo)
// add declarations to the team
addDeclaration("I1", team.ID)
addDeclaration("I2", team.ID)
// reconcile
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// DDM sync is triggered for the host in the team
checkDDMSync(device)
checkNoCommands(deviceTwo)
// add a new host, this one belongs to the team
mdmHostThree, deviceThree := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &team.ID, HostIDs: []uint{mdmHostThree.ID}}, http.StatusOK)
// reconcile
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// DDM sync is triggered only for the new host
checkNoCommands(device)
checkNoCommands(deviceTwo)
checkDDMSync(deviceThree)
// no new commands after another reconciliation
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
checkNoCommands(device)
checkNoCommands(deviceTwo)
checkNoCommands(deviceThree)
// TODO: use proper APIs for this
// add a new label + label declaration
addDeclaration("I3", team.ID)
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: t.Name(), Query: "select 1;"})
require.NoError(t, err)
// update label with host membership
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
mdmHostThree.ID,
label.ID,
)
return err
},
)
// update declaration <-> label mapping
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
`INSERT INTO
mdm_declaration_labels (apple_declaration_uuid, label_name, label_id)
VALUES ((SELECT declaration_uuid FROM mdm_apple_declarations WHERE team_id = ? and identifier = ?), ?, ?)`,
team.ID,
"I3",
label.Name,
label.ID,
)
return err
},
)
// reconcile
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// DDM sync is triggered only for the host with the label
checkNoCommands(device)
checkNoCommands(deviceTwo)
checkDDMSync(deviceThree)
}
func (s *integrationMDMTestSuite) TestAppleDDMStatusReport() {
t := s.T()
ctx := context.Background()
// TODO: use config logger or take into account FLEET_INTEGRATION_TESTS_DISABLE_LOG
logger := kitlog.NewJSONLogger(os.Stdout)
// TODO: figure out the best way to do this. We might even consider
// starting a different test suite.
t.Cleanup(func() { s.cleanupDeclarations(t) })
assertHostDeclarations := func(hostUUID string, wantDecls []*fleet.MDMAppleHostDeclaration) {
var gotDecls []*fleet.MDMAppleHostDeclaration
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(context.Background(), q, &gotDecls, `SELECT declaration_identifier, status, operation_type FROM host_mdm_apple_declarations WHERE host_uuid = ?`, hostUUID)
})
require.ElementsMatch(t, wantDecls, gotDecls)
}
// create a host and then enroll in MDM.
mdmHost, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
declarations := []fleet.MDMProfileBatchPayload{
{Name: "N1.json", Contents: declarationForTest("I1")},
{Name: "N2.json", Contents: declarationForTest("I2")},
}
// add global declarations
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
// reconcile profiles
err := ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
// declarations are ("install", "pending") after the cron run
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
{Identifier: "I2", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeInstall},
})
// host gets a DDM sync call
cmd, err := device.Idle()
require.NoError(t, err)
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
_, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
r, err := device.DeclarativeManagement("declaration-items")
require.NoError(t, err)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var items fleet.MDMAppleDDMDeclarationItemsResponse
require.NoError(t, json.Unmarshal(body, &items))
var i1ServerToken, i2ServerToken string
for _, d := range items.Declarations.Configurations {
switch d.Identifier {
case "I1":
i1ServerToken = d.ServerToken
case "I2":
i2ServerToken = d.ServerToken
}
}
// declarations are ("install", "verifying") after the ack
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
{Identifier: "I2", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
})
// host sends a partial DDM report
report := fleet.MDMAppleDDMStatusReport{}
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
}
_, err = device.DeclarativeManagement("status", report)
require.NoError(t, err)
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
{Identifier: "I2", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
})
// host sends a report with a wrong (could be old) server token for I2, nothing changes
report = fleet.MDMAppleDDMStatusReport{}
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I2", ServerToken: "foo"},
}
_, err = device.DeclarativeManagement("status", report)
require.NoError(t, err)
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
{Identifier: "I2", Status: &fleet.MDMDeliveryVerifying, OperationType: fleet.MDMOperationTypeInstall},
})
// host sends a full report, declaration I2 is invalid
report = fleet.MDMAppleDDMStatusReport{}
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
{Active: false, Valid: fleet.MDMAppleDeclarationInvalid, Identifier: "I2", ServerToken: i2ServerToken},
}
_, err = device.DeclarativeManagement("status", report)
require.NoError(t, err)
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
{Identifier: "I2", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
})
// do a batch request, this time I2 is deleted
declarations = []fleet.MDMProfileBatchPayload{
{Name: "N1.json", Contents: declarationForTest("I1")},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: declarations}, http.StatusNoContent)
// reconcile profiles
err = ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, logger)
require.NoError(t, err)
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
{Identifier: "I2", Status: &fleet.MDMDeliveryPending, OperationType: fleet.MDMOperationTypeRemove},
})
// host sends a report, declaration I2 is removed from the hosts_* table
report = fleet.MDMAppleDDMStatusReport{}
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
{Active: true, Valid: fleet.MDMAppleDeclarationValid, Identifier: "I1", ServerToken: i1ServerToken},
}
_, err = device.DeclarativeManagement("status", report)
require.NoError(t, err)
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryVerified, OperationType: fleet.MDMOperationTypeInstall},
})
// host sends a report, declaration I1 is failing after a while
report = fleet.MDMAppleDDMStatusReport{}
report.StatusItems.Management.Declarations.Configurations = []fleet.MDMAppleDDMStatusDeclaration{
{Active: false, Valid: fleet.MDMAppleDeclarationInvalid, Identifier: "I1", ServerToken: i1ServerToken},
}
_, err = device.DeclarativeManagement("status", report)
require.NoError(t, err)
assertHostDeclarations(mdmHost.UUID, []*fleet.MDMAppleHostDeclaration{
{Identifier: "I1", Status: &fleet.MDMDeliveryFailed, OperationType: fleet.MDMOperationTypeInstall},
})
}
func declarationForTest(identifier string) []byte {
return []byte(fmt.Sprintf(`
{
"Type": "com.apple.configuration.management.test",
"Payload": {
"Echo": "foo"
},
"Identifier": "%s"
}`, identifier))
}
func (s *integrationMDMTestSuite) cleanupDeclarations(t *testing.T) {
ctx := context.Background()
// TODO: figure out the best way to do this. We might even consider
// starting a different test suite.
// delete declarations to not affect other tests
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM mdm_apple_declarations")
return err
})
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, "DELETE FROM host_mdm_apple_declarations")
return err
})
}

View file

@ -25,6 +25,7 @@ import (
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/log"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/stretchr/testify/require"
@ -264,7 +265,9 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
//default:
// fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType)
//}
cmds = append(cmds, cmd)
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmds = append(cmds, &fullCmd)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
@ -327,7 +330,9 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
cmds = append(cmds, cmd)
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
cmds = append(cmds, &fullCmd)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
@ -374,12 +379,14 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
if cmd.Command.RequestType == "InstallEnterpriseApplication" &&
cmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) {
fleetdCmd = cmd
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if fullCmd.Command.RequestType == "InstallEnterpriseApplication" &&
fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
strings.Contains(*fullCmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) {
fleetdCmd = &fullCmd
} else if cmd.Command.RequestType == "InstallProfile" {
installProfileCmd = cmd
installProfileCmd = &fullCmd
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)

View file

@ -1272,7 +1272,7 @@ func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
}
func checkNextPayloads(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, forceDeviceErr bool) ([][]byte, []string) {
var cmd *micromdm.CommandPayload
var cmd *mdm.Command
var err error
installs := [][]byte{}
removes := []string{}
@ -1297,11 +1297,13 @@ func checkNextPayloads(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, forc
break
}
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
switch cmd.Command.RequestType {
case "InstallProfile":
installs = append(installs, cmd.Command.InstallProfile.Payload)
installs = append(installs, fullCmd.Command.InstallProfile.Payload)
case "RemoveProfile":
removes = append(removes, cmd.Command.RemoveProfile.Identifier)
removes = append(removes, fullCmd.Command.RemoveProfile.Identifier)
}
}
@ -2048,9 +2050,14 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
mdmDeviceA := mdmtest.NewTestMDMClientAppleDirect(mdmEnrollInfo)
err := mdmDeviceA.Enroll()
require.NoError(t, err)
s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(),
fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple"}`, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), 0)
mdmDeviceB := mdmtest.NewTestMDMClientAppleDirect(mdmEnrollInfo)
err = mdmDeviceB.Enroll()
require.NoError(t, err)
s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(),
fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple"}`, mdmDeviceB.SerialNumber, mdmDeviceB.Model, mdmDeviceB.SerialNumber), 0)
// Find the ID of Fleet's MDM solution
var mdmID uint
@ -2079,23 +2086,6 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
}
}
// Activities are generated for each device
activities := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at")
require.GreaterOrEqual(t, len(activities.Activities), 2)
details := []*json.RawMessage{}
for _, activity := range activities.Activities {
if activity.Type == "mdm_enrolled" {
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
details = append(details, activity.Details)
}
}
require.Len(t, details, 2)
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple"}`, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), string(*details[len(details)-2]))
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple"}`, mdmDeviceB.SerialNumber, mdmDeviceB.Model, mdmDeviceB.SerialNumber), string(*details[len(details)-1]))
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
@ -2132,7 +2122,7 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
require.NoError(t, err)
// An activity is created
activities = listActivitiesResponse{}
activities := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities)
found := false
@ -2141,7 +2131,6 @@ func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
found = true
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
details = append(details, activity.Details)
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, mdmDeviceA.SerialNumber, mdmDeviceA.Model, mdmDeviceA.SerialNumber), string(*activity.Details))
}
}
@ -5168,9 +5157,13 @@ func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
cmd, err := d.device.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
// if the command is to install the bootstrap package
if manifest := cmd.Command.InstallEnterpriseApplication.Manifest; manifest != nil {
if manifest := fullCmd.Command.InstallEnterpriseApplication.Manifest; manifest != nil {
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.NotNil(t, manifest)
require.Equal(t, "software-package", (*manifest).ManifestItems[0].Assets[0].Kind)
wantURL, err := bp.URL(s.server.URL)
require.NoError(t, err)
@ -6867,7 +6860,7 @@ func (s *integrationMDMTestSuite) TestSSO() {
s.runWorker()
// ask for commands and verify that we get AccountConfiguration
var accCmd *micromdm.CommandPayload
var accCmd *mdm.Command
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
@ -6879,9 +6872,12 @@ func (s *integrationMDMTestSuite) TestSSO() {
}
require.NotNil(t, accCmd)
require.NotNil(t, accCmd.Command)
require.True(t, accCmd.Command.AccountConfiguration.LockPrimaryAccountInfo)
require.Equal(t, "SSO User 1", accCmd.Command.AccountConfiguration.PrimaryAccountFullName)
require.Equal(t, "sso_user", accCmd.Command.AccountConfiguration.PrimaryAccountUserName)
var fullAccCmd *micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(accCmd.Raw, &fullAccCmd))
require.True(t, fullAccCmd.Command.AccountConfiguration.LockPrimaryAccountInfo)
require.Equal(t, "SSO User 1", fullAccCmd.Command.AccountConfiguration.PrimaryAccountFullName)
require.Equal(t, "sso_user", fullAccCmd.Command.AccountConfiguration.PrimaryAccountUserName)
// report host details for the device
var hostResp getHostResponse
@ -8725,6 +8721,8 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
t.Cleanup(func() { s.cleanupDeclarations(t) })
assertAppleProfile := func(filename, name, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := map[string][]string{
"labels": labelNames,
@ -8750,6 +8748,39 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
require.Equal(t, "a", string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
assertAppleDeclaration := func(filename, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := map[string][]string{
"labels": labelNames,
}
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
bytes := []byte(fmt.Sprintf(`{
"Type": "com.apple.configuration.foo",
"Payload": {
"Echo": "f1337"
},
"Identifier": "%s"
}`, ident))
body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, fleet.MDMAppleDeclarationUUIDPrefix, string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
createAppleProfile := func(name, ident string, teamID uint, labelNames []string) string {
uid := assertAppleProfile(name+".mobileconfig", name, ident, teamID, labelNames, http.StatusOK, "")
@ -8764,6 +8795,20 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
return uid
}
createAppleDeclaration := func(name, ident string, teamID uint, labelNames []string) string {
uid := assertAppleDeclaration(name+".json", ident, teamID, labelNames, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "identifier": %q}`, name, ident)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "identifier": %q}`, teamID, testTeam.Name, name, ident)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedDeclarationProfile{}.ActivityName(), wantJSON, 0)
return uid
}
assertWindowsProfile := func(filename, locURI string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := map[string][]string{
"labels": labelNames,
@ -8831,9 +8876,27 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
// but no conflict for no-team
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "")
// add some macOS declarations
createAppleDeclaration("apple-declaration", "test-declaration-ident", 0, nil)
// identifier must be unique, it conflicts with existing declaration
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", 0, nil, http.StatusConflict, "test-declaration-ident already exists")
// name is pulled from filename, it conflicts with existing declaration
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "apple-declaration already exists")
// uniqueness is checked only within team, so it's fine to have the same name and identifier in different teams
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", testTeam.ID, nil, http.StatusOK, "")
// name is pulled from filename, it conflicts with existing macOS config profile
assertAppleDeclaration("apple-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "apple-global-profile already exists")
// name is pulled from filename, it conflicts with existing macOS config profile
assertAppleDeclaration("win-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "win-global-profile already exists")
// windows profile name conflicts with existing declaration
assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
// macOS profile name conflicts with existing declaration
assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
// not an xml nor mobileconfig file
assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.")
assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't upload. The file should be a .mobileconfig or .xml file.")
assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAppleDeclaration("foo.txt", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
// Windows-reserved LocURI
assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.")
@ -8842,11 +8905,13 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
// Fleet-reserved profiles
for name := range servermdm.FleetReservedProfileNames() {
assertAppleProfile(name+".mobileconfig", name, name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %s is not allowed`, name))
assertAppleDeclaration(name+".json", name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %q is not allowed`, name))
assertWindowsProfile(name+".xml", "./Test", 0, nil, http.StatusBadRequest, fmt.Sprintf(`Couldn't upload. Profile name %q is not allowed.`, name))
}
// profiles with non-existent labels
assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
// create a couple of labels
@ -8859,26 +8924,33 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
// profiles mixing existent and non-existent labels
assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, "some or all the labels provided don't exist")
// profiles with valid labels
uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"foo"}, http.StatusOK, "")
uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"})
uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"foo", "bar"}, http.StatusOK, "")
// verify that the label associations have been created
// TODO: update when we have datastore methods to get this data
var profileLabels []fleet.ConfigurationProfileLabel
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `SELECT COALESCE(apple_profile_uuid, windows_profile_uuid) as profile_uuid, label_name, label_id FROM mdm_configuration_profile_labels`
stmt := `
SELECT COALESCE(apple_profile_uuid, windows_profile_uuid) as profile_uuid, label_name, COALESCE(label_id, 0) as label_id
FROM mdm_configuration_profile_labels
UNION SELECT apple_declaration_uuid as profile_uuid, label_name, COALESCE(label_id, 0) as label_id
FROM mdm_declaration_labels ORDER BY profile_uuid, label_name;`
return sqlx.SelectContext(context.Background(), q, &profileLabels, stmt)
})
require.NotEmpty(t, profileLabels)
require.Len(t, profileLabels, 3)
require.Len(t, profileLabels, 4)
require.ElementsMatch(
t,
[]fleet.ConfigurationProfileLabel{
{ProfileUUID: uuidAppleWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
{ProfileUUID: uuidAppleDDMWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
{ProfileUUID: uuidWindowsWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
{ProfileUUID: uuidWindowsWithLabel, LabelName: labelBar.Name, LabelID: labelBar.ID},
},
@ -8891,19 +8963,27 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't upload. The file should include valid XML:")
// Apple invalid content
// Apple invalid mobileconfig content
body, headers = generateNewProfileMultipartRequest(t,
"apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "mobileconfig is not XML nor PKCS7 parseable")
// Apple invalid json declaration
body, headers = generateNewProfileMultipartRequest(t,
"apple.json", []byte("{"), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't upload. The file should include valid JSON:")
// get the existing profiles work
expectedProfiles := []fleet.MDMConfigProfilePayload{
{ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil},
{ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID},
{ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
{ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID},
{ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil, Labels: []fleet.ConfigurationProfileLabel{{LabelID: labelFoo.ID, LabelName: labelFoo.Name}}},
}
for _, prof := range expectedProfiles {
var getResp getMDMConfigProfileResponse
@ -8922,8 +9002,10 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media")
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
if getResp.Platform == "darwin" {
if strings.HasPrefix(prof.ProfileUUID, "a") {
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
} else {
require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream")
}
@ -8938,6 +9020,9 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
// get an unknown Apple profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media")
// get an unknown Apple declaration
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
// get an unknown Windows profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media")
@ -8948,6 +9033,16 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp)
// delete non-existing Apple profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp)
// delete existing Apple declaration
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(),
`{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null}`,
0,
)
// delete non-existing Apple declaration
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
// delete existing Windows profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamWinProfUUID), nil, http.StatusOK, &deleteResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamWinProfUUID), nil, http.StatusOK, &deleteResp)
@ -8978,6 +9073,7 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
return err
})
}
// TODO: Add tests for create/delete forbidden declaration types?
// make fleet add a FileVault profile
acResp := appConfigResponse{}
@ -8999,6 +9095,8 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
// try to delete the profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp)
// TODO: Add tests for OS updates declaration when implemented.
}
func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() {
@ -11029,10 +11127,12 @@ func (s *integrationMDMTestSuite) TestManualEnrollmentCommands() {
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
if manifest := cmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if manifest := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; manifest != nil {
foundInstallFleetdCommand = true
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.Contains(t, *cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL)
require.Contains(t, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL)
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
@ -11706,7 +11806,10 @@ func (s *integrationMDMTestSuite) TestDontIgnoreAnyProfileErrors() {
for cmd != nil {
if cmd.Command.RequestType == "RemoveProfile" {
var errChain []mdm.ErrorChain
if cmd.Command.RemoveProfile.Identifier == "I1" {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
if fullCmd.Command.RemoveProfile.Identifier == "I1" {
errChain = append(errChain, mdm.ErrorChain{ErrorCode: 89, ErrorDomain: "MDMClientError", USEnglishDescription: "Profile with identifier 'I1' not found."})
} else {
errChain = append(errChain, mdm.ErrorChain{ErrorCode: 96, ErrorDomain: "MDMClientError", USEnglishDescription: "Cannot replace profile 'I2' because it was not installed by the MDM server."})
@ -11836,7 +11939,7 @@ func (s *integrationMDMTestSuite) TestSCEPCertExpiration() {
require.NoError(t, err)
checkRenewCertCommand := func(device *mdmtest.TestAppleMDMClient, enrollRef string) {
var renewCmd *micromdm.CommandPayload
var renewCmd *mdm.Command
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
@ -11847,7 +11950,9 @@ func (s *integrationMDMTestSuite) TestSCEPCertExpiration() {
require.NoError(t, err)
}
require.NotNil(t, renewCmd)
s.verifyEnrollmentProfile(renewCmd.Command.InstallProfile.Payload, enrollRef)
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(renewCmd.Raw, &fullCmd))
s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef)
}
checkRenewCertCommand(manualEnrolledDevice, "")

View file

@ -1019,6 +1019,25 @@ func getMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
}, nil
}
if isAppleDeclarationUUID(req.ProfileUUID) {
// TODO: we could potentially combined with the other service methods
decl, err := svc.GetMDMAppleDeclaration(ctx, req.ProfileUUID)
if err != nil {
return &getMDMConfigProfileResponse{Err: err}, nil
}
if downloadRequested {
return downloadFileResponse{
content: decl.RawJSON,
contentType: "application/json",
filename: fmt.Sprintf("%s_%s.json", time.Now().Format("2006-01-02"), strings.ReplaceAll(decl.Name, " ", "_")),
}, nil
}
return &getMDMConfigProfileResponse{
MDMConfigProfilePayload: fleet.NewMDMConfigProfilePayloadFromAppleDDM(decl),
}, nil
}
// Windows config profile
cp, err := svc.GetMDMWindowsConfigProfile(ctx, req.ProfileUUID)
if err != nil {
@ -1077,6 +1096,9 @@ func deleteMDMConfigProfileEndpoint(ctx context.Context, request interface{}, sv
var err error
if isAppleProfileUUID(req.ProfileUUID) {
err = svc.DeleteMDMAppleConfigProfile(ctx, req.ProfileUUID)
} else if isAppleDeclarationUUID(req.ProfileUUID) {
// TODO: we could potentially combined with the other service methods
err = svc.DeleteMDMAppleDeclaration(ctx, req.ProfileUUID)
} else {
err = svc.DeleteMDMWindowsConfigProfile(ctx, req.ProfileUUID)
}
@ -1157,6 +1179,10 @@ func isAppleProfileUUID(profileUUID string) bool {
return strings.HasPrefix(profileUUID, "a")
}
func isAppleDeclarationUUID(profileUUID string) bool {
return strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix)
}
////////////////////////////////////////////////////////////////////////////////
// POST /mdm/profiles (Create Apple or Windows MDM Config Profile)
////////////////////////////////////////////////////////////////////////////////
@ -1221,7 +1247,23 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
defer ff.Close()
fileExt := filepath.Ext(req.Profile.Filename)
if isApple := strings.EqualFold(fileExt, ".mobileconfig"); isApple {
profileName := strings.TrimSuffix(filepath.Base(req.Profile.Filename), fileExt)
isMobileConfig := strings.EqualFold(fileExt, ".mobileconfig")
isJSON := strings.EqualFold(fileExt, ".json")
if isMobileConfig || isJSON {
// Then it's an Apple configuration file
if isJSON {
decl, err := svc.NewMDMAppleDeclaration(ctx, req.TeamID, ff, req.Labels, profileName)
if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil
}
return &newMDMConfigProfileResponse{
ProfileUUID: decl.DeclarationUUID,
}, nil
}
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, ff, req.Labels)
if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil
@ -1232,7 +1274,6 @@ func newMDMConfigProfileEndpoint(ctx context.Context, request interface{}, svc f
}
if isWindows := strings.EqualFold(fileExt, ".xml"); isWindows {
profileName := strings.TrimSuffix(filepath.Base(req.Profile.Filename), fileExt)
cp, err := svc.NewMDMWindowsConfigProfile(ctx, req.TeamID, profileName, ff, req.Labels)
if err != nil {
return &newMDMConfigProfileResponse{Err: err}, nil
@ -1254,7 +1295,7 @@ func (svc *Service) NewMDMUnsupportedConfigProfile(ctx context.Context, teamID u
// this is required because we need authorize to return the error, and
// svc.authz is only available on the concrete Service struct, not on the
// Service interface so it cannot be done in the endpoint itself.
return &fleet.BadRequestError{Message: "Couldn't upload. The file should be a .mobileconfig or .xml file."}
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) {
@ -1476,7 +1517,7 @@ func (svc *Service) BatchSetMDMProfiles(
return ctxerr.Wrap(ctx, err, "validating labels")
}
appleProfiles, err := getAppleProfiles(ctx, tmID, appCfg, profiles, labelMap)
appleProfiles, appleDecls, err := getAppleProfiles(ctx, tmID, appCfg, profiles, labelMap)
if err != nil {
return ctxerr.Wrap(ctx, err, "validating macOS profiles")
}
@ -1490,7 +1531,7 @@ func (svc *Service) BatchSetMDMProfiles(
return nil
}
if err := svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles); err != nil {
if err := svc.ds.BatchSetMDMProfiles(ctx, tmID, appleProfiles, windowsProfiles, appleDecls); err != nil {
return ctxerr.Wrap(ctx, err, "setting config profiles")
}
@ -1576,17 +1617,94 @@ func getAppleProfiles(
appCfg *fleet.AppConfig,
profiles []fleet.MDMProfileBatchPayload,
labelMap map[string]fleet.ConfigurationProfileLabel,
) ([]*fleet.MDMAppleConfigProfile, error) {
) ([]*fleet.MDMAppleConfigProfile, []*fleet.MDMAppleDeclaration, error) {
// any duplicate identifier or name in the provided set results in an error
profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles))
byName, byIdent := make(map[string]bool, len(profiles)), make(map[string]bool, len(profiles))
decls := make([]*fleet.MDMAppleDeclaration, 0, len(profiles))
// we need to keep track of the names and identifiers to check for duplicates so we will use
// a map where the key is the name oridentifier and the value is either "mobileconfig" or
// "declaration" to differentiate between the two types of profiles
byName, byIdent := make(map[string]string, len(profiles)), make(map[string]string, len(profiles))
for _, prof := range profiles {
if mdm.GetRawProfilePlatform(prof.Contents) != "darwin" {
continue
}
// Check for DDM files
isJSON := func(b []byte) bool {
var js json.RawMessage
return json.Unmarshal(b, &js) == nil
}
// TODO(roberto): As a mini optimization, GetRawDeclarationValues could replace isJSON.
if isJSON(prof.Contents) {
rawDecl, err := fleet.GetRawDeclarationValues(prof.Contents)
if err != nil {
return nil, nil, err
}
if err := rawDecl.ValidateUserProvided(); err != nil {
return nil, nil, err
}
mdmDecl := fleet.NewMDMAppleDeclaration(prof.Contents, tmID, prof.Name, rawDecl.Type, rawDecl.Identifier)
for _, labelName := range prof.Labels {
if lbl, ok := labelMap[labelName]; ok {
declLabel := fleet.ConfigurationProfileLabel{
LabelName: lbl.LabelName,
LabelID: lbl.LabelID,
}
mdmDecl.Labels = append(mdmDecl.Labels, declLabel)
}
}
v, ok := byName[mdmDecl.Name]
switch {
case !ok:
byName[mdmDecl.Name] = "declaration"
case v == "mobileconfig":
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(mdmDecl.Name, "A configuration profile with this name already exists."),
"duplicate mobileconfig profile by name")
case v == "declaration":
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(mdmDecl.Name, "A declaration profile with this name already exists."),
"duplicate declaration profile by name")
default:
// this should never happen but just in case
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(mdmDecl.Name, "A profile with this name already exists."),
"duplicate profile by name")
}
v, ok = byIdent[mdmDecl.Identifier]
switch {
case !ok:
byIdent[mdmDecl.Identifier] = "declaration"
case v == "mobileconfig":
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(mdmDecl.Identifier, "A configuration profile with this identifier already exists."),
"duplicate mobileconfig profile by identifier")
case v == "declaration":
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(mdmDecl.Identifier, "A declaration profile with this identifier already exists."),
"duplicate declaration profile by identifier")
default:
// this should never happen but just in case
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(mdmDecl.Identifier, "A profile with this identifier already exists."),
"duplicate identifier by identifier")
}
decls = append(decls, mdmDecl)
continue
}
mdmProf, err := fleet.NewMDMAppleConfigProfile(prof.Contents, tmID)
if err != nil {
return nil, ctxerr.Wrap(ctx,
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(prof.Name, err.Error()),
"invalid mobileconfig profile")
}
@ -1598,29 +1716,31 @@ func getAppleProfiles(
}
if err := mdmProf.ValidateUserProvided(); err != nil {
return nil, ctxerr.Wrap(ctx,
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(prof.Name, err.Error()))
}
if mdmProf.Name != prof.Name {
return nil, ctxerr.Wrap(ctx,
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldnt edit custom_settings. The name provided for the profile must match the profile PayloadDisplayName: %q", mdmProf.Name)),
"duplicate mobileconfig profile by name")
}
if byName[mdmProf.Name] {
return nil, ctxerr.Wrap(ctx,
// TODO: confirm error messages
if _, ok := byName[mdmProf.Name]; ok {
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldnt edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
"duplicate mobileconfig profile by name")
}
byName[mdmProf.Name] = true
byName[mdmProf.Name] = "mobileconfig"
if byIdent[mdmProf.Identifier] {
return nil, ctxerr.Wrap(ctx,
// TODO: confirm error messages
if _, ok := byIdent[mdmProf.Identifier]; ok {
return nil, nil, ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError(prof.Name, fmt.Sprintf("Couldnt edit custom_settings. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)),
"duplicate mobileconfig profile by identifier")
}
byIdent[mdmProf.Identifier] = true
byIdent[mdmProf.Identifier] = "mobileconfig"
profs = append(profs, mdmProf)
}
@ -1632,13 +1752,13 @@ func getAppleProfiles(
// custom_settings key, we just return a success response in this
// situation.
if len(profs) == 0 {
return []*fleet.MDMAppleConfigProfile{}, nil
return []*fleet.MDMAppleConfigProfile{}, []*fleet.MDMAppleDeclaration{}, nil
}
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: Fleet MDM is not configured"))
return nil, nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: Fleet MDM is not configured"))
}
return profs, nil
return profs, decls, nil
}
func getWindowsProfiles(

View file

@ -753,6 +753,8 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) {
})
}
// TODO: Add tests for Apple DDM authz?
func TestMDMWindowsConfigProfileAuthz(t *testing.T) {
ds := new(mock.Store)
// while the config profiles are not premium-only, teams are and we want to test with teams.
@ -1093,7 +1095,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
ds.TeamFunc = func(ctx context.Context, id uint) (*fleet.Team, error) {
return &fleet.Team{ID: id, Name: "team"}, nil
}
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile) error {
ds.BatchSetMDMProfilesFunc = func(ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, macDecls []*fleet.MDMAppleDeclaration) error {
return nil
}
ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
@ -1300,6 +1302,7 @@ func TestMDMBatchSetProfiles(t *testing.T) {
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N2", "I2")},
{Name: "N3", Contents: mobileconfigForTest("N3", "I3")},
{Name: "N4", Contents: declBytesForTest("D1", "d1content")},
},
``,
},

View file

@ -339,6 +339,10 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ
commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher),
logger: kitlog.NewNopLogger(),
},
&MDMAppleDDMService{
ds: ds,
logger: logger,
},
)
require.NoError(t, err)
}

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,7 @@ variable "fleet_config" {
type = object({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.47.2")
image = optional(string, "fleetdm/fleet:v4.47.3")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])

View file

@ -74,7 +74,7 @@ variable "fleet_config" {
type = object({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.47.2")
image = optional(string, "fleetdm/fleet:v4.47.3")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])

View file

@ -17,7 +17,7 @@ provider "aws" {
}
locals {
fleet_image = "fleetdm/fleet:v4.47.2"
fleet_image = "fleetdm/fleet:v4.47.3"
domain_name = "example.com"
}

View file

@ -167,7 +167,7 @@ variable "fleet_config" {
type = object({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.47.2")
image = optional(string, "fleetdm/fleet:v4.47.3")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])

View file

@ -59,8 +59,8 @@ module "fleet" {
fleet_config = {
# To avoid pull-rate limiting from dockerhub, consider using our quay.io mirror
# for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.47.2"
image = "fleetdm/fleet:v4.47.2" # override default to deploy the image you desire
# for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.47.3"
image = "fleetdm/fleet:v4.47.3" # override default to deploy the image you desire
# See https://fleetdm.com/docs/deploy/reference-architectures#aws for appropriate scaling
# memory and cpu.
autoscaling = {

View file

@ -215,7 +215,7 @@ variable "fleet_config" {
type = object({
mem = optional(number, 4096)
cpu = optional(number, 512)
image = optional(string, "fleetdm/fleet:v4.47.2")
image = optional(string, "fleetdm/fleet:v4.47.3")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])

View file

@ -1,6 +1,6 @@
{
"name": "fleetctl",
"version": "v4.47.2",
"version": "v4.47.3",
"description": "Installer for the fleetctl CLI tool",
"bin": {
"fleetctl": "./run.js"

View file

@ -511,6 +511,7 @@ module.exports.routes = {
'GET /learn-more-about/enrolling-hosts': '/docs/using-fleet/adding-hosts',
'GET /learn-more-about/setup-assistant': '/docs/using-fleet/mdm-macos-setup-experience#macos-setup-assistant',
'GET /learn-more-about/policy-automations': '/docs/using-fleet/automations',
'GET /install-wine': 'https://github.com/fleetdm/fleet/blob/main/scripts/macos-install-wine.sh',
// Sitemap
// =============================================================================================================

View file

@ -115,9 +115,10 @@
</div>
<div purpose="feature-text" class="d-flex flex-column">
<h3>Up-to-date data without scans</h3>
<p>Traditional network vulnerability scans can clog networks. Fleet does things differently.</p>
<p>Traditional network vulnerability scans can clog your network and even haunt your printers with pages full of wingdings. Fleet does things differently.</p>
<div purpose="checklist" class="flex-column d-flex">
<p>Lightweight enough for the most brittle environments (OT, data centers, embedded/BTS, low-latency gaming servers).</p>
<p>Eliminate the risk of side effects from scanning the network.</p>
<p>Lightweight enough for the most brittle environments (OT, data centers, embedded/BTS, low-latency gaming servers).</p>
<p>Quickly pull data about important CVEs and zero days during an incident or audit.</p>
</div>
</div>