Add feature to resend MDM configuration profiles (#18280)

Includes PRs #18111, #18212, and #18271
This commit is contained in:
George Karr 2024-04-15 16:48:42 -05:00 committed by GitHub
commit fa5e224a4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1034 additions and 141 deletions

View file

@ -0,0 +1 @@
- Added API to support resending MDM profiles.

View file

@ -0,0 +1 @@
- add UI for resending a profile for a host on the host details page in the OS Settings modal

View file

@ -1108,6 +1108,25 @@ This activity contains the following fields:
}
```
## resent_configuration_profile
Generated when a user resends an MDM configuration profile to a host.
This activity contains the following fields:
- "host_id": The ID of the host.
- "host_display_name": The display name of the host.
- "profile_name": The name of the configuration profile.
#### Example
```json
{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"profile_name": "Passcode requirements"
}
```
<meta name="title" value="Audit logs">
<meta name="pageOrderInSection" value="1400">

View file

@ -9,14 +9,16 @@ import { COLORS } from "styles/var/colors";
interface ITooltipTruncatedTextCellProps {
value: React.ReactNode;
/** Tooltip to dispay. If this is provided then this will be rendered as the tooltip content. If
* not the value will be displayed as the tooltip content. Defaults to `undefined` */
* not, the value will be displayed as the tooltip content. Defaults to `undefined` */
tooltip?: React.ReactNode;
/** If set to `true` the text inside the tooltip will break on words instead of any character.
* By default the tooltip text breaks on any character.
* Default is `false`.
*/
tooltipBreakOnWord?: boolean;
/** @deprecated use the prop `className` in order to add custom classes to this component */
classes?: string;
className?: string;
}
const baseClass = "tooltip-truncated-cell";
@ -26,8 +28,9 @@ const TooltipTruncatedTextCell = ({
tooltip,
tooltipBreakOnWord = false,
classes = "w250",
className,
}: ITooltipTruncatedTextCellProps): JSX.Element => {
const classNames = classnames(baseClass, classes, {
const classNames = classnames(baseClass, classes, className, {
"tooltip-break-on-word": tooltipBreakOnWord,
});

View file

@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import classnames from "classnames";
import { Row, UseExpandedRowProps } from "react-table";
import { Row } from "react-table";
import ReactTooltip from "react-tooltip";
import useDeepEffect from "hooks/useDeepEffect";

View file

@ -70,6 +70,7 @@ export enum ActivityType {
CreatedDeclarationProfile = "created_declaration_profile",
DeletedDeclarationProfile = "deleted_declaration_profile",
EditedDeclarationProfile = "edited_declaration_profile",
ResentConfigurationProfile = "resent_configuration_profile",
}
// This is a subset of ActivityType that are shown only for the host past activities

View file

@ -810,6 +810,16 @@ const TAGGED_TEMPLATES = {
</>
);
},
resentConfigProfile: (activity: IActivity) => {
return (
<>
{" "}
resent {activity.details?.profile_name} configuration profile to{" "}
{activity.details?.host_display_name}.
</>
);
},
};
const getDetail = (
@ -980,6 +990,9 @@ const getDetail = (
case ActivityType.EditedDeclarationProfile: {
return TAGGED_TEMPLATES.editedDeclarationProfile(activity, isPremiumTier);
}
case ActivityType.ResentConfigurationProfile: {
return TAGGED_TEMPLATES.resentConfigProfile(activity);
}
default: {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);

View file

@ -445,10 +445,12 @@ const DeviceUserPage = ({
policy={selectedPolicy}
/>
)}
{showOSSettingsModal && (
{!!host && showOSSettingsModal && (
<OSSettingsModal
platform={host?.platform}
hostMDMData={host?.mdm}
canResendProfiles={false}
hostId={host.id}
platform={host.platform}
hostMDMData={host.mdm}
onClose={toggleOSSettingsModal}
/>
)}

View file

@ -932,9 +932,12 @@ const HostDetailsPage = ({
)}
{showOSSettingsModal && (
<OSSettingsModal
platform={host?.platform}
hostMDMData={host?.mdm}
canResendProfiles
hostId={host.id}
platform={host.platform}
hostMDMData={host.mdm}
onClose={toggleOSSettingsModal}
onProfileResent={refetchHostDetails}
/>
)}
{showUnenrollMdmModal && !!host && (

View file

@ -7,17 +7,27 @@ import OSSettingsTable from "./OSSettingsTable";
import { generateTableData } from "./OSSettingsTable/OSSettingsTableConfig";
interface IOSSettingsModalProps {
platform?: string;
hostMDMData?: IHostMdmData;
hostId: number;
platform: string;
hostMDMData: IHostMdmData;
/** controls showing the action for a user to resend a profile. Defaults to `false` */
canResendProfiles?: boolean;
onClose: () => void;
/** handler that fires when a profile was reset. Requires `canResendProfiles` prop
* to be `true`, otherwise has no effect.
*/
onProfileResent?: () => void;
}
const baseClass = "os-settings-modal";
const OSSettingsModal = ({
hostId,
platform,
hostMDMData,
canResendProfiles = false,
onClose,
onProfileResent,
}: IOSSettingsModalProps) => {
// the caller should ensure that hostMDMData is not undefined and that platform is "windows" or
// "darwin", otherwise we will allow an empty modal will be rendered.
@ -36,7 +46,12 @@ const OSSettingsModal = ({
width="large"
>
<>
<OSSettingsTable tableData={memoizedTableData || []} />
<OSSettingsTable
canResendProfiles={canResendProfiles}
hostId={hostId}
tableData={memoizedTableData ?? []}
onProfileResent={onProfileResent}
/>
<div className="modal-cta-wrap">
<Button variant="brand" onClick={onClose}>
Done

View file

@ -4,10 +4,7 @@ import { uniqueId } from "lodash";
import Icon from "components/Icon";
import TextCell from "components/TableContainer/DataTable/TextCell";
import {
FLEET_FILEVAULT_PROFILE_DISPLAY_NAME,
ProfileOperationType,
} from "interfaces/mdm";
import { ProfileOperationType } from "interfaces/mdm";
import { COLORS } from "styles/var/colors";
import {
@ -16,6 +13,7 @@ import {
} from "../OSSettingsTableConfig";
import TooltipContent from "./components/Tooltip/TooltipContent";
import {
isDiskEncryptionProfile,
PROFILE_DISPLAY_CONFIG,
ProfileDisplayOption,
WINDOWS_DISK_ENCRYPTION_DISPLAY_CONFIG,
@ -50,9 +48,6 @@ const OSSettingStatusCell = ({
.toLowerCase()
.includes("/device/");
const isDiskEncryptionProfile =
profileName === FLEET_FILEVAULT_PROFILE_DISPLAY_NAME;
if (displayOption) {
const { statusText, iconName, tooltip } = displayOption;
const tooltipId = uniqueId();
@ -81,7 +76,9 @@ const OSSettingStatusCell = ({
<TooltipContent
innerContent={tooltip}
innerProps={{
isDiskEncryptionProfile,
isDiskEncryptionProfile: isDiskEncryptionProfile(
profileName
),
}}
/>
) : (

View file

@ -1,4 +1,7 @@
import { ProfileOperationType } from "interfaces/mdm";
import {
FLEET_FILEVAULT_PROFILE_DISPLAY_NAME,
ProfileOperationType,
} from "interfaces/mdm";
import { IconNames } from "components/icons";
import {
@ -9,6 +12,10 @@ import {
import { OsSettingsTableStatusValue } from "../OSSettingsTableConfig";
import TooltipInnerContentActionRequired from "./components/Tooltip/ActionRequired";
export const isDiskEncryptionProfile = (profileName: string) => {
return profileName === FLEET_FILEVAULT_PROFILE_DISPLAY_NAME;
};
export type ProfileDisplayOption = {
statusText: string;
iconName: IconNames;

View file

@ -0,0 +1,147 @@
import React, { useContext, useState } from "react";
import classnames from "classnames";
import { noop } from "lodash";
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
import hostAPI from "services/entities/hosts";
import { NotificationContext } from "context/notification";
import TooltipTruncatedTextCell from "components/TableContainer/DataTable/TooltipTruncatedTextCell";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import { IHostMdmProfileWithAddedStatus } from "../OSSettingsTableConfig";
const baseClass = "os-settings-error-cell";
interface IRefetchButtonProps {
isFetching: boolean;
onClick: (evt: React.MouseEvent<HTMLButtonElement, React.MouseEvent>) => void;
}
const RefetchButton = ({ isFetching, onClick }: IRefetchButtonProps) => {
const classNames = classnames(`${baseClass}__resend-button`, "resend-link", {
[`${baseClass}__resending`]: isFetching,
});
const buttonText = isFetching ? "Resending..." : "Resend";
// add additonal props when we need to display a tooltip for the button
return (
<Button
disabled={isFetching}
onClick={onClick}
variant="text-icon"
className={classNames}
>
<Icon name="refresh" color="core-fleet-blue" size="small" />
{buttonText}
</Button>
);
};
/**
* generates the formatted tooltip for the error column.
* the expected format of the error string is:
* "key1: value1, key2: value2, key3: value3"
*/
const generateFormattedTooltip = (detail: string) => {
const keyValuePairs = detail.split(/, */);
const formattedElements: JSX.Element[] = [];
// Special case to handle bitlocker error message. It does not follow the
// expected string format so we will just render the error message as is.
if (
detail.includes("BitLocker") ||
detail.includes("preparing volume for encryption")
) {
return detail;
}
keyValuePairs.forEach((pair, i) => {
const [key, value] = pair.split(/: */);
if (key && value) {
formattedElements.push(
<span key={key}>
<b>{key.trim()}:</b> {value.trim()}
{/* dont add the trailing comma for the last element */}
{i !== keyValuePairs.length - 1 && (
<>
,<br />
</>
)}
</span>
);
}
});
return formattedElements.length ? <>{formattedElements}</> : detail;
};
/**
* generates the error tooltip for the error column. This will be formatted or
* unformatted.
*/
const generateErrorTooltip = (
cellValue: string,
profile: IHostMdmProfileWithAddedStatus
) => {
if (profile.status !== "failed") return null;
if (profile.platform !== "windows") {
return cellValue;
}
return generateFormattedTooltip(profile.detail);
};
interface IOSSettingsErrorCellProps {
canResendProfiles: boolean;
hostId: number;
profile: IHostMdmProfileWithAddedStatus;
onProfileResent?: () => void;
}
const OSSettingsErrorCell = ({
canResendProfiles,
hostId,
profile,
onProfileResent = noop,
}: IOSSettingsErrorCellProps) => {
const { renderFlash } = useContext(NotificationContext);
const [isLoading, setIsLoading] = useState(false);
const onResendProfile = async () => {
setIsLoading(true);
try {
await hostAPI.resendProfile(hostId, profile.profile_uuid);
onProfileResent();
} catch (e) {
renderFlash("error", "Couldn't resend. Please try again.");
}
setIsLoading(false);
};
const isFailed = profile.status === "failed";
const isVerified = profile.status === "verified";
const showRefetchButton = canResendProfiles && (isFailed || isVerified);
const value = (isFailed && profile.detail) || DEFAULT_EMPTY_CELL_VALUE;
const tooltip = generateErrorTooltip(value, profile);
return (
<div className={baseClass}>
<TooltipTruncatedTextCell
tooltipBreakOnWord
tooltip={tooltip}
value={value}
classes={showRefetchButton ? `${baseClass}__failed-message` : undefined}
/>
{showRefetchButton && (
<RefetchButton isFetching={isLoading} onClick={onResendProfile} />
)}
</div>
);
};
export default OSSettingsErrorCell;

View file

@ -0,0 +1,56 @@
.os-settings-error-cell {
display: flex;
justify-content: space-between;
align-items: center;
gap: $pad-small;
&__failed-message {
max-width: calc(250px - 48px);
min-width: 100px;
.data-table__tooltip-truncated-text--cell {
// for some reason this is need to vertically align the text and
// the resend button
display: block;
}
}
&__resend-button {
display: flex;
.children-wrapper {
display: flex;
.icon {
vertical-align: middle;
margin-right: 8px;
}
}
}
&__resending {
color: $core-vibrant-blue;
cursor: default;
font-size: $x-small;
height: 38px;
opacity: 50%;
filter: saturate(100%);
.icon {
vertical-align: middle;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
transform-origin: center center;
}
100% {
transform: rotate(360deg);
transform-origin: center center;
}
}
}
}

View file

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

View file

@ -1,23 +1,37 @@
import React from "react";
import TableContainer from "components/TableContainer";
import tableHeaders, {
import generateTableHeaders, {
IHostMdmProfileWithAddedStatus,
} from "./OSSettingsTableConfig";
const baseClass = "os-settings-table";
interface IOSSettingsTableProps {
tableData?: IHostMdmProfileWithAddedStatus[];
canResendProfiles: boolean;
hostId: number;
tableData: IHostMdmProfileWithAddedStatus[];
onProfileResent?: () => void;
}
const OSSettingsTable = ({ tableData }: IOSSettingsTableProps) => {
const OSSettingsTable = ({
canResendProfiles,
hostId,
tableData,
onProfileResent,
}: IOSSettingsTableProps) => {
const tableConfig = generateTableHeaders(
hostId,
canResendProfiles,
onProfileResent
);
return (
<div className={baseClass}>
<TableContainer
resultsTitle="settings"
defaultSortHeader="name"
columnConfigs={tableHeaders}
columnConfigs={tableConfig}
data={tableData}
emptyComponent="symbol"
isLoading={false}

View file

@ -9,16 +9,14 @@ import {
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";
import OSSettingsErrorCell from "./OSSettingsErrorCell";
export const isMdmProfileStatus = (
status: string
@ -34,118 +32,58 @@ export interface IHostMdmProfileWithAddedStatus
type ITableColumnConfig = Column<IHostMdmProfileWithAddedStatus>;
type ITableStringCellProps = IStringCellProps<IHostMdmProfileWithAddedStatus>;
/** Non DDM profiles can have an `action_required` as a profile status. DDM
* Profiles will never have this status.
*/
export type INonDDMProfileStatus = MdmProfileStatus | "action_required";
export type OsSettingsTableStatusValue =
| MdmDDMProfileStatus
| INonDDMProfileStatus;
/**
* generates the formatted tooltip for the error column.
* the expected format of the error string is:
* "key1: value1, key2: value2, key3: value3"
*/
const generateFormattedTooltip = (detail: string) => {
const keyValuePairs = detail.split(/, */);
const formattedElements: JSX.Element[] = [];
// Special case to handle bitlocker error message. It does not follow the
// expected string format so we will just render the error message as is.
if (
detail.includes("BitLocker") ||
detail.includes("preparing volume for encryption")
) {
return detail;
}
keyValuePairs.forEach((pair, i) => {
const [key, value] = pair.split(/: */);
if (key && value) {
formattedElements.push(
<span key={key}>
<b>{key.trim()}:</b> {value.trim()}
{/* dont add the trailing comma for the last element */}
{i !== keyValuePairs.length - 1 && (
<>
,<br />
</>
)}
</span>
);
}
});
return formattedElements.length ? <>{formattedElements}</> : detail;
};
/**
* generates the error tooltip for the error column. This will be formatted or
* unformatted.
*/
const generateErrorTooltip = (
cellValue: string,
platform: ProfilePlatform,
detail: string
) => {
if (platform !== "windows") {
return cellValue;
}
return generateFormattedTooltip(detail);
};
const tableHeaders: ITableColumnConfig[] = [
{
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
return <TextCell value={cellProps.cell.value} />;
const generateTableConfig = (
hostId: number,
canResendProfiles: boolean,
onProfileResent?: () => void
): ITableColumnConfig[] => {
return [
{
Header: "Name",
disableSortBy: true,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
return <TextCell value={cellProps.cell.value} />;
},
},
},
{
Header: "Status",
disableSortBy: true,
accessor: "status",
Cell: (cellProps: ITableStringCellProps) => {
return (
<OSSettingStatusCell
status={cellProps.row.original.status}
operationType={cellProps.row.original.operation_type}
profileName={cellProps.row.original.name}
{
Header: "Status",
disableSortBy: true,
accessor: "status",
Cell: (cellProps: ITableStringCellProps) => {
return (
<OSSettingStatusCell
status={cellProps.row.original.status}
operationType={cellProps.row.original.operation_type}
profileName={cellProps.row.original.name}
/>
);
},
},
{
Header: "Error",
disableSortBy: true,
accessor: "detail",
Cell: (cellProps: ITableStringCellProps) => (
<OSSettingsErrorCell
canResendProfiles={canResendProfiles}
hostId={hostId}
profile={cellProps.row.original}
onProfileResent={onProfileResent}
/>
);
),
},
},
{
Header: "Error",
disableSortBy: true,
accessor: "detail",
Cell: (cellProps: ITableStringCellProps): JSX.Element => {
const profile = cellProps.row.original;
const value =
(profile.status === "failed" && profile.detail) ||
DEFAULT_EMPTY_CELL_VALUE;
const tooltip =
profile.status === "failed"
? generateErrorTooltip(
value,
cellProps.row.original.platform,
profile.detail
)
: null;
return (
<TooltipTruncatedTextCell
tooltipBreakOnWord
tooltip={tooltip}
value={value}
/>
);
},
},
];
];
};
const makeWindowsRows = ({ profiles, os_settings }: IHostMdmData) => {
const rows: IHostMdmProfileWithAddedStatus[] = [];
@ -195,13 +133,9 @@ const makeDarwinRows = ({ profiles, macos_settings }: IHostMdmData) => {
};
export const generateTableData = (
hostMDMData?: IHostMdmData,
platform?: string
hostMDMData: IHostMdmData,
platform: string
) => {
if (!platform || !hostMDMData) {
return null;
}
switch (platform) {
case "windows":
return makeWindowsRows(hostMDMData);
@ -212,4 +146,4 @@ export const generateTableData = (
}
};
export default tableHeaders;
export default generateTableConfig;

View file

@ -4,4 +4,17 @@
white-space: nowrap;
}
}
// row hover effect for resend button. we dont want this behavior when the
// button is resending
.resend-link:not(.os-settings-error-cell__resending) {
opacity: 0;
transition: opacity 250ms;
}
tr:hover {
.resend-link:not(.os-settings-error-cell__resending) {
opacity: 1;
}
}
}

View file

@ -512,4 +512,10 @@ export default {
const { HOST_WIPE } = endpoints;
return sendRequest("POST", HOST_WIPE(id));
},
resendProfile: (hostId: number, profileUUID: string) => {
const { HOST_RESEND_PROFILE } = endpoints;
return sendRequest("POST", HOST_RESEND_PROFILE(hostId, profileUUID));
},
};

View file

@ -44,6 +44,8 @@ export default {
HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`,
HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`,
HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`,
HOST_RESEND_PROFILE: (hostId: number, profileUUID: string) =>
`/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/resend/${profileUUID}`,
INVITES: `/${API_VERSION}/fleet/invites`,
LABELS: `/${API_VERSION}/fleet/labels`,

View file

@ -1098,3 +1098,66 @@ func (ds *Datastore) CleanSCEPRenewRefs(ctx context.Context, hostUUID string) er
return nil
}
func (ds *Datastore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profUUID string) (fleet.MDMDeliveryStatus, error) {
table, column, err := getTableAndColumnNameForHostMDMProfileUUID(profUUID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting table and column")
}
selectStmt := fmt.Sprintf(`
SELECT
COALESCE(status, ?) as status
FROM
%s
WHERE
operation_type = ?
AND host_uuid = ?
AND %s = ?
`, table, column)
var status fleet.MDMDeliveryStatus
if err := sqlx.GetContext(ctx, ds.writer(ctx), &status, selectStmt, fleet.MDMDeliveryPending, fleet.MDMOperationTypeInstall, hostUUID, profUUID); err != nil {
if err == sql.ErrNoRows {
return "", notFound("HostMDMProfile").WithMessage("unable to match profile to host")
}
return "", ctxerr.Wrap(ctx, err, "get MDM profile status")
}
return status, nil
}
func (ds *Datastore) ResendHostMDMProfile(ctx context.Context, hostUUID string, profUUID string) error {
table, column, err := getTableAndColumnNameForHostMDMProfileUUID(profUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting table and column")
}
// update the status to NULL to trigger resending on the next cron run
updateStmt := fmt.Sprintf(`UPDATE %s SET status = NULL WHERE host_uuid = ? AND %s = ?`, table, column)
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, updateStmt, hostUUID, profUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "resending host MDM profile")
}
if rows, _ := res.RowsAffected(); rows == 0 {
// this should never happen, log for debugging
level.Debug(ds.logger).Log("msg", "resend profile status not updated", "host_uuid", hostUUID, "profile_uuid", profUUID)
}
return nil
})
}
func getTableAndColumnNameForHostMDMProfileUUID(profUUID string) (table, column string, err error) {
switch {
case strings.HasPrefix(profUUID, fleet.MDMAppleDeclarationUUIDPrefix):
return "host_mdm_apple_declarations", "declaration_uuid", nil
case strings.HasPrefix(profUUID, fleet.MDMAppleProfileUUIDPrefix):
return "host_mdm_apple_profiles", "profile_uuid", nil
case strings.HasPrefix(profUUID, fleet.MDMWindowsProfileUUIDPrefix):
return "host_mdm_windows_profiles", "profile_uuid", nil
default:
return "", "", fmt.Errorf("invalid profile UUID prefix %s", profUUID)
}
}

View file

@ -445,7 +445,7 @@ func InsertWindowsProfileForTest(t *testing.T, ds *Datastore, teamID uint) strin
profUUID := "w" + uuid.NewString()
prof := generateDummyWindowsProfile(profUUID)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml) VALUES (?, ?, ?, ?);`
stmt := `INSERT INTO mdm_windows_configuration_profiles (profile_uuid, team_id, name, syncml, uploaded_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, profUUID, teamID, fmt.Sprintf("name-%s", profUUID), prof)
return err
})

View file

@ -88,6 +88,8 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeCreatedDeclarationProfile{},
ActivityTypeDeletedDeclarationProfile{},
ActivityTypeEditedDeclarationProfile{},
ActivityTypeResentConfigurationProfile{},
}
type ActivityDetails interface {
@ -1390,6 +1392,28 @@ func (a ActivityTypeEditedDeclarationProfile) Documentation() (activity string,
}`
}
type ActivityTypeResentConfigurationProfile struct {
HostID *uint `json:"host_id"`
HostDisplayName *string `json:"host_display_name"`
ProfileName string `json:"profile_name"`
}
func (a ActivityTypeResentConfigurationProfile) ActivityName() string {
return "resent_configuration_profile"
}
func (a ActivityTypeResentConfigurationProfile) Documentation() (activity string, details string, detailsExample string) {
return `Generated when a user resends an MDM configuration profile to a host.`,
`This activity contains the following fields:
- "host_id": The ID of the host.
- "host_display_name": The display name of the host.
- "profile_name": The name of the configuration profile.`, `{
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"profile_name": "Passcode requirements"
}`
}
// 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,8 +451,6 @@ 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

View file

@ -1192,7 +1192,7 @@ type Datastore interface {
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
MDMAppleBatchSetHostDeclarationState(ctx context.Context) ([]string, error)
// MDMAppleStoreDDMStatusReport receives a host.uuid and a slice
// of declarations, and updates the tracked host declaration status for
@ -1264,6 +1264,13 @@ type Datastore interface {
// corresponding to the criteria.
ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt ListOptions) ([]*MDMConfigProfilePayload, *PaginationMetadata, error)
// ResendHostMDMProfile updates the host's profile status to NULL thereby triggering the profile
// to be resent upon the next cron run.
ResendHostMDMProfile(ctx context.Context, hostUUID string, profileUUID string) error
// GetHostMDMProfileInstallStatus returns the status of the profile for the host.
GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (MDMDeliveryStatus, error)
///////////////////////////////////////////////////////////////////////////////
// MDM Commands

View file

@ -12,6 +12,10 @@ import (
const (
MDMPlatformApple = "apple"
MDMPlatformMicrosoft = "microsoft"
MDMAppleDeclarationUUIDPrefix = "d"
MDMAppleProfileUUIDPrefix = "a"
MDMWindowsProfileUUIDPrefix = "w"
)
type AppleMDM struct {

View file

@ -926,6 +926,9 @@ type Service interface {
// assigned to any team).
GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uint) (*MDMDiskEncryptionSummary, error)
// ResendHostMDMProfile resends the MDM profile to the host.
ResendHostMDMProfile(ctx context.Context, hostID uint, profileUUID string) error
///////////////////////////////////////////////////////////////////////////////
// Host Script Execution

View file

@ -831,6 +831,10 @@ type GetHostMDMWindowsProfilesFunc func(ctx context.Context, hostUUID string) ([
type ListMDMConfigProfilesFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]*fleet.MDMConfigProfilePayload, *fleet.PaginationMetadata, error)
type ResendHostMDMProfileFunc func(ctx context.Context, hostUUID string, profileUUID string) error
type GetHostMDMProfileInstallStatusFunc func(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error)
type GetMDMCommandPlatformFunc func(ctx context.Context, commandUUID string) (string, error)
type ListMDMCommandsFunc func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error)
@ -2120,6 +2124,12 @@ type DataStore struct {
ListMDMConfigProfilesFunc ListMDMConfigProfilesFunc
ListMDMConfigProfilesFuncInvoked bool
ResendHostMDMProfileFunc ResendHostMDMProfileFunc
ResendHostMDMProfileFuncInvoked bool
GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFunc
GetHostMDMProfileInstallStatusFuncInvoked bool
GetMDMCommandPlatformFunc GetMDMCommandPlatformFunc
GetMDMCommandPlatformFuncInvoked bool
@ -5070,6 +5080,20 @@ func (s *DataStore) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt
return s.ListMDMConfigProfilesFunc(ctx, teamID, opt)
}
func (s *DataStore) ResendHostMDMProfile(ctx context.Context, hostUUID string, profileUUID string) error {
s.mu.Lock()
s.ResendHostMDMProfileFuncInvoked = true
s.mu.Unlock()
return s.ResendHostMDMProfileFunc(ctx, hostUUID, profileUUID)
}
func (s *DataStore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error) {
s.mu.Lock()
s.GetHostMDMProfileInstallStatusFuncInvoked = true
s.mu.Unlock()
return s.GetHostMDMProfileInstallStatusFunc(ctx, hostUUID, profileUUID)
}
func (s *DataStore) GetMDMCommandPlatform(ctx context.Context, commandUUID string) (string, error) {
s.mu.Lock()
s.GetMDMCommandPlatformFuncInvoked = true

View file

@ -593,6 +593,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Deprecated: GET /mdm/hosts/:id/profiles is now deprecated, replaced by
// GET /hosts/:id/configuration_profiles.
mdmAppleMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/profiles", getHostProfilesEndpoint, getHostProfilesRequest{})
// TODO: Confirm if response should be updated to include Windows profiles and use mdmAnyMW
mdmAppleMW.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/configuration_profiles", getHostProfilesEndpoint, getHostProfilesRequest{})
// Deprecated: PATCH /mdm/apple/setup is now deprecated, replaced by the
@ -684,6 +685,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
mdmAnyMW.POST("/api/_version_/fleet/mdm/profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{})
mdmAnyMW.POST("/api/_version_/fleet/configuration_profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{})
mdmAnyMW.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/configuration_profiles/resend/{profile_uuid}", resendHostMDMProfileEndpoint, resendHostMDMProfileRequest{})
// Deprecated: PATCH /mdm/apple/settings is deprecated, replaced by POST /disk_encryption.
// It was only used to set disk encryption.
mdmAnyMW.PATCH("/api/_version_/fleet/mdm/apple/settings", updateMDMAppleSettingsEndpoint, updateMDMAppleSettingsRequest{})

View file

@ -725,6 +725,153 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
// add a new profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID)
wantTeamProfiles = append(wantTeamProfiles, prof)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID))
return err
})
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
require.Equal(t, prof, installs[0])
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend profile while verifying
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the profile to pending, can't resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil)
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the profile to failed, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
require.Equal(t, prof, installs[0])
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend profile while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the profile to verified, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID)
return err
})
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
require.Equal(t, prof, installs[0])
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
s.lastActivityMatches(
fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID),
0)
// add a declaration to the team
declIdent := "decl-ident-" + uuid.NewString()
fields := map[string][]string{
"team_id": {fmt.Sprintf("%d", tm.ID)},
}
body, headers := generateNewProfileMultipartRequest(
t, "some-declaration.json", declarationForTest(declIdent), s.token, fields,
)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
var resp newMDMConfigProfileResponse
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, "d", string(resp.ProfileUUID[0]))
declUUID := resp.ProfileUUID
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger))
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, fmt.Sprintf("expected no more commands, but got: %+v", cmd))
_, err = d.DeclarativeManagement("tokens")
require.NoError(t, err)
}
checkDDMSync(mdmDevice)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend declaration while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the declaration to verified, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID)
return err
})
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusAccepted)
checkDDMSync(mdmDevice)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
s.lastActivityMatches(
fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": "some-declaration"}`, host.ID, host.DisplayName()),
0)
// transfer the host to the global team
err = s.ds.AddHostsToTeam(ctx, nil, []uint{host.ID})
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, len(wantGlobalProfiles))
require.ElementsMatch(t, wantGlobalProfiles, installs)
require.Len(t, removes, len(wantTeamProfiles))
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{Verifying: 1}, nil) // host now verifying global profiles
// can't resend profile from another team
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Unable to match profile to host")
// add a Windows profile, resend not supported when host is macOS
wpUUID := mysql.InsertWindowsProfileForTest(t, s.ds, 0)
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, wpUUID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Profile is not compatible with host platform")
// invalid profile UUID prefix should return 404
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Invalid profile UUID prefix")
}
func (s *integrationMDMTestSuite) TestAppleProfileRetries() {
@ -10688,6 +10835,11 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
checkHostsProfilesMatch(host, globalProfiles)
checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
// can't resend a profile while it is verifying
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusConflict)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// create new label that includes host
label := &fleet.Label{
Name: t.Name() + "foo",
@ -10750,6 +10902,11 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
Verifying: 0,
}, nil)
// can resend a profile after it has failed
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusAccepted)
verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
require.NoError(t, err)
@ -10777,6 +10934,16 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
checkHostsProfilesMatch(host, teamProfiles)
checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
// can't resend a profile while it is verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// can't resend a profile from the wrong team
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Unable to match profile to host.")
// another sync shouldn't return profiles
verifyProfiles(mdmDevice, 0, false)
@ -10844,6 +11011,32 @@ func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
Failed: 1,
Verifying: 0,
}, nil)
// can resend a profile after it has failed
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so back to verifying
s.lastActivityMatches(
fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+teamProfiles[0]),
0)
// add a macOS profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID))
return err
})
// trigger a profile sync, device doesn't get the macOS profile
verifyProfiles(mdmDevice, 0, false)
// can't resend a macOS profile to a Windows host
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Profile is not compatible with host platform")
}
func (s *integrationMDMTestSuite) TestAppConfigMDMWindowsProfiles() {

View file

@ -1980,3 +1980,130 @@ func (svc *Service) UpdateMDMDiskEncryption(ctx context.Context, teamID *uint, e
}
return svc.updateAppConfigMDMDiskEncryption(ctx, enableDiskEncryption)
}
////////////////////////////////////////////////////////////////////////////////
// POST /hosts/{id:[0-9]+}/configuration_profiles/{profile_uuid}
////////////////////////////////////////////////////////////////////////////////
type resendHostMDMProfileRequest struct {
HostID uint `url:"host_id"`
ProfileUUID string `url:"profile_uuid"`
}
type resendHostMDMProfileResponse struct {
Err error `json:"error,omitempty"`
}
func (r resendHostMDMProfileResponse) error() error { return r.Err }
func (r resendHostMDMProfileResponse) Status() int { return http.StatusAccepted }
func resendHostMDMProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*resendHostMDMProfileRequest)
if err := svc.ResendHostMDMProfile(ctx, req.HostID, req.ProfileUUID); err != nil {
return resendHostMDMProfileResponse{Err: err}, nil
}
return resendHostMDMProfileResponse{}, nil
}
func (svc *Service) ResendHostMDMProfile(ctx context.Context, hostID uint, profileUUID string) error {
// first we perform a perform basic authz check, we use selective list action to include gitops users
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
return ctxerr.Wrap(ctx, err)
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return ctxerr.Wrap(ctx, err)
}
// now we can do a specific authz check based on team id of the host before proceeding
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err)
}
var profileTeamID *uint
var profileName string
switch {
case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix):
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple mdm enabled")
}
if host.Platform != "darwin" {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform")
}
prof, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting apple config profile")
}
profileTeamID = prof.TeamID
profileName = prof.Name
case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix):
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check apple mdm enabled")
}
if host.Platform != "darwin" {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform")
}
decl, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting apple declaration")
}
profileTeamID = decl.TeamID
profileName = decl.Name
case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix):
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest), "check windows mdm enabled")
}
if host.Platform != "windows" {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Profile is not compatible with host platform."), "check host platform")
}
prof, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting windows config profile")
}
profileTeamID = prof.TeamID
profileName = prof.Name
default:
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Invalid profile UUID prefix.").WithStatus(http.StatusNotFound), "check profile UUID prefix")
}
// check again based on team id of profile before we proceeding
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: profileTeamID}, fleet.ActionWrite); err != nil {
return ctxerr.Wrap(ctx, err, "authorizing profile team")
}
status, err := svc.ds.GetHostMDMProfileInstallStatus(ctx, host.UUID, profileUUID)
if err != nil {
if fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Unable to match profile to host.").WithStatus(http.StatusNotFound), "getting host mdm profile status")
}
return ctxerr.Wrap(ctx, err, "getting host mdm profile status")
}
if status == fleet.MDMDeliveryPending || status == fleet.MDMDeliveryVerifying {
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("HostMDMProfile", "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.").WithStatus(http.StatusConflict), "check profile status")
}
if status != fleet.MDMDeliveryFailed && status != fleet.MDMDeliveryVerified {
// this should never happen, but just in case
return ctxerr.Errorf(ctx, "unrecognized profile status %s", status)
}
if err := svc.ds.ResendHostMDMProfile(ctx, host.UUID, profileUUID); err != nil {
return ctxerr.Wrap(ctx, err, "resending host mdm profile")
}
if err := svc.ds.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeResentConfigurationProfile{
HostID: &host.ID,
HostDisplayName: ptr.String(host.DisplayName()),
ProfileName: profileName,
}); err != nil {
return ctxerr.Wrap(ctx, err, "logging activity for resend config profile")
}
return nil
}

View file

@ -1524,3 +1524,215 @@ func TestBackwardsCompatProfilesParamUnmarshalJSON(t *testing.T) {
})
}
}
func TestMDMResendConfigProfileAuthz(t *testing.T) {
ds := new(mock.Store)
// while the config profiles are not premium-only, teams are and we want to test with teams.
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
testCases := []struct {
name string
user *fleet.User
shouldFailGlobalRead bool
shouldFailTeamRead bool
shouldFailGlobalWrite bool
shouldFailTeamWrite bool
}{
{
"global admin",
&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
false,
false,
false,
false,
},
{
"global maintainer",
&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
false,
false,
false,
false,
},
{
"global observer",
&fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
true,
true,
true,
true,
},
{
"global observer+",
&fleet.User{GlobalRole: ptr.String(fleet.RoleObserverPlus)},
true,
true,
true,
true,
},
{
// this is authorized because gitops can access hosts by identifier (the
// first authorization check) and then gitops have write-access the
// profiles.
"global gitops",
&fleet.User{GlobalRole: ptr.String(fleet.RoleGitOps)},
false,
false,
false,
false,
},
{
"team admin, belongs to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
true,
false,
true,
false,
},
{
"team admin, DOES NOT belong to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
true,
true,
true,
true,
},
{
"team maintainer, belongs to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
true,
false,
true,
false,
},
{
"team maintainer, DOES NOT belong to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
true,
true,
true,
true,
},
{
"team observer, belongs to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
true,
true,
true,
true,
},
{
"team observer, DOES NOT belong to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
true,
true,
true,
true,
},
{
"team observer+, belongs to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserverPlus}}},
true,
true,
true,
true,
},
{
"team observer+, DOES NOT belong to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserverPlus}}},
true,
true,
true,
true,
},
{
// this is authorized because gitops can access hosts by identifier (the
// first authorization check) and then gitops have write-access the
// profiles.
"team gitops, belongs to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleGitOps}}},
true,
false,
true,
false,
},
{
"team gitops, DOES NOT belong to team",
&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleGitOps}}},
true,
true,
true,
true,
},
{
"user no roles",
&fleet.User{ID: 1337},
true,
true,
true,
true,
},
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
MDM: fleet.MDM{
EnabledAndConfigured: true,
WindowsEnabledAndConfigured: true,
},
}, nil
}
ds.HostLiteFunc = func(ctx context.Context, hid uint) (*fleet.Host, error) {
if hid == 1 {
return &fleet.Host{ID: hid, UUID: "host-uuid-1", Platform: "darwin", TeamID: ptr.Uint(1)}, nil
} else if hid == 1337 {
return &fleet.Host{ID: hid, UUID: "host-uuid-no-team", Platform: "darwin", TeamID: nil}, nil
}
return nil, &notFoundErr{}
}
ds.GetMDMAppleConfigProfileFunc = func(ctx context.Context, pid string) (*fleet.MDMAppleConfigProfile, error) {
var tid uint
if pid == "a-team-1-profile" {
tid = 1
}
return &fleet.MDMAppleConfigProfile{
ProfileUUID: pid,
TeamID: &tid,
}, nil
}
ds.GetHostMDMProfileInstallStatusFunc = func(ctx context.Context, hostUUID string, profUUID string) (fleet.MDMDeliveryStatus, error) {
return fleet.MDMDeliveryFailed, nil
}
ds.ResendHostMDMProfileFunc = func(ctx context.Context, hostUUID, profUUID string) error {
return nil
}
ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error {
return nil
}
checkShouldFail := func(t *testing.T, err error, shouldFail bool) {
if !shouldFail {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
}
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user})
// ds.TeamFunc = mockTeamFuncWithUser(tt.user)
// test authz resend config profile (no team)
err := svc.ResendHostMDMProfile(ctx, 1337, "a-no-team-profile")
checkShouldFail(t, err, tt.shouldFailGlobalWrite)
// test authz resend config profile (team 1)
err = svc.ResendHostMDMProfile(ctx, 1, "a-team-1-profile")
checkShouldFail(t, err, tt.shouldFailTeamWrite)
})
}
}