mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 01:18:42 +00:00
Add feature to resend MDM configuration profiles (#18280)
Includes PRs #18111, #18212, and #18271
This commit is contained in:
commit
fa5e224a4a
32 changed files with 1034 additions and 141 deletions
1
changes/17897-api-resend-mdm-profile
Normal file
1
changes/17897-api-resend-mdm-profile
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Added API to support resending MDM profiles.
|
||||
1
changes/issue-17896-ui-resend-profile
Normal file
1
changes/issue-17896-ui-resend-profile
Normal file
|
|
@ -0,0 +1 @@
|
|||
- add UI for resending a profile for a host on the host details page in the OS Settings modal
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./OSSettingsErrorCell";
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import (
|
|||
const (
|
||||
MDMPlatformApple = "apple"
|
||||
MDMPlatformMicrosoft = "microsoft"
|
||||
|
||||
MDMAppleDeclarationUUIDPrefix = "d"
|
||||
MDMAppleProfileUUIDPrefix = "a"
|
||||
MDMWindowsProfileUUIDPrefix = "w"
|
||||
)
|
||||
|
||||
type AppleMDM struct {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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() {
|
||||
|
|
|
|||
|
|
@ -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", "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¬FoundErr{}
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue