From 0d459359a48bc2fab0e13fca031b19f2d40c3759 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Wed, 20 Nov 2024 11:41:40 +0000 Subject: [PATCH 1/4] Feat UI create policies fleet app (#23842) relates to #23136 This is the UI for the creating of policies when adding fleet maintained software. This includes only the creating of the policy and there will be another PR for viewing more information on the software titles details page. **new install type options. this determines if a policy should be created or not.** ![image](https://github.com/user-attachments/assets/20538c66-bc1c-4903-aa70-83d24da97617) there are also some new icons for the software titles. - [] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [x] Manual QA for all new/changed functionality --- frontend/__mocks__/softwareMock.ts | 1 + .../SoftwareNameCell/SoftwareNameCell.tsx | 82 ++++-- .../forms/fields/Radio/Radio.tests.tsx | 2 +- .../components/forms/fields/Radio/Radio.tsx | 49 ++-- .../forms/fields/Radio/_styles.scss | 32 ++- .../components/icons/AutomaticSelfService.tsx | 32 +++ frontend/components/icons/User.tsx | 30 +++ frontend/components/icons/index.ts | 4 + frontend/interfaces/policy.ts | 1 - frontend/interfaces/software.ts | 6 + .../FleetAppDetailsForm.tsx | 40 +++ .../FleetAppDetailsForm/_styles.scss | 18 ++ .../FleetMaintainedAppDetailsPage.tsx | 88 +++++-- .../FleetMaintainedAppDetailsPage/helpers.tsx | 41 +++ .../SoftwareTitleDetailsPage/helpers.tests.ts | 1 + frontend/services/entities/team_policies.ts | 2 + server/mdm/maintainedapps/apps.json | 236 ++++++++++-------- 17 files changed, 490 insertions(+), 175 deletions(-) create mode 100644 frontend/components/icons/AutomaticSelfService.tsx create mode 100644 frontend/components/icons/User.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers.tsx diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 66a193046f..e789572138 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -212,6 +212,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { pending_uninstall: 1, failed_uninstall: 1, }, + automatic_install_policies: [], }; export const createMockSoftwarePackage = ( diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx index 2b1ce645fc..ca9bcf5065 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx @@ -1,23 +1,71 @@ import React from "react"; import { InjectedRouter } from "react-router"; import ReactTooltip from "react-tooltip"; - import { uniqueId } from "lodash"; -import { ISoftwarePackage } from "interfaces/software"; - import Icon from "components/Icon"; +import { IconNames } from "components/icons"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; import LinkCell from "../LinkCell"; const baseClass = "software-name-cell"; +type InstallType = + | "manual" + | "selfService" + | "automatic" + | "automaticSelfService"; + +interface installIconConfig { + iconName: IconNames; + tooltip: JSX.Element; +} + +const installIconMap: Record = { + manual: { + iconName: "install", + tooltip: <>Software can be installed on Host details page., + }, + selfService: { + iconName: "user", + tooltip: ( + <> + End users can install from Fleet Desktop {">"} Self-service. + + ), + }, + automatic: { + iconName: "refresh", + tooltip: <>Software will be automatically installed on each host., + }, + automaticSelfService: { + iconName: "automatic-self-service", + tooltip: ( + <> + Software will be automatically installed on each host. End users can + reinstall from Fleet Desktop {">"} Self-service. + + ), + }, +}; + +interface IInstallIconWithTooltipProps { + isSelfService: boolean; + installType?: "manual" | "automatic"; +} + const InstallIconWithTooltip = ({ isSelfService, -}: { - isSelfService: ISoftwarePackage["self_service"]; -}) => { + installType, +}: IInstallIconWithTooltipProps) => { + let iconType: InstallType = "manual"; + if (installType === "automatic") { + iconType = isSelfService ? "automaticSelfService" : "automatic"; + } else if (isSelfService) { + iconType = "selfService"; + } + const tooltipId = uniqueId(); return (
@@ -27,8 +75,9 @@ const InstallIconWithTooltip = ({ data-for={tooltipId} >
- {isSelfService ? ( - <> - End users can install from Fleet Desktop {">"} Self-service - . - - ) : ( - <> - Install manually on Host details page or automatically with - policy automations. - - )} + {installIconMap[iconType].tooltip} @@ -65,6 +104,7 @@ interface ISoftwareNameCellProps { router?: InjectedRouter; hasPackage?: boolean; isSelfService?: boolean; + installType?: "manual" | "automatic"; iconUrl?: string; } @@ -75,6 +115,7 @@ const SoftwareNameCell = ({ router, hasPackage = false, isSelfService = false, + installType, iconUrl, }: ISoftwareNameCellProps) => { // NO path or router means it's not clickable. return @@ -104,7 +145,10 @@ const SoftwareNameCell = ({ {name} {hasPackage && ( - + )} } diff --git a/frontend/components/forms/fields/Radio/Radio.tests.tsx b/frontend/components/forms/fields/Radio/Radio.tests.tsx index 0bb07fb99b..493d5beb32 100644 --- a/frontend/components/forms/fields/Radio/Radio.tests.tsx +++ b/frontend/components/forms/fields/Radio/Radio.tests.tsx @@ -73,7 +73,7 @@ describe("Radio - component", () => { // Also adds a disabled class to the componet const radioComponent = screen.getByTestId("radio-input"); - expect(radioComponent).toHaveClass("disabled"); + expect(radioComponent).toHaveClass("radio__disabled"); }); it("render a tooltip from the tooltip prop", async () => { diff --git a/frontend/components/forms/fields/Radio/Radio.tsx b/frontend/components/forms/fields/Radio/Radio.tsx index 86dd93c4da..d2f5a4daa3 100644 --- a/frontend/components/forms/fields/Radio/Radio.tsx +++ b/frontend/components/forms/fields/Radio/Radio.tsx @@ -16,6 +16,7 @@ export interface IRadioProps { className?: string; disabled?: boolean; tooltip?: React.ReactNode; + helpText?: React.ReactNode; testId?: string; } @@ -28,35 +29,39 @@ const Radio = ({ disabled, label, tooltip, + helpText, testId, onChange, }: IRadioProps): JSX.Element => { const wrapperClasses = classnames(baseClass, className, { - [`disabled`]: disabled, + [`${baseClass}__disabled`]: disabled, }); return ( - +
+ + {helpText &&
{helpText}
} +
); }; diff --git a/frontend/components/forms/fields/Radio/_styles.scss b/frontend/components/forms/fields/Radio/_styles.scss index e254fb2642..57c116ab25 100644 --- a/frontend/components/forms/fields/Radio/_styles.scss +++ b/frontend/components/forms/fields/Radio/_styles.scss @@ -3,8 +3,12 @@ .radio { font-size: $x-small; - display: flex; - align-items: center; + + // this includes the control button and the radio label text + &__radio-control { + display: flex; + align-items: center; + } &__input { display: flex; @@ -15,7 +19,7 @@ height: 0; position: absolute; - & + .radio__control::before { + & + .radio__control-button::before { position: absolute; content: ""; width: 10px; @@ -29,17 +33,17 @@ transform: scale(0); } - &:checked + .radio__control::before { + &:checked + .radio__control-button::before { transform: scale(1); } - &:focus + .radio__control { + &:focus + .radio__control-button { border-color: $core-vibrant-blue; } } } - &__control { + &__control-button { position: relative; display: flex; width: 16px; @@ -53,4 +57,20 @@ margin-left: $pad-small; line-height: 1; } + + &__help-text { + color: $ui-fleet-black-75; + margin-top: $pad-xxsmall; + margin-left: calc(20px + #{$pad-small}); + } + + &__disabled { + .radio__label { + color: $ui-fleet-black-50; + } + + .radio__help-text { + color: $ui-fleet-black-50; + } + } } diff --git a/frontend/components/icons/AutomaticSelfService.tsx b/frontend/components/icons/AutomaticSelfService.tsx new file mode 100644 index 0000000000..01926aaf1f --- /dev/null +++ b/frontend/components/icons/AutomaticSelfService.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { COLORS, Colors } from "styles/var/colors"; +import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes"; + +interface IAutomaticSelfServiceProps { + size?: IconSizes; + color?: Colors; +} + +const AutomaticSelfService = ({ + size = "medium", + color = "ui-fleet-black-75", +}: IAutomaticSelfServiceProps) => { + return ( + + + + ); +}; + +export default AutomaticSelfService; diff --git a/frontend/components/icons/User.tsx b/frontend/components/icons/User.tsx new file mode 100644 index 0000000000..d936603b52 --- /dev/null +++ b/frontend/components/icons/User.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import { COLORS, Colors } from "styles/var/colors"; +import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes"; + +interface IUserProps { + size?: IconSizes; + color?: Colors; +} + +const User = ({ size = "medium", color = "ui-fleet-black-75" }: IUserProps) => { + return ( + + + + ); +}; + +export default User; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index f61fa59e30..df221cd38f 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -64,6 +64,8 @@ import Refresh from "./Refresh"; import Install from "./Install"; import InstallSelfService from "./InstallSelfService"; import Settings from "./Settings"; +import AutomaticSelfService from "./AutomaticSelfService"; +import User from "./User"; // a mapping of the usable names of icons to the icon source. export const ICON_MAP = { @@ -134,6 +136,8 @@ export const ICON_MAP = { install: Install, "install-self-service": InstallSelfService, settings: Settings, + "automatic-self-service": AutomaticSelfService, + user: User, }; export type IconNames = keyof typeof ICON_MAP; diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index d501374a57..56bb49fae2 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -105,7 +105,6 @@ export interface IPolicyFormData { team_id?: number | null; id?: number; calendar_events_enabled?: boolean; - // undefined from GET/LIST when not set, null for PATCH to unset software_title_id?: number | null; // null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script script_id?: number | null; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index e7a278840e..75f51d47ee 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -55,6 +55,11 @@ export interface ISoftwareTitleVersion { hosts_count?: number; } +export interface ISoftwarePackagePolicy { + id: number; + name: string; +} + export interface ISoftwarePackage { name: string; version: string; @@ -71,6 +76,7 @@ export interface ISoftwarePackage { pending_uninstall: number; failed_uninstall: number; }; + automatic_install_policies: ISoftwarePackagePolicy[]; install_during_setup?: boolean; } diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx index 0ebd284397..7ab76d8495 100644 --- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx +++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/FleetAppDetailsForm/FleetAppDetailsForm.tsx @@ -4,6 +4,7 @@ import Checkbox from "components/forms/fields/Checkbox"; import TooltipWrapper from "components/TooltipWrapper"; import RevealButton from "components/buttons/RevealButton"; import Button from "components/buttons/Button"; +import Radio from "components/forms/fields/Radio"; import AdvancedOptionsFields from "pages/SoftwarePage/components/AdvancedOptionsFields"; @@ -17,6 +18,7 @@ export interface IFleetMaintainedAppFormData { preInstallQuery?: string; postInstallScript?: string; uninstallScript?: string; + installType: string; } export interface IFormValidation { @@ -51,6 +53,7 @@ const FleetAppDetailsForm = ({ installScript: defaultInstallScript, postInstallScript: defaultPostInstallScript, uninstallScript: defaultUninstallScript, + installType: "manual", }); const [formValidation, setFormValidation] = useState({ isValid: true, @@ -87,6 +90,11 @@ const FleetAppDetailsForm = ({ setFormValidation(generateFormValidation(newData)); }; + const onChangeInstallType = (value: string) => { + const newData = { ...formData, installType: value }; + setFormData(newData); + }; + const onSubmitForm = (evt: React.FormEvent) => { evt.preventDefault(); onSubmit(formData); @@ -96,6 +104,38 @@ const FleetAppDetailsForm = ({ return (
+
+ Install +
+ + + Automatically install on each host that's{" "} + + missing this software. + {" "} + Policy that triggers install can be customized after software is + added. + + } + /> +
+
softwareAPI.getFleetMainainedApp(appId), { @@ -131,25 +138,70 @@ const FleetMaintainedAppDetailsPage = ({ setShowAddFleetAppSoftwareModal(true); + const { installType } = formData; try { await softwareAPI.addFleetMaintainedApp(parseInt(teamId, 10), { ...formData, appId, }); + + // for manual install we redirect only on a successful software add. + if (installType === "manual") { + router.push( + `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ + team_id: teamId, + available_for_install: true, + })}` + ); + renderFlash( + "success", + <> + {fleetApp?.name} successfully added. + + ); + } + } catch (error) { + // quick exit if there was an error adding the software. Skip the policy + // creation. + renderFlash("error", getErrorReason(error)); + setShowAddFleetAppSoftwareModal(false); + return; + } + + // If the install type is automatic we now need to create the new policy. + if (installType === "automatic" && fleetApp) { + try { + await teamPoliciesAPI.create({ + name: getFleetAppPolicyName(fleetApp.name), + description: getFleetAppPolicyDescription(fleetApp.name), + query: getFleetAppPolicyQuery(fleetApp.name), + team_id: parseInt(teamId, 10), + software_title_id: fleetApp.id, // TODO: this needs to to be the software title id and not the app id. API changes needed. + platform: "darwin", + }); + + renderFlash( + "success", + <> + {fleetApp?.name} successfully added. + + ); + } catch (e) { + renderFlash( + "error", + "Couldn't add automatic install policy. Software is successfuly added. To try again delete software and add it again.", + { persistOnPageChange: true } + ); + } + + // for automatic install we redirect on both a successful and error policy + // add because the software was already successfuly added. router.push( `${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({ team_id: teamId, available_for_install: true, })}` ); - renderFlash( - "success", - <> - {data?.name} successfully added. - - ); - } catch (error) { - renderFlash("error", getErrorReason(error)); // TODO: handle error messages } setShowAddFleetAppSoftwareModal(false); @@ -168,7 +220,7 @@ const FleetMaintainedAppDetailsPage = ({ return ; } - if (data) { + if (fleetApp) { return ( <> -

{data.name}

+

{fleetApp.name}

setSidePanelOpen(true)} onCancel={onCancel} onSubmit={onSubmit} @@ -205,7 +257,7 @@ const FleetMaintainedAppDetailsPage = ({ <>{renderContent()} - {isPremiumTier && data && isSidePanelOpen && ( + {isPremiumTier && fleetApp && isSidePanelOpen && ( = { + "1Password": "1password", + "Adobe Acrobat Reader": "adobe-acrobat-reader", + "Box Drive": "box-drive", + Brave: "brave-browser", + "Cloudflare WARP": "cloudflare-warp", + "Docker Desktop": "docker", + Figma: "figma", + "Mozilla Firefox": "firefox", + "Google Chrome": "google-chrome", + "Microsoft Edge": "microsoft-edge", + "Microsoft Excel": "microsoft-excel", + "Microsoft Teams": "microsoft-teams", + "Microsoft Word": "microsoft-word", + Notion: "notion", + Postman: "postman", + Slack: "slack", + TeamViewer: "teamviewer", + "Microsoft Visual Studio Code": "visual-studio-code", + WhatsApp: "whatsapp", + Zoom: "zoom", +}; + +const getFleetAppData = (name: string) => { + const appId = NameToIdentifierMap[name]; // TODO: need a better matching mechanism here + return fleetAppData.find((app) => app.identifier === appId); +}; + +export const getFleetAppPolicyName = (appName: string) => { + return `[Install software] ${appName}`; +}; + +export const getFleetAppPolicyDescription = (appName: string) => { + return `Policy triggers automatic install of ${appName} on each host that's missing this software.`; +}; + +export const getFleetAppPolicyQuery = (name: string) => { + return getFleetAppData(name)?.automatic_policy_query; +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index b87ee91649..8dcf561167 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -22,6 +22,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { }, install_script: "echo foo", icon_url: "https://example.com/icon.png", + automatic_install_policies: [], }, app_store_app: null, source: "apps", diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 664c298589..5fb8ef226a 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -64,6 +64,7 @@ export default { resolution, platform, critical, + software_title_id, // note absence of automations-related fields, which are only set by the UI via update } = data; const { TEAMS } = endpoints; @@ -76,6 +77,7 @@ export default { resolution, platform, critical, + software_title_id, }); }, update: (id: number, data: IPolicyFormData) => { diff --git a/server/mdm/maintainedapps/apps.json b/server/mdm/maintainedapps/apps.json index ce8eaf816e..d7d06e1cfb 100644 --- a/server/mdm/maintainedapps/apps.json +++ b/server/mdm/maintainedapps/apps.json @@ -1,110 +1,130 @@ [ - { - "identifier": "1password", - "bundle_identifier": "com.1password.1password", - "installer_format": "zip:app" - }, - { - "identifier": "adobe-acrobat-reader", - "bundle_identifier": "com.adobe.Reader", - "installer_format": "dmg:pkg" - }, - { - "identifier": "box-drive", - "bundle_identifier": "com.box.desktop", - "installer_format": "pkg", - "pre_uninstall_scripts": [ - "(cd /Users/$LOGGED_IN_USER; sudo -u $LOGGED_IN_USER fileproviderctl domain remove -A com.box.desktop.boxfileprovider)", - "(cd /Users/$LOGGED_IN_USER; sudo -u $LOGGED_IN_USER /Applications/Box.app/Contents/MacOS/fpe/streem --remove-fpe-domain-and-archive-unsynced-content Box)", - "(cd /Users/$LOGGED_IN_USER; sudo -u $LOGGED_IN_USER /Applications/Box.app/Contents/MacOS/fpe/streem --remove-fpe-domain-and-preserve-unsynced-content Box)", - "(cd /Users/$LOGGED_IN_USER; defaults delete com.box.desktop)", - "echo \"${LOGGED_IN_USER} ALL = (root) NOPASSWD: /Library/Application\\ Support/Box/uninstall_box_drive_r\" >> /etc/sudoers.d/box_uninstall" - ], - "post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"] - }, - { - "identifier": "brave-browser", - "bundle_identifier": "com.brave.Browser", - "installer_format": "dmg:app" - }, - { - "identifier": "cloudflare-warp", - "bundle_identifier": "com.cloudflare.1dot1dot1dot1.macos", - "installer_format": "pkg" - }, - { - "identifier": "docker", - "bundle_identifier": "com.docker.docker", - "installer_format": "dmg:app" - }, - { - "identifier": "figma", - "bundle_identifier": "com.figma.Desktop", - "installer_format": "zip:app" - }, - { - "identifier": "firefox", - "bundle_identifier": "org.mozilla.firefox", - "installer_format": "dmg:app" - }, - { - "identifier": "google-chrome", - "bundle_identifier": "com.google.Chrome", - "installer_format": "dmg:app" - }, - { - "identifier": "microsoft-edge", - "bundle_identifier": "com.microsoft.edgemac", - "installer_format": "pkg" - }, - { - "identifier": "microsoft-excel", - "bundle_identifier": "com.microsoft.Excel", - "installer_format": "pkg" - }, - { - "identifier": "microsoft-teams", - "bundle_identifier": "com.microsoft.teams2", - "installer_format": "pkg" - }, - { - "identifier": "microsoft-word", - "bundle_identifier": "com.microsoft.Word", - "installer_format": "pkg" - }, - { - "identifier": "notion", - "bundle_identifier": "notion.id", - "installer_format": "dmg:app" - }, - { - "identifier": "postman", - "bundle_identifier": "com.postmanlabs.mac", - "installer_format": "zip:app" - }, - { - "identifier": "slack", - "bundle_identifier": "com.tinyspeck.slackmacgap", - "installer_format": "dmg:app" - }, - { - "identifier": "teamviewer", - "bundle_identifier": "com.teamviewer.TeamViewer", - "installer_format": "pkg" - }, - { - "identifier": "visual-studio-code", - "bundle_identifier": "com.microsoft.VSCode", - "installer_format": "zip:app" - }, - { - "identifier": "whatsapp", - "bundle_identifier": "net.whatsapp.WhatsApp", - "installer_format": "zip:app" - }, - { - "identifier": "zoom", - "bundle_identifier": "us.zoom.xos", - "installer_format": "pkg" - } + { + "identifier": "1password", + "bundle_identifier": "com.1password.1password", + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.1password.1password';" + }, + { + "identifier": "adobe-acrobat-reader", + "bundle_identifier": "com.adobe.Reader", + "installer_format": "dmg:pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.adobe.Reader';" + }, + { + "identifier": "box-drive", + "bundle_identifier": "com.box.desktop", + "installer_format": "pkg", + "pre_uninstall_scripts": [ + "(cd /Users/$LOGGED_IN_USER; sudo -u $LOGGED_IN_USER fileproviderctl domain remove -A com.box.desktop.boxfileprovider)", + "(cd /Users/$LOGGED_IN_USER; sudo -u $LOGGED_IN_USER /Applications/Box.app/Contents/MacOS/fpe/streem --remove-fpe-domain-and-archive-unsynced-content Box)", + "(cd /Users/$LOGGED_IN_USER; sudo -u $LOGGED_IN_USER /Applications/Box.app/Contents/MacOS/fpe/streem --remove-fpe-domain-and-preserve-unsynced-content Box)", + "(cd /Users/$LOGGED_IN_USER; defaults delete com.box.desktop)", + "echo \"${LOGGED_IN_USER} ALL = (root) NOPASSWD: /Library/Application\\ Support/Box/uninstall_box_drive_r\" >> /etc/sudoers.d/box_uninstall" + ], + "post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"], + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.box.desktop';" + }, + { + "identifier": "brave-browser", + "bundle_identifier": "com.brave.Browser", + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.brave.Browser';" + }, + { + "identifier": "cloudflare-warp", + "bundle_identifier": "com.cloudflare.1dot1dot1dot1.macos", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.cloudflare.1dot1dot1dot1.macos';" + }, + { + "identifier": "docker", + "bundle_identifier": "com.docker.docker", + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.docker.docker';" + }, + { + "identifier": "figma", + "bundle_identifier": "com.figma.Desktop", + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.figma.Desktop';" + }, + { + "identifier": "firefox", + "bundle_identifier": "org.mozilla.firefox", + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefox';" + }, + { + "identifier": "google-chrome", + "bundle_identifier": "com.google.Chrome", + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.google.Chrome';" + }, + { + "identifier": "microsoft-edge", + "bundle_identifier": "com.microsoft.edgemac", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.edgemac';" + }, + { + "identifier": "microsoft-excel", + "bundle_identifier": "com.microsoft.Excel", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Excel';" + }, + { + "identifier": "microsoft-teams", + "bundle_identifier": "com.microsoft.teams2", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.teams2';" + }, + { + "identifier": "microsoft-word", + "bundle_identifier": "com.microsoft.Word", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Word';" + }, + { + "identifier": "notion", + "bundle_identifier": "notion.id", + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'notion.id';" + }, + { + "identifier": "postman", + "bundle_identifier": "com.postmanlabs.mac", + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.postmanlabs.mac';" + }, + { + "identifier": "slack", + "bundle_identifier": "com.tinyspeck.slackmacgap", + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.tinyspeck.slackmacgap';" + }, + { + "identifier": "teamviewer", + "bundle_identifier": "com.teamviewer.TeamViewer", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.teamviewer.TeamViewer';" + }, + { + "identifier": "visual-studio-code", + "bundle_identifier": "com.microsoft.VSCode", + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.VSCode';" + }, + { + "identifier": "whatsapp", + "bundle_identifier": "net.whatsapp.WhatsApp", + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'net.whatsapp.WhatsApp';" + }, + { + "identifier": "zoom", + "bundle_identifier": "us.zoom.xos", + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'us.zoom.xos';" + } ] From 80edd0dbfeda17716e909241a4f96c21d3dcb50d Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Tue, 26 Nov 2024 22:21:00 +0000 Subject: [PATCH 2/4] Feat UI creat policies fleet apps title details (#23972) relates to #23137, #23136 implements to the rest of the UI for automatically creating fleet policies when adding a fleet maintained app. Also includes the API changes needed for this which include changing the `GET /software/titles` and `GET /software/titles/:id` endpoints to include the `automatic_install_policies` data. UI added includes: **Adding tag for automatic install software titles** ![image](https://github.com/user-attachments/assets/a7f17350-58f2-44bc-8ea0-477c633b394a) **Adding modal to show the policies associated with that software title** ![image](https://github.com/user-attachments/assets/eb08f3e0-0dcd-44d7-915c-b08b7434f615) - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jahziel Villasana-Espinoza --- ...ui-creat-policies-fleet-apps-title-details | 1 + ee/server/service/maintained_apps.go | 34 ++-- frontend/__mocks__/softwareMock.ts | 3 + frontend/components/Tag/Tag.tsx | 41 +++++ frontend/components/Tag/_styles.scss | 27 +++ frontend/components/Tag/index.ts | 1 + .../components/buttons/Button/_styles.scss | 2 +- frontend/interfaces/software.ts | 5 +- .../FleetMaintainedAppDetailsPage.tsx | 15 +- .../AutomaticInstallModal.tsx | 92 ++++++++++ .../AutomaticInstallModal/_styles.scss | 23 +++ .../AutomaticInstallModal/index.ts | 1 + .../DeleteSoftwareModal.tsx | 2 +- .../SoftwarePackageCard.tsx | 40 +++-- .../SoftwarePackageCard/_styles.scss | 15 -- .../SoftwareTitleDetailsPage.tsx | 3 - .../SoftwareTitleDetailsPage/helpers.tests.ts | 3 + .../SoftwareTitlesTableConfig.tsx | 11 ++ server/datastore/mysql/maintained_apps.go | 27 +++ .../datastore/mysql/maintained_apps_test.go | 84 +++++++++ server/datastore/mysql/policies.go | 62 +++++-- server/datastore/mysql/policies_test.go | 163 ++++++++++++++++++ server/datastore/mysql/software_installers.go | 6 + server/datastore/mysql/software_titles.go | 13 ++ server/fleet/datastore.go | 3 + server/fleet/service.go | 2 +- server/fleet/software_installer.go | 12 ++ server/mock/datastore_mock.go | 12 ++ server/service/maintained_apps.go | 11 +- server/service/software_titles.go | 2 +- 30 files changed, 648 insertions(+), 68 deletions(-) create mode 100644 changes/feat-ui-creat-policies-fleet-apps-title-details create mode 100644 frontend/components/Tag/Tag.tsx create mode 100644 frontend/components/Tag/_styles.scss create mode 100644 frontend/components/Tag/index.ts create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/AutomaticInstallModal.tsx create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts diff --git a/changes/feat-ui-creat-policies-fleet-apps-title-details b/changes/feat-ui-creat-policies-fleet-apps-title-details new file mode 100644 index 0000000000..e69ff76e18 --- /dev/null +++ b/changes/feat-ui-creat-policies-fleet-apps-title-details @@ -0,0 +1 @@ +- Adds functionality for creating an automatic install policy for Fleet-maintained apps \ No newline at end of file diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 76798a16be..d189491921 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -26,19 +27,19 @@ func (svc *Service) AddFleetMaintainedApp( appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, -) error { +) (titleID uint, err error) { if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { - return err + return 0, err } vc, ok := viewer.FromContext(ctx) if !ok { - return fleet.ErrNoContext + return 0, fleet.ErrNoContext } app, err := svc.ds.GetMaintainedAppByID(ctx, appID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting maintained app by id") + return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id") } // Download installer from the URL @@ -50,13 +51,13 @@ func (svc *Service) AddFleetMaintainedApp( client := fleethttp.NewClient(fleethttp.WithTimeout(timeout)) installerTFR, filename, err := maintainedapps.DownloadInstaller(ctx, app.InstallerURL, client) if err != nil { - return ctxerr.Wrap(ctx, err, "downloading app installer") + return 0, ctxerr.Wrap(ctx, err, "downloading app installer") } defer installerTFR.Close() extension, err := maintainedapps.ExtensionForBundleIdentifier(app.BundleIdentifier) if err != nil { - return ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier) + return 0, ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier) } // Validate the bytes we got are what we expected, if homebrew supports @@ -68,11 +69,11 @@ func (svc *Service) AddFleetMaintainedApp( gotHash := hex.EncodeToString(h.Sum(nil)) if gotHash != app.SHA256 { - return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash") + return 0, ctxerr.New(ctx, "mismatch in maintained app SHA256 hash") } if err := installerTFR.Rewind(); err != nil { - return ctxerr.Wrap(ctx, err, "rewind installer reader") + return 0, ctxerr.Wrap(ctx, err, "rewind installer reader") } } @@ -120,12 +121,12 @@ func (svc *Service) AddFleetMaintainedApp( // Create record in software installers table _, err = svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload) if err != nil { - return ctxerr.Wrap(ctx, err, "setting downloaded installer") + return 0, ctxerr.Wrap(ctx, err, "setting downloaded installer") } // Save in S3 if err := svc.storeSoftware(ctx, payload); err != nil { - return ctxerr.Wrap(ctx, err, "upload maintained app installer to S3") + return 0, ctxerr.Wrap(ctx, err, "upload maintained app installer to S3") } // Create activity @@ -133,7 +134,7 @@ func (svc *Service) AddFleetMaintainedApp( if payload.TeamID != nil && *payload.TeamID != 0 { t, err := svc.ds.Team(ctx, *payload.TeamID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting team") + return 0, ctxerr.Wrap(ctx, err, "getting team") } teamName = &t.Name } @@ -145,10 +146,17 @@ func (svc *Service) AddFleetMaintainedApp( TeamID: payload.TeamID, SelfService: payload.SelfService, }); err != nil { - return ctxerr.Wrap(ctx, err, "creating activity for added software") + return 0, ctxerr.Wrap(ctx, err, "creating activity for added software") } - return nil + // Use the writer for this query; we need the software installer that might have just been + // created above + titleId, err := svc.ds.GetSoftwareTitleIDByMaintainedAppID(ctxdb.RequirePrimary(ctx, true), app.ID, payload.TeamID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id") + } + + return titleId, nil } func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index e789572138..d01745f068 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -213,6 +213,9 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { failed_uninstall: 1, }, automatic_install_policies: [], + last_install: null, + last_uninstall: null, + package_url: "", }; export const createMockSoftwarePackage = ( diff --git a/frontend/components/Tag/Tag.tsx b/frontend/components/Tag/Tag.tsx new file mode 100644 index 0000000000..4688b1b31c --- /dev/null +++ b/frontend/components/Tag/Tag.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import classnames from "classnames"; + +import Icon from "components/Icon"; +import { IconNames } from "components/icons"; + +const baseClass = "tag"; + +interface ITagProps { + icon: IconNames; + text: string; + className?: string; + onClick?: () => void; +} + +const Tag = ({ icon, text, className, onClick }: ITagProps) => { + const classNames = classnames( + baseClass, + className, + onClick && `${baseClass}__clickable-tag` + ); + + const content = ( + <> + + {text} + + ); + + return onClick ? ( + // use a button element so that the tag can be focused and clicked + // with the keyboard + + ) : ( +
{content}
+ ); +}; + +export default Tag; diff --git a/frontend/components/Tag/_styles.scss b/frontend/components/Tag/_styles.scss new file mode 100644 index 0000000000..6832637eda --- /dev/null +++ b/frontend/components/Tag/_styles.scss @@ -0,0 +1,27 @@ +.tag { + display: flex; + height: 18px; + padding: 3px 6px; + align-items: center; + gap: $pad-xsmall; + border-radius: $border-radius; + border: 1px solid $ui-fleet-black-10; + color: $ui-fleet-black-75; + font-size: $xx-small; + font-weight: $bold; + white-space: nowrap; + + // styles to override the default +
+ + + ); +}; + +export default AutomaticInstallModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss new file mode 100644 index 0000000000..40aea908a2 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss @@ -0,0 +1,23 @@ +.automatic-install-modal { + + &__description { + margin: 0 0 $pad-large + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-medium; + } + + &__list-item { + border-bottom: 1px solid $ui-fleet-black-10; + padding: $pad-small $pad-large; + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts new file mode 100644 index 0000000000..adb3cad5bf --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AutomaticInstallModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx index 633a221993..0327da4239 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx @@ -67,7 +67,7 @@ const DeleteSoftwareModal = ({

Installs or uninstalls currently running on a host will still - complete, but results won’t appear in Fleet. + complete, but results won't appear in Fleet.

You cannot undo this action.

diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index eef9344421..faa72a7684 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -22,6 +22,7 @@ import ActionsDropdown from "components/ActionsDropdown"; import TooltipWrapper from "components/TooltipWrapper"; import DataSet from "components/DataSet"; import Icon from "components/Icon"; +import Tag from "components/Tag"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; import endpoints from "utilities/endpoints"; @@ -34,6 +35,7 @@ import { SOFTWARE_PACKAGE_DROPDOWN_OPTIONS, downloadFile, } from "./helpers"; +import AutomaticInstallModal from "../AutomaticInstallModal"; const baseClass = "software-package-card"; @@ -267,6 +269,9 @@ const SoftwarePackageCard = ({ const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showAutomaticInstallModal, setShowAutomaticInstallModal] = useState( + false + ); const onEditSoftwareClick = () => { setShowEditSoftwareModal(true); @@ -342,16 +347,22 @@ const SoftwarePackageCard = ({
- {isSelfService && ( -
- - Self-service -
- )} + {softwarePackage?.automatic_install_policies && + softwarePackage?.automatic_install_policies.length > 0 && ( + + setShowAutomaticInstallModal(true)} + /> + + )} + {isSelfService && } {showActions && ( )} + {showAutomaticInstallModal && + softwarePackage?.automatic_install_policies && + softwarePackage?.automatic_install_policies.length > 0 && ( + setShowAutomaticInstallModal(false)} + /> + )} ); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss index 2de1944fa0..8140ab7da8 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/_styles.scss @@ -87,21 +87,6 @@ align-items: center; } - &__self-service-badge { - display: flex; - height: 18px; - padding: 3px 6px; - align-items: center; - gap: 4px; - border-radius: 4px; - border: 1px solid $ui-fleet-black-10; - background: $ui-off-white; - color: $ui-fleet-black-75; - font-size: $xx-small; - font-weight: $bold; - white-space: nowrap; - } - &__actions { @include button-dropdown; color: $core-fleet-black; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index b3eaa5dec6..cc55f31e28 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -7,11 +7,8 @@ import { RouteComponentProps } from "react-router"; import { AxiosError } from "axios"; import paths from "router/paths"; - import useTeamIdParam from "hooks/useTeamIdParam"; - import { AppContext } from "context/app"; - import { ISoftwareTitleDetails, formatSoftwareType, diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index 8dcf561167..8e452a1b59 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -23,6 +23,9 @@ describe("SoftwareTitleDetailsPage helpers", () => { install_script: "echo foo", icon_url: "https://example.com/icon.png", automatic_install_policies: [], + last_install: null, + last_uninstall: null, + package_url: "", }, app_store_app: null, source: "apps", diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx index f9758cb3ba..2be2d532da 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx @@ -70,10 +70,19 @@ const getSoftwareNameCellData = ( const { software_package, app_store_app } = softwareTitle; let hasPackage = false; let isSelfService = false; + let installType: "manual" | "automatic" | undefined; let iconUrl: string | null = null; if (software_package) { hasPackage = true; isSelfService = software_package.self_service; + if ( + software_package.automatic_install_policies && + software_package.automatic_install_policies.length > 0 + ) { + installType = "automatic"; + } else { + installType = "manual"; + } } else if (app_store_app) { hasPackage = true; isSelfService = app_store_app.self_service; @@ -88,6 +97,7 @@ const getSoftwareNameCellData = ( path: softwareTitleDetailsPath, hasPackage: hasPackage && !isAllTeams, isSelfService, + installType, iconUrl, }; }; @@ -117,6 +127,7 @@ const generateTableHeaders = ( router={router} hasPackage={nameCellData.hasPackage} isSelfService={nameCellData.isSelfService} + installType={nameCellData.installType} iconUrl={nameCellData.iconUrl ?? undefined} /> ); diff --git a/server/datastore/mysql/maintained_apps.go b/server/datastore/mysql/maintained_apps.go index 951bf4b473..5b84ee26f7 100644 --- a/server/datastore/mysql/maintained_apps.go +++ b/server/datastore/mysql/maintained_apps.go @@ -163,3 +163,30 @@ WHERE NOT EXISTS ( return avail, meta, nil } + +// GetSoftwareTitleIDByAppID returns the software title ID related to a given fleet library app ID. +func (ds *Datastore) GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) { + stmt := ` + SELECT + st.id + FROM software_titles st + JOIN software_installers si ON si.title_id = st.id + JOIN fleet_library_apps fla ON fla.id = si.fleet_library_app_id + WHERE fla.id = ? AND si.global_or_team_id = ?` + + var globalOrTeamID uint + if teamID != nil { + globalOrTeamID = *teamID + } + + var titleID uint + if err := sqlx.GetContext(ctx, ds.reader(ctx), &titleID, stmt, appID, globalOrTeamID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "no matching software installer found") + } + + return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id") + } + + return titleID, nil +} diff --git a/server/datastore/mysql/maintained_apps_test.go b/server/datastore/mysql/maintained_apps_test.go index 97f5959751..95d6155c56 100644 --- a/server/datastore/mysql/maintained_apps_test.go +++ b/server/datastore/mysql/maintained_apps_test.go @@ -3,6 +3,7 @@ package mysql import ( "context" "os" + "strings" "testing" "github.com/fleetdm/fleet/v4/server/fleet" @@ -24,6 +25,7 @@ func TestMaintainedApps(t *testing.T) { {"IngestWithBrew", testIngestWithBrew}, {"ListAvailableApps", testListAvailableApps}, {"GetMaintainedAppByID", testGetMaintainedAppByID}, + {"GetSoftwareTitleIdByAppID", testGetSoftwareTitleIdByAppID}, } for _, c := range cases { @@ -377,3 +379,85 @@ func testGetMaintainedAppByID(t *testing.T, ds *Datastore) { require.Equal(t, expApp, gotApp) } + +func testGetSoftwareTitleIdByAppID(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Maintained app doesn't exist, should get not found error + _, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, 99, nil) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + + app, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ + Name: "foo", + Token: "token", + Version: "1.0.0", + Platform: "darwin", + InstallerURL: "https://example.com/foo.zip", + SHA256: "sha", + BundleIdentifier: "bundle", + InstallScript: "install", + UninstallScript: "uninstall", + }) + require.NoError(t, err) + + // Valid maintained app ID, but no installer yet so we should get not found error + _, err = ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, nil) + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + + // create a software installer for team and for no team + installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + + installerTm1ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + FleetLibraryAppID: &app.ID, + }) + require.NoError(t, err) + + _, err = ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: nil, + FleetLibraryAppID: &app.ID, + }) + require.NoError(t, err) + + // get the software installer metadata as we will need the associated software title id. + installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installerTm1ID) + require.NoError(t, err) + require.NotNil(t, installer1.TitleID) + + stID, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, &team1.ID) + require.NoError(t, err) + require.Equal(t, *installer1.TitleID, stID) + + stNoTmID, err := ds.GetSoftwareTitleIDByMaintainedAppID(ctx, app.ID, nil) + require.NoError(t, err) + require.Equal(t, *installer1.TitleID, stNoTmID) + + require.NoError(t, err) +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index a174d1bd38..b1fa426365 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -465,7 +465,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea var args []interface{} query := ` - SELECT + SELECT ` + policyCols + `, COALESCE(u.name, '') AS author_name, COALESCE(u.email, '') AS author_email, @@ -705,7 +705,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op var args []interface{} query := ` - SELECT + SELECT ` + policyCols + `, COALESCE(u.name, '') AS author_name, COALESCE(u.email, '') AS author_email, @@ -1473,15 +1473,15 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { p.id as policy_id, t.id AS inherited_team_id, ( - SELECT COUNT(*) - FROM policy_membership pm - INNER JOIN hosts h ON pm.host_id = h.id + SELECT COUNT(*) + FROM policy_membership pm + INNER JOIN hosts h ON pm.host_id = h.id WHERE pm.policy_id = p.id AND pm.passes = true AND h.team_id = t.id ) AS passing_host_count, ( - SELECT COUNT(*) - FROM policy_membership pm - INNER JOIN hosts h ON pm.host_id = h.id + SELECT COUNT(*) + FROM policy_membership pm + INNER JOIN hosts h ON pm.host_id = h.id WHERE pm.policy_id = p.id AND pm.passes = false AND h.team_id = t.id ) AS failing_host_count FROM policies p @@ -1555,12 +1555,12 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { SELECT p.id, NULL AS inherited_team_id, -- using NULL to represent global scope - COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0), + COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 1)), 0), COALESCE(SUM(IF(pm.passes IS NULL, 0, pm.passes = 0)), 0) FROM policies p LEFT JOIN policy_membership pm ON p.id = pm.policy_id GROUP BY p.id - ON DUPLICATE KEY UPDATE + ON DUPLICATE KEY UPDATE updated_at = NOW(), passing_host_count = VALUES(passing_host_count), failing_host_count = VALUES(failing_host_count); @@ -1622,7 +1622,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( hostID *uint, ) ([]fleet.HostPolicyMembershipData, error) { query := ` - SELECT + SELECT COALESCE(sh.email, '') AS email, COALESCE(pm.passing, 1) AS passing, COALESCE(pm.failing_policy_ids, '') AS failing_policy_ids, @@ -1640,7 +1640,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( SELECT host_id, MIN(email) AS email FROM host_emails JOIN hosts ON host_emails.host_id=hosts.id - WHERE email LIKE CONCAT('%@', ?) AND team_id = ? + WHERE email LIKE CONCAT('%@', ?) AND team_id = ? GROUP BY host_id ) sh ON h.id = sh.host_id LEFT JOIN host_display_names hdn ON h.id = hdn.host_id @@ -1663,3 +1663,41 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( return hosts, nil } + +// GetPoliciesBySoftwareTitleID returns the policies that are associated with a set of software titles. +func (ds *Datastore) getPoliciesBySoftwareTitleIDs( + ctx context.Context, + softwareTitleIDs []uint, + teamID *uint, +) ([]fleet.AutomaticInstallPolicy, error) { + if len(softwareTitleIDs) == 0 { + return nil, nil + } + + query := ` + SELECT + p.id AS id, + p.name AS name, + st.id AS software_title_id + FROM policies p + JOIN software_installers si ON p.software_installer_id = si.id + JOIN software_titles st ON si.title_id = st.id + WHERE st.id IN (?) AND p.team_id = ? +` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + query, args, err := sqlx.In(query, softwareTitleIDs, tmID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "build select get policies by software id query") + } + + var policies []fleet.AutomaticInstallPolicy + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies by software installer id") + } + return policies, nil +} diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index d3626231ac..50369b8a24 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -69,6 +69,7 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNewGlobalPolicyWithScript", testNewGlobalPolicyWithScript}, {"TestPoliciesTeamPoliciesWithScript", testTeamPoliciesWithScript}, {"TeamPoliciesNoTeam", testTeamPoliciesNoTeam}, + {"TestPoliciesBySoftwareTitleID", testPoliciesBySoftwareTitleID}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -4999,3 +5000,165 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { require.Equal(t, "SELECT 0;", host5PolicyQueries[strconv.FormatUint(uint64(policy0NoTeam.ID), 10)]) require.Equal(t, "SELECT 3;", host5PolicyQueries[strconv.FormatUint(uint64(policy3NoTeam.ID), 10)]) } + +func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { + ctx := context.Background() + + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + policy1 := newTestPolicy(t, ds, user1, "policy 1", "darwin", &team1.ID) + policy2 := newTestPolicy(t, ds, user1, "policy 2", "darwin", &team2.ID) + + // Get policies for an invalid title ID + policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{999}, &team1.ID) + require.NoError(t, err) + require.Empty(t, policies) + + installer, err := fleet.NewTempFileReader(strings.NewReader("hello"), t.TempDir) + require.NoError(t, err) + + // Associate an installer to policy 1 on team 1. + installer1ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage1", + Filename: "file1", + Title: "file1", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team1.ID, + }) + require.NoError(t, err) + policy1.SoftwareInstallerID = ptr.Uint(installer1ID) + err = ds.SavePolicy(context.Background(), policy1, false, false) + require.NoError(t, err) + + // Associate an installer to policy 2 on team 2. + installer2ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello", + PreInstallQuery: "SELECT 1", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage2", + Filename: "file2", + Title: "file2", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: &team2.ID, + }) + require.NoError(t, err) + policy2.SoftwareInstallerID = ptr.Uint(installer2ID) + err = ds.SavePolicy(context.Background(), policy2, false, false) + require.NoError(t, err) + + // get the software installer metadata as we will need the associated software title ids. + installer1, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer1ID) + require.NoError(t, err) + require.NotNil(t, installer1.TitleID) + installer2, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer2ID) + require.NoError(t, err) + require.NotNil(t, installer2.TitleID) + + // software title 1 should have policy 1 when filtering by team 1 + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, &team1.ID) + require.NoError(t, err) + require.Len(t, policies, 1) + require.Equal(t, policy1.ID, policies[0].ID) + require.Equal(t, policy1.Name, policies[0].Name) + + // software title 1 should not have any policies when filtering by team 2 + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, &team2.ID) + require.NoError(t, err) + require.Len(t, policies, 0) + + // software title 2 should have policy 2 when filtering by team 2 + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, &team2.ID) + require.NoError(t, err) + require.Len(t, policies, 1) + require.Equal(t, policy2.ID, policies[0].ID) + require.Equal(t, policy2.Name, policies[0].Name) + + // software title 2 should not have any policies when filtering by team 1 + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, &team1.ID) + require.NoError(t, err) + require.Len(t, policies, 0) + + // software title 2 should not have any policies when filtering by no team + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, nil) + require.NoError(t, err) + require.Len(t, policies, 0) + + // Associate a couple of installers to policy 3 on no team. + installer3ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello noteam", + PreInstallQuery: "SELECT 1 from noteam", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage3noteam", + Filename: "file3noteam", + Title: "file3noteam", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: nil, + }) + require.NoError(t, err) + + installer4ID, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "hello noteam", + PreInstallQuery: "SELECT 1 from noteam", + PostInstallScript: "world", + InstallerFile: installer, + StorageID: "storage4noteam", + Filename: "file4noteam", + Title: "file4noteam", + Version: "1.0", + Source: "apps", + UserID: user1.ID, + TeamID: nil, + }) + require.NoError(t, err) + + policy3 := newTestPolicy(t, ds, user1, "policy 3", "darwin", ptr.Uint(0)) + policy3.SoftwareInstallerID = ptr.Uint(installer3ID) + err = ds.SavePolicy(context.Background(), policy3, false, false) + require.NoError(t, err) + + policy4 := newTestPolicy(t, ds, user1, "policy 4", "darwin", ptr.Uint(0)) + policy4.SoftwareInstallerID = ptr.Uint(installer4ID) + err = ds.SavePolicy(context.Background(), policy4, false, false) + require.NoError(t, err) + + installer3, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer3ID) + require.NoError(t, err) + require.NotNil(t, installer3.TitleID) + + installer4, err := ds.GetSoftwareInstallerMetadataByID(ctx, installer4ID) + require.NoError(t, err) + require.NotNil(t, installer3.TitleID) + + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer3.TitleID, *installer4.TitleID}, nil) + require.NoError(t, err) + require.Len(t, policies, 2) + expected := map[uint]fleet.AutomaticInstallPolicy{ + policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID}, + policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID}, + } + + for _, got := range policies { + require.Equal(t, expected[got.ID], got) + } + + // "No team" titles should not have any policies when filtering by team 1 + policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer3.TitleID, *installer4.TitleID}, ptr.Uint(1)) + require.NoError(t, err) + require.Len(t, policies, 0) +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 59baad7e2e..90d0b732af 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -405,6 +405,12 @@ WHERE return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") } + policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get policies by software title ID") + } + dest.AutomaticInstallPolicies = policies + return &dest, nil } diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 817b83308b..82b9896188 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -151,6 +151,7 @@ func (ds *Datastore) ListSoftwareTitles( if title.PackageVersion != nil { version = *title.PackageVersion } + title.SoftwarePackage = &fleet.SoftwarePackageOrApp{ Name: *title.PackageName, Version: version, @@ -179,6 +180,18 @@ func (ds *Datastore) ListSoftwareTitles( titleIndex[title.ID] = i } + // Grab the automatic install policies, if any exist + policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, titleIDs, opt.TeamID) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "batch getting policies by software title IDs") + } + + for _, p := range policies { + if i, ok := titleIndex[p.TitleID]; ok { + softwareList[i].SoftwarePackage.AutomaticInstallPolicies = append(softwareList[i].SoftwarePackage.AutomaticInstallPolicies, p) + } + } + // we grab matching versions separately and build the desired object in // the application logic. This is because we need to support MySQL 5.7 // and there's no good way to do an aggregation that builds a structure diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index f0e2d99871..73652cc321 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1865,6 +1865,9 @@ type Datastore interface { // CleanUpMDMManagedCertificates removes all managed certificates that are not associated with any host+profile. CleanUpMDMManagedCertificates(ctx context.Context) error + + // GetSoftwareTitleIDByMaintainedAppID returns the software title ID for the given app ID. + GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) } // MDMAppleStore wraps nanomdm's storage and adds methods to deal with diff --git a/server/fleet/service.go b/server/fleet/service.go index 7e9f7c973c..e4e7611d44 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1159,7 +1159,7 @@ type Service interface { // Fleet-maintained apps // AddFleetMaintainedApp adds a Fleet-maintained app to the given team. - AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) error + AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) // ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team ListFleetMaintainedApps(ctx context.Context, teamID uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error) // GetFleetMaintainedApp returns a Fleet-maintained app by ID diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 835762c154..c1e376cc8d 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -124,6 +124,9 @@ type SoftwareInstaller struct { URL string `json:"url" db:"url"` // FleetLibraryAppID is the related Fleet-maintained app for this installer (if not nil). FleetLibraryAppID *uint `json:"-" db:"fleet_library_app_id"` + // AutomaticInstallPolicies is the list of policies that trigger automatic + // installation of this software. + AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies" db:"-"` } // SoftwarePackageResponse is the response type used when applying software by batch. @@ -414,6 +417,12 @@ type HostSoftwareWithInstaller struct { AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` } +type AutomaticInstallPolicy struct { + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` + TitleID uint `json:"-" db:"software_title_id"` +} + // SoftwarePackageOrApp provides information about a software installer // package or a VPP app. type SoftwarePackageOrApp struct { @@ -421,6 +430,9 @@ type SoftwarePackageOrApp struct { AppStoreID string `json:"app_store_id,omitempty"` // Name is only present for software installer packages. Name string `json:"name,omitempty"` + // AutomaticInstallPolicies is only present for Fleet maintained apps + // installed automatically with a policy. + AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"` Version string `json:"version"` SelfService *bool `json:"self_service,omitempty"` diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1dfb943661..93f0400304 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1167,6 +1167,8 @@ type GetHostMDMCertificateProfileFunc func(ctx context.Context, hostUUID string, type CleanUpMDMManagedCertificatesFunc func(ctx context.Context) error +type GetSoftwareTitleIDByMaintainedAppIDFunc func(ctx context.Context, appID uint, teamID *uint) (uint, error) + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -2887,6 +2889,9 @@ type DataStore struct { CleanUpMDMManagedCertificatesFunc CleanUpMDMManagedCertificatesFunc CleanUpMDMManagedCertificatesFuncInvoked bool + GetSoftwareTitleIDByMaintainedAppIDFunc GetSoftwareTitleIDByMaintainedAppIDFunc + GetSoftwareTitleIDByMaintainedAppIDFuncInvoked bool + mu sync.Mutex } @@ -6900,3 +6905,10 @@ func (s *DataStore) CleanUpMDMManagedCertificates(ctx context.Context) error { s.mu.Unlock() return s.CleanUpMDMManagedCertificatesFunc(ctx) } + +func (s *DataStore) GetSoftwareTitleIDByMaintainedAppID(ctx context.Context, appID uint, teamID *uint) (uint, error) { + s.mu.Lock() + s.GetSoftwareTitleIDByMaintainedAppIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareTitleIDByMaintainedAppIDFunc(ctx, appID, teamID) +} diff --git a/server/service/maintained_apps.go b/server/service/maintained_apps.go index ad08e3542a..b8edc671fb 100644 --- a/server/service/maintained_apps.go +++ b/server/service/maintained_apps.go @@ -20,7 +20,8 @@ type addFleetMaintainedAppRequest struct { } type addFleetMaintainedAppResponse struct { - Err error `json:"error,omitempty"` + SoftwareTitleID uint `json:"software_title_id,omitempty"` + Err error `json:"error,omitempty"` } func (r addFleetMaintainedAppResponse) error() error { return r.Err } @@ -29,7 +30,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc req := request.(*addFleetMaintainedAppRequest) ctx, cancel := context.WithTimeout(ctx, maintainedapps.InstallerTimeout) defer cancel() - err := svc.AddFleetMaintainedApp( + titleId, err := svc.AddFleetMaintainedApp( ctx, req.TeamID, req.AppID, @@ -46,15 +47,15 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc return &addFleetMaintainedAppResponse{Err: err}, nil } - return &addFleetMaintainedAppResponse{}, nil + return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil } -func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) error { +func (svc *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool) (uint, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) - return fleet.ErrMissingLicense + return 0, fleet.ErrMissingLicense } type listFleetMaintainedAppsRequest struct { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 5d78ae8960..fef34c9a6c 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -175,7 +175,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "checked using a global admin") } - return nil, fleet.NewPermissionError("Error: You don’t have permission to view specified software. It is installed on hosts that belong to team you don’t have permissions to view.") + return nil, fleet.NewPermissionError("Error: You don't have permission to view specified software. It is installed on hosts that belong to team you don't have permissions to view.") } return nil, ctxerr.Wrap(ctx, err, "getting software title by id") } From f1530a6cea76ff65542a8846abe5ba34b9b9d8bd Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Mon, 2 Dec 2024 15:26:01 -0500 Subject: [PATCH 3/4] feat: integration tests (#24270) # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- server/service/integration_enterprise_test.go | 107 +++++++++++++++++- server/service/testing_client.go | 5 + 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 93bac52410..cdc606230c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -15132,7 +15132,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { installerBytes := []byte("abc") // Mock server to serve the "installers" - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/badinstaller": _, _ = w.Write([]byte("badinstaller")) @@ -15143,7 +15143,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { _, _ = w.Write(installerBytes) } })) - defer srv.Close() + defer installerServer.Close() getSoftwareInstallerIDByMAppID := func(mappID uint) uint { var id uint @@ -15166,11 +15166,11 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { _, err := h.Write(installerBytes) require.NoError(t, err) spoofedSHA := hex.EncodeToString(h.Sum(nil)) - _, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET sha256 = ?, installer_url = ?", spoofedSHA, srv.URL+"/installer.zip") + _, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET sha256 = ?, installer_url = ?", spoofedSHA, installerServer.URL+"/installer.zip") require.NoError(t, err) - _, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 2", srv.URL+"/badinstaller") + _, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 2", installerServer.URL+"/badinstaller") require.NoError(t, err) - _, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 3", srv.URL+"/timeout") + _, err = q.ExecContext(ctx, "UPDATE fleet_library_apps SET installer_url = ? WHERE id = 3", installerServer.URL+"/timeout") return err }) @@ -15352,4 +15352,101 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { postinstall, err = s.ds.GetAnyScriptContents(ctx, *i.PostInstallScriptContentID) require.NoError(t, err) require.Equal(t, req.PostInstallScript, string(postinstall)) + + // =========================================================================================== + // Adding an automatically installed FMA + // =========================================================================================== + + // Add another FMA + req = &addFleetMaintainedAppRequest{ + AppID: 5, + SelfService: false, + PreInstallQuery: "SELECT 1", + InstallScript: "echo foo", + PostInstallScript: "echo done", + TeamID: ptr.Uint(0), + } + + addMAResp = addFleetMaintainedAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusOK, &addMAResp) + require.NoError(t, addMAResp.Err) + require.NotEmpty(t, addMAResp.SoftwareTitleID) + + // Add the automatic install policy + tpParams := teamPolicyRequest{ + Name: "[Install software]", + Query: "select * from osquery;", + Description: "Some description", + Platform: "darwin", + SoftwareTitleID: &addMAResp.SoftwareTitleID, + } + tpResp := teamPolicyResponse{} + s.DoJSON("POST", "/api/latest/fleet/teams/0/policies", tpParams, http.StatusOK, &tpResp) + require.NotNil(t, tpResp.Policy) + require.NotEmpty(t, tpResp.Policy.ID) + + // List software titles; we should see the policy on the software title object + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "per_page", "2", + "order_key", "id", + "order_direction", "desc", + "available_for_install", "true", + "team_id", "0", + ) + + require.Len(t, resp.SoftwareTitles, 2) + // most recently added FMA should have 1 automatic install policy + st := resp.SoftwareTitles[0] // sorted by ID above + require.NotNil(t, st.SoftwarePackage) + require.Len(t, st.SoftwarePackage.AutomaticInstallPolicies, 1) + gotPolicy := st.SoftwarePackage.AutomaticInstallPolicies[0] + require.Equal(t, tpResp.Policy.Name, gotPolicy.Name) + require.Equal(t, tpResp.Policy.ID, gotPolicy.ID) + + // First FMA added doesn't have automatic install policies + st = resp.SoftwareTitles[1] // sorted by ID above + require.NotNil(t, st.SoftwarePackage) + require.Empty(t, st.SoftwarePackage.AutomaticInstallPolicies) + + // Get the specific app that we set to be installed automatically + var titleResp getSoftwareTitleResponse + s.DoJSON( + "GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", addMAResp.SoftwareTitleID), + getSoftwareTitleRequest{}, + http.StatusOK, &titleResp, + "team_id", "0", + ) + require.NotNil(t, titleResp.SoftwareTitle) + swTitle := titleResp.SoftwareTitle + require.NotNil(t, swTitle.SoftwarePackage) + require.Len(t, swTitle.SoftwarePackage.AutomaticInstallPolicies, 1) + gotPolicy = swTitle.SoftwarePackage.AutomaticInstallPolicies[0] + require.Equal(t, tpResp.Policy.Name, gotPolicy.Name) + require.Equal(t, tpResp.Policy.ID, gotPolicy.ID) + + // Policy should appear in the list of policies + var listPolResp listTeamPoliciesResponse + s.DoJSON( + "GET", "/api/latest/fleet/teams/0/policies", + listTeamPoliciesRequest{}, + http.StatusOK, &listPolResp, + "page", "0", + ) + + require.Len(t, listPolResp.Policies, 1) + policies := listPolResp.Policies + require.Equal(t, tpResp.Policy.Name, policies[0].Name) + require.Equal(t, tpResp.Policy.ID, policies[0].ID) + require.Equal(t, tpResp.Policy.Description, policies[0].Description) + require.Equal(t, tpResp.Policy.Query, policies[0].Query) + require.Equal(t, "darwin", policies[0].Platform) + require.False(t, policies[0].Critical) + require.NotNil(t, policies[0].InstallSoftware) + require.Equal(t, tpResp.Policy.InstallSoftware.Name, policies[0].InstallSoftware.Name) + require.Equal(t, tpResp.Policy.InstallSoftware.SoftwareTitleID, policies[0].InstallSoftware.SoftwareTitleID) } diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 291b671a3e..635fbacda8 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -159,6 +159,11 @@ func (ts *withServer) commonTearDownTest(t *testing.T) { require.NoError(t, err) } + mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `DELETE FROM policies;`) + return err + }) + // Clean software installers in "No team" (the others are deleted in ts.ds.DeleteTeam above). mysql.ExecAdhocSQL(t, ts.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `DELETE FROM software_installers WHERE global_or_team_id = 0;`) From ddf5e1d19bb7e3bbf47d318a2fe767e381598713 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Tue, 3 Dec 2024 16:11:08 -0500 Subject: [PATCH 4/4] fix: add back queries removed during merge with main --- server/mdm/maintainedapps/apps.json | 60 +++++++++++++++++++---------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/server/mdm/maintainedapps/apps.json b/server/mdm/maintainedapps/apps.json index 4594433737..98a2b9e57b 100644 --- a/server/mdm/maintainedapps/apps.json +++ b/server/mdm/maintainedapps/apps.json @@ -2,12 +2,14 @@ { "identifier": "1password", "bundle_identifier": "com.1password.1password", - "installer_format": "zip:app" + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.1password.1password';" }, { "identifier": "adobe-acrobat-reader", "bundle_identifier": "com.adobe.Reader", - "installer_format": "dmg:pkg" + "installer_format": "dmg:pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.adobe.Reader';" }, { "identifier": "box-drive", @@ -20,92 +22,110 @@ "(cd /Users/$LOGGED_IN_USER; defaults delete com.box.desktop)", "echo \"${LOGGED_IN_USER} ALL = (root) NOPASSWD: /Library/Application\\ Support/Box/uninstall_box_drive_r\" >> /etc/sudoers.d/box_uninstall" ], - "post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"] + "post_uninstall_scripts": ["rm /etc/sudoers.d/box_uninstall"], + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.box.desktop';" }, { "identifier": "brave-browser", "bundle_identifier": "com.brave.Browser", - "installer_format": "dmg:app" + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.brave.Browser';" }, { "identifier": "cloudflare-warp", "bundle_identifier": "com.cloudflare.1dot1dot1dot1.macos", "installer_format": "pkg", - "post_uninstall_scripts": ["/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh"] + "post_uninstall_scripts": ["/Applications/Cloudflare\\ WARP.app/Contents/Resources/uninstall.sh"], + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.cloudflare.1dot1dot1dot1.macos';" }, { "identifier": "docker", "bundle_identifier": "com.docker.docker", - "installer_format": "dmg:app" + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.docker.docker';" }, { "identifier": "figma", "bundle_identifier": "com.figma.Desktop", - "installer_format": "zip:app" + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.figma.Desktop';" }, { "identifier": "firefox", "bundle_identifier": "org.mozilla.firefox", - "installer_format": "dmg:app" + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefox';" }, { "identifier": "google-chrome", "bundle_identifier": "com.google.Chrome", - "installer_format": "dmg:app" + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.google.Chrome';" }, { "identifier": "microsoft-edge", "bundle_identifier": "com.microsoft.edgemac", - "installer_format": "pkg" + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.edgemac';" }, { "identifier": "microsoft-excel", "bundle_identifier": "com.microsoft.Excel", - "installer_format": "pkg" + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Excel';" }, { "identifier": "microsoft-teams", "bundle_identifier": "com.microsoft.teams2", - "installer_format": "pkg" + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.teams2';" }, { "identifier": "microsoft-word", "bundle_identifier": "com.microsoft.Word", - "installer_format": "pkg" + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.Word';" }, { "identifier": "notion", "bundle_identifier": "notion.id", - "installer_format": "dmg:app" + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'notion.id';" }, { "identifier": "postman", "bundle_identifier": "com.postmanlabs.mac", - "installer_format": "zip:app" + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.postmanlabs.mac';" }, { "identifier": "slack", "bundle_identifier": "com.tinyspeck.slackmacgap", - "installer_format": "dmg:app" + "installer_format": "dmg:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.tinyspeck.slackmacgap';" }, { "identifier": "teamviewer", "bundle_identifier": "com.teamviewer.TeamViewer", - "installer_format": "pkg" + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.teamviewer.TeamViewer';" }, { "identifier": "visual-studio-code", "bundle_identifier": "com.microsoft.VSCode", - "installer_format": "zip:app" + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'com.microsoft.VSCode';" }, { "identifier": "whatsapp", "bundle_identifier": "net.whatsapp.WhatsApp", - "installer_format": "zip:app" + "installer_format": "zip:app", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'net.whatsapp.WhatsApp';" }, { "identifier": "zoom-for-it-admins", "bundle_identifier": "us.zoom.xos", - "installer_format": "pkg" + "installer_format": "pkg", + "automatic_policy_query": "SELECT 1 FROM apps WHERE bundle_identifier = 'us.zoom.xos';" } ]