diff --git a/changes/17897-api-resend-mdm-profile b/changes/17897-api-resend-mdm-profile new file mode 100644 index 0000000000..8bbdf7dd1a --- /dev/null +++ b/changes/17897-api-resend-mdm-profile @@ -0,0 +1 @@ +- Added API to support resending MDM profiles. diff --git a/changes/issue-17896-ui-resend-profile b/changes/issue-17896-ui-resend-profile new file mode 100644 index 0000000000..3911edd2bf --- /dev/null +++ b/changes/issue-17896-ui-resend-profile @@ -0,0 +1 @@ +- add UI for resending a profile for a host on the host details page in the OS Settings modal diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index 700f2e869a..d0bda3d634 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -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" +} +``` + diff --git a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx index 64dda5d065..0a78718146 100644 --- a/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx +++ b/frontend/components/TableContainer/DataTable/TooltipTruncatedTextCell/TooltipTruncatedTextCell.tsx @@ -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, }); diff --git a/frontend/components/TableContainer/TableContainer.tsx b/frontend/components/TableContainer/TableContainer.tsx index 4435260157..a78c023c1f 100644 --- a/frontend/components/TableContainer/TableContainer.tsx +++ b/frontend/components/TableContainer/TableContainer.tsx @@ -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"; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index dd871d7be6..841c326ab2 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -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 diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 9998278692..9827afc2ec 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -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); diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index b9fa3811bc..629f4956b5 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -445,10 +445,12 @@ const DeviceUserPage = ({ policy={selectedPolicy} /> )} - {showOSSettingsModal && ( + {!!host && showOSSettingsModal && ( )} diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 6a2839035e..5370d3f6b9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -932,9 +932,12 @@ const HostDetailsPage = ({ )} {showOSSettingsModal && ( )} {showUnenrollMdmModal && !!host && ( diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx index cc2db63d17..a9c916f570 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsModal.tsx @@ -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" > <> - +
+ ); +}; + +/** + * 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( + + {key.trim()}: {value.trim()} + {/* dont add the trailing comma for the last element */} + {i !== keyValuePairs.length - 1 && ( + <> + ,
+ + )} +
+ ); + } + }); + + 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 ( +
+ + {showRefetchButton && ( + + )} +
+ ); +}; + +export default OSSettingsErrorCell; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss new file mode 100644 index 0000000000..c78f92985b --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/_styles.scss @@ -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; + } + } + } +} diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts new file mode 100644 index 0000000000..aabe7454f5 --- /dev/null +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsErrorCell/index.ts @@ -0,0 +1 @@ +export { default } from "./OSSettingsErrorCell"; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx index 7aea4d34ad..ee667c5217 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/OSSettingsTable.tsx @@ -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 (
; type ITableStringCellProps = IStringCellProps; +/** 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( - - {key.trim()}: {value.trim()} - {/* dont add the trailing comma for the last element */} - {i !== keyValuePairs.length - 1 && ( - <> - ,
- - )} -
- ); - } - }); - - 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 ; +const generateTableConfig = ( + hostId: number, + canResendProfiles: boolean, + onProfileResent?: () => void +): ITableColumnConfig[] => { + return [ + { + Header: "Name", + disableSortBy: true, + accessor: "name", + Cell: (cellProps: ITableStringCellProps) => { + return ; + }, }, - }, - { - Header: "Status", - disableSortBy: true, - accessor: "status", - Cell: (cellProps: ITableStringCellProps) => { - return ( - { + return ( + + ); + }, + }, + { + Header: "Error", + disableSortBy: true, + accessor: "detail", + Cell: (cellProps: ITableStringCellProps) => ( + - ); + ), }, - }, - { - 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 ( - - ); - }, - }, -]; + ]; +}; 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; diff --git a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss index a01007917a..1e8c8aeed2 100644 --- a/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss +++ b/frontend/pages/hosts/details/OSSettingsModal/OSSettingsTable/_styles.scss @@ -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; + } + } } diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index c5598a3aee..cd7be3beb2 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -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)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 58c2b4c5db..f1169b31e0 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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`, diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 0ef788c464..ef16b993e8 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -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) + } +} diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index a1eadba60d..4671b63b77 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -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 }) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index f299222c24..00a2964455 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -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) { diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index a646bddcad..97708a98f7 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -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 diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 3292e8740d..0b6d83f228 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -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 diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index baa501bb4b..7d66519313 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -12,6 +12,10 @@ import ( const ( MDMPlatformApple = "apple" MDMPlatformMicrosoft = "microsoft" + + MDMAppleDeclarationUUIDPrefix = "d" + MDMAppleProfileUUIDPrefix = "a" + MDMWindowsProfileUUIDPrefix = "w" ) type AppleMDM struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index c38c9550c5..e05fa0b376 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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 diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1ed9fe53eb..2fb532210e 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -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 diff --git a/server/service/handler.go b/server/service/handler.go index a67cf5cc8f..ecd586e472 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -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{}) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 1c428b66bc..d1a0e4ddc2 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -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() { diff --git a/server/service/mdm.go b/server/service/mdm.go index 821ba0b1d8..322298d02b 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -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 +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 472981e518..013571ff31 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -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) + }) + } +}