From 4a333e01be7391a81aa564e5693ba2eb42cce652 Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Mon, 9 Mar 2026 14:25:21 -0400 Subject: [PATCH 1/7] first pass at edit policy and automations --- .../pages/policies/PolicyPage/PolicyPage.tsx | 42 ++++- .../PolicyAutomations/PolicyAutomations.tsx | 149 ++++++++++++++++++ .../components/PolicyAutomations/_styles.scss | 62 ++++++++ .../components/PolicyAutomations/index.ts | 1 + .../PolicyForm/PolicyForm.tests.tsx | 4 + .../components/PolicyForm/PolicyForm.tsx | 76 ++++++++- .../PolicyPage/screens/QueryEditor.tsx | 9 ++ 7 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx create mode 100644 frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss create mode 100644 frontend/pages/policies/PolicyPage/components/PolicyAutomations/index.ts diff --git a/frontend/pages/policies/PolicyPage/PolicyPage.tsx b/frontend/pages/policies/PolicyPage/PolicyPage.tsx index 2085b8b82f5..c034e8a6416 100644 --- a/frontend/pages/policies/PolicyPage/PolicyPage.tsx +++ b/frontend/pages/policies/PolicyPage/PolicyPage.tsx @@ -21,6 +21,7 @@ import { } from "interfaces/team"; import globalPoliciesAPI from "services/entities/global_policies"; import teamPoliciesAPI from "services/entities/team_policies"; +import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import hostAPI from "services/entities/hosts"; import statusAPI from "services/entities/status"; import { DOCUMENT_TITLE_SUFFIX, LIVE_POLICY_STEPS } from "utilities/constants"; @@ -155,15 +156,20 @@ const PolicyPage = ({ false ); + // TODO: Remove team endpoint workaround once global policy endpoint populates patch_software. + // The global endpoint does not return patch_software for patch policies, but the team endpoint does. const { isLoading: isStoredPolicyLoading, data: storedPolicy, error: storedPolicyError, } = useQuery( - ["policy", policyId], - () => globalPoliciesAPI.load(policyId as number), // Note: Team users have access to policies through global API + ["policy", policyId, teamIdForApi], + () => + teamIdForApi && teamIdForApi > 0 + ? teamPoliciesAPI.load(teamIdForApi, policyId as number) + : globalPoliciesAPI.load(policyId as number), { - enabled: isRouteOk && !!policyId, // Note: this justifies the number type assertions above + enabled: isRouteOk && !!policyId, refetchOnWindowFocus: false, retry: false, select: (data: IStoredPolicyResponse) => data.policy, @@ -233,6 +239,35 @@ const PolicyPage = ({ ); } + // Fetch team config to determine "Other" automations (webhooks/integrations) + const { data: teamData } = useQuery( + ["teams", teamIdForApi], + () => teamsAPI.load(teamIdForApi), + { + enabled: + isRouteOk && + teamIdForApi !== undefined && + teamIdForApi > 0 && + storedPolicy?.type === "patch", + staleTime: 5000, + } + ); + + let currentAutomatedPolicies: number[] = []; + if (teamData?.team) { + const { + webhook_settings: { failing_policies_webhook: webhook }, + integrations, + } = teamData.team; + const isIntegrationEnabled = + (integrations?.jira?.some((j: any) => j.enable_failing_policies) || + integrations?.zendesk?.some((z: any) => z.enable_failing_policies)) ?? + false; + if (isIntegrationEnabled || webhook?.enable_failing_policies_webhook) { + currentAutomatedPolicies = webhook?.policy_ids || []; + } + } + // this function is passed way down, wrapped and ultimately called by SaveNewPolicyModal const { mutateAsync: createPolicy } = useMutation( (formData: IPolicyFormData) => { @@ -319,6 +354,7 @@ const PolicyPage = ({ onOpenSchemaSidebar, renderLiveQueryWarning, teamIdForApi, + currentAutomatedPolicies, }; const step2Opts = { diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx new file mode 100644 index 00000000000..c67fa6f280d --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { Link } from "react-router"; + +import { IPolicy } from "interfaces/policy"; +import PATHS from "router/paths"; +import { getPathWithQueryParams } from "utilities/url"; +import Button from "components/buttons/Button"; +import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import Icon from "components/Icon/Icon"; + +const baseClass = "policy-automations"; + +interface IPolicyAutomationsProps { + storedPolicy: IPolicy; + currentAutomatedPolicies: number[]; + onAddAutomation: () => void; + isAddingAutomation: boolean; + gitOpsModeEnabled: boolean; +} + +interface IAutomationRow { + name: string; + type: string; + link?: string; + sortOrder: number; + sortName: string; +} + +const PolicyAutomations = ({ + storedPolicy, + currentAutomatedPolicies, + onAddAutomation, + isAddingAutomation, + gitOpsModeEnabled, +}: IPolicyAutomationsProps): JSX.Element => { + const isPatchPolicy = storedPolicy.type === "patch"; + const hasPatchSoftware = !!storedPolicy.patch_software; + const hasSoftwareAutomation = !!storedPolicy.install_software; + const showCtaCard = + isPatchPolicy && hasPatchSoftware && !hasSoftwareAutomation; + + const automationRows: IAutomationRow[] = []; + + if (storedPolicy.install_software) { + automationRows.push({ + name: storedPolicy.install_software.name, + type: "Software", + link: getPathWithQueryParams( + PATHS.SOFTWARE_TITLE_DETAILS( + storedPolicy.install_software.software_title_id.toString() + ), + { fleet_id: storedPolicy.team_id } + ), + sortOrder: 0, + sortName: storedPolicy.install_software.name.toLowerCase(), + }); + } + + if (storedPolicy.run_script) { + automationRows.push({ + name: storedPolicy.run_script.name, + type: "Script", + sortOrder: 1, + sortName: storedPolicy.run_script.name.toLowerCase(), + }); + } + + if (storedPolicy.calendar_events_enabled) { + automationRows.push({ + name: "Maintenance window", + type: "Calendar", + sortOrder: 2, + sortName: "", + }); + } + + if (storedPolicy.conditional_access_enabled) { + automationRows.push({ + name: "Block single sign-on", + type: "Conditional access", + sortOrder: 3, + sortName: "", + }); + } + + if (currentAutomatedPolicies.includes(storedPolicy.id)) { + automationRows.push({ + name: "Create ticket or send webhook", + type: "Other", + sortOrder: 4, + sortName: "", + }); + } + + automationRows.sort((a, b) => { + if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder; + return a.sortName.localeCompare(b.sortName); + }); + + const patchSoftwareName = + storedPolicy.patch_software?.display_name || + storedPolicy.patch_software?.name || + ""; + + return ( +
+ {showCtaCard && ( +
+ + Automatically patch {patchSoftwareName} + + ( + + )} + /> +
+ )} + {automationRows.length > 0 && ( +
+
Automations
+ {automationRows.map((row) => ( +
+ + {row.link ? {row.name} : row.name} + + {row.type} +
+ ))} +
+ )} +
+ ); +}; + +export default PolicyAutomations; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss new file mode 100644 index 00000000000..fb1c0e0e8ee --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss @@ -0,0 +1,62 @@ +.policy-automations { + margin-top: $pad-medium; + + &__cta-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: $pad-medium; + border: 1px solid $ui-fleet-black-10; + border-radius: 8px; + margin-bottom: $pad-medium; + } + + &__cta-label { + font-size: $small; + font-weight: $bold; + color: $core-fleet-black; + } + + &__list { + margin-top: $pad-small; + } + + &__list-header { + font-size: $xx-small; + font-weight: $bold; + text-transform: uppercase; + color: $ui-fleet-black-50; + margin-bottom: $pad-small; + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + padding: $pad-small 0; + border-bottom: 1px solid $ui-fleet-black-10; + + &:last-child { + border-bottom: none; + } + } + + &__row-name { + font-size: $x-small; + color: $core-fleet-black; + + a { + color: $core-vibrant-blue; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__row-type { + font-size: $x-small; + color: $ui-fleet-black-50; + } +} diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/index.ts b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/index.ts new file mode 100644 index 00000000000..4c857907a68 --- /dev/null +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/index.ts @@ -0,0 +1 @@ +export { default } from "./PolicyAutomations"; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx index 12ffa326a08..016d100e13f 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx @@ -60,6 +60,7 @@ describe("PolicyForm - component", () => { isFetchingAutofillDescription: false, isFetchingAutofillResolution: false, resetAiAutofillData: jest.fn(), + currentAutomatedPolicies: [], }; it("should not show the target selector in the free tier", async () => { @@ -144,6 +145,7 @@ describe("PolicyForm - component", () => { isFetchingAutofillDescription={false} isFetchingAutofillResolution={false} resetAiAutofillData={jest.fn()} + currentAutomatedPolicies={[]} /> ); @@ -206,6 +208,7 @@ describe("PolicyForm - component", () => { isFetchingAutofillDescription={false} isFetchingAutofillResolution={false} resetAiAutofillData={jest.fn()} + currentAutomatedPolicies={[]} /> ); @@ -284,6 +287,7 @@ describe("PolicyForm - component", () => { isFetchingAutofillDescription={false} isFetchingAutofillResolution={false} resetAiAutofillData={jest.fn()} + currentAutomatedPolicies={[]} /> ); diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index 3577aaa2891..52536c6af11 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */ /* eslint-disable jsx-a11y/interactive-supports-focus */ import React, { useState, useContext, useEffect, KeyboardEvent } from "react"; -import { useQuery } from "react-query"; +import { useQuery, useQueryClient } from "react-query"; import { Ace } from "ace-builds"; import ReactTooltip from "react-tooltip"; @@ -12,6 +12,7 @@ import { COLORS } from "styles/var/colors"; import { addGravatarUrlToResource } from "utilities/helpers"; import { AppContext } from "context/app"; +import { NotificationContext } from "context/notification"; import { PolicyContext } from "context/policy"; import usePlatformCompatibility from "hooks/usePlatformCompatibility"; import usePlatformSelector from "hooks/usePlatformSelector"; @@ -48,7 +49,10 @@ import labelsAPI, { ILabelsSummaryResponse, } from "services/entities/labels"; +import teamPoliciesAPI from "services/entities/team_policies"; + import SaveNewPolicyModal from "../SaveNewPolicyModal"; +import PolicyAutomations from "../PolicyAutomations"; const baseClass = "policy-form"; @@ -71,6 +75,7 @@ interface IPolicyFormProps { onClickAutofillDescription: () => Promise; onClickAutofillResolution: () => Promise; resetAiAutofillData: () => void; + currentAutomatedPolicies: number[]; } const validateQuerySQL = (query: string) => { @@ -104,6 +109,7 @@ const PolicyForm = ({ onClickAutofillDescription, onClickAutofillResolution, resetAiAutofillData, + currentAutomatedPolicies, }: IPolicyFormProps): JSX.Element => { const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined const [isSaveNewPolicyModalOpen, setIsSaveNewPolicyModalOpen] = useState( @@ -120,6 +126,9 @@ const PolicyForm = ({ ); const [selectedLabels, setSelectedLabels] = useState({}); + const isPatchPolicy = storedPolicy?.type === "patch"; + const [isAddingAutomation, setIsAddingAutomation] = useState(false); + // Note: The PolicyContext values should always be used for any mutable policy data such as query name // The storedPolicy prop should only be used to access immutable metadata such as author id const { @@ -154,6 +163,9 @@ const PolicyForm = ({ }); }; + const { renderFlash } = useContext(NotificationContext); + const queryClient = useQueryClient(); + const { currentUser, currentTeam, @@ -312,6 +324,28 @@ const PolicyForm = ({ } }; + const onAddPatchAutomation = async () => { + if ( + !storedPolicy?.patch_software?.software_title_id || + !storedPolicy?.team_id + ) { + return; + } + setIsAddingAutomation(true); + try { + await teamPoliciesAPI.update(policyIdForEdit as number, { + team_id: storedPolicy.team_id, + software_title_id: storedPolicy.patch_software.software_title_id, + }); + queryClient.invalidateQueries(["policy", policyIdForEdit]); + renderFlash("success", "Automation added."); + } catch { + renderFlash("error", "Couldn't set automation. Please try again."); + } finally { + setIsAddingAutomation(false); + } + }; + const promptSavePolicy = () => (evt: React.MouseEvent) => { evt.preventDefault(); @@ -322,13 +356,30 @@ const PolicyForm = ({ }); } - if (isExistingPolicy && !isAnyPlatformSelected) { + if (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) { return setErrors({ ...errors, name: "At least one platform must be selected", }); } + if (isPatchPolicy && isExistingPolicy) { + // Patch policies: only send editable fields, not query/platform + const payload: IPolicyFormData = { + name: lastEditedQueryName, + description: lastEditedQueryDescription, + resolution: lastEditedQueryResolution, + }; + if (isPremiumTier) { + payload.critical = lastEditedQueryCritical; + } + onUpdate(payload); + setIsEditingName(false); + setIsEditingDescription(false); + setIsEditingResolution(false); + return; + } + let selectedPlatforms = getSelectedPlatforms(); if (selectedPlatforms.length === 0 && !isExistingPolicy && !defaultPolicy) { // If no platforms are selected, default to all compatible platforms @@ -718,7 +769,7 @@ const PolicyForm = ({ const renderEditablePolicyForm = () => { // Save disabled for no platforms selected, query name blank on existing query, or sql errors const disableSaveFormErrors = - (isExistingPolicy && !isAnyPlatformSelected) || + (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) || (lastEditedQueryName === "" && !!lastEditedQueryId) || (selectedTargetType === "Custom" && !Object.entries(selectedLabels).some(([, value]) => { @@ -747,10 +798,16 @@ const PolicyForm = ({ handleSubmit={promptSavePolicy} wrapEnabled focus={!isExistingPolicy} + readOnly={isPatchPolicy} + helpText={ + isPatchPolicy + ? "This query is automatically managed by Fleet." + : undefined + } /> {renderPlatformCompatibility()} - {isExistingPolicy && platformSelector.render()} - {isExistingPolicy && isPremiumTier && ( + {isExistingPolicy && !isPatchPolicy && platformSelector.render()} + {isExistingPolicy && isPremiumTier && !isPatchPolicy && ( )} + {isExistingPolicy && isPatchPolicy && storedPolicy && ( + + )} {isExistingPolicy && isPremiumTier && renderCriticalPolicy()} {renderLiveQueryWarning()}
diff --git a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx index e80528ac238..e366b4616f5 100644 --- a/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx +++ b/frontend/pages/policies/PolicyPage/screens/QueryEditor.tsx @@ -32,6 +32,7 @@ interface IQueryEditorProps { onOpenSchemaSidebar: () => void; renderLiveQueryWarning: () => JSX.Element | null; teamIdForApi?: number; + currentAutomatedPolicies?: number[]; } const QueryEditor = ({ @@ -49,6 +50,7 @@ const QueryEditor = ({ onOpenSchemaSidebar, renderLiveQueryWarning, teamIdForApi, + currentAutomatedPolicies, }: IQueryEditorProps): JSX.Element | null => { const { currentUser, isPremiumTier, filteredPoliciesPath } = useContext( AppContext @@ -205,6 +207,12 @@ const QueryEditor = ({ lastEditedQueryPlatform, }); + // Patch policies: never send query or platform (BE rejects them) + if (storedPolicy?.type === "patch") { + delete updatedPolicy.query; + delete updatedPolicy.platform; + } + const updateAPIRequest = () => { // storedPolicy.team_id is used for existing policies because selectedTeamId is subject to change const team_id = storedPolicy?.team_id ?? undefined; @@ -275,6 +283,7 @@ const QueryEditor = ({ onClickAutofillDescription={onClickAutofillDescription} onClickAutofillResolution={onClickAutofillResolution} resetAiAutofillData={() => setPolicyAutofillData(null)} + currentAutomatedPolicies={currentAutomatedPolicies || []} />
); From 705d35f2800c0fa82f15a6d62bb85b757545530d Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Mon, 9 Mar 2026 14:58:35 -0400 Subject: [PATCH 2/7] style updates to align to Figma --- .../PolicyAutomations/PolicyAutomations.tsx | 23 +++++++++++++++---- .../components/PolicyAutomations/_styles.scss | 8 +++++++ .../components/PolicyForm/PolicyForm.tsx | 2 ++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx index c67fa6f280d..b979fb45611 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Link } from "react-router"; import { IPolicy } from "interfaces/policy"; +import { IconNames } from "components/icons"; import PATHS from "router/paths"; import { getPathWithQueryParams } from "utilities/url"; import Button from "components/buttons/Button"; @@ -21,6 +22,7 @@ interface IPolicyAutomationsProps { interface IAutomationRow { name: string; type: string; + iconName: IconNames; link?: string; sortOrder: number; sortName: string; @@ -45,6 +47,7 @@ const PolicyAutomations = ({ automationRows.push({ name: storedPolicy.install_software.name, type: "Software", + iconName: "install", link: getPathWithQueryParams( PATHS.SOFTWARE_TITLE_DETAILS( storedPolicy.install_software.software_title_id.toString() @@ -60,6 +63,7 @@ const PolicyAutomations = ({ automationRows.push({ name: storedPolicy.run_script.name, type: "Script", + iconName: "text", sortOrder: 1, sortName: storedPolicy.run_script.name.toLowerCase(), }); @@ -69,6 +73,7 @@ const PolicyAutomations = ({ automationRows.push({ name: "Maintenance window", type: "Calendar", + iconName: "calendar", sortOrder: 2, sortName: "", }); @@ -78,6 +83,7 @@ const PolicyAutomations = ({ automationRows.push({ name: "Block single sign-on", type: "Conditional access", + iconName: "disable", sortOrder: 3, sortName: "", }); @@ -87,6 +93,7 @@ const PolicyAutomations = ({ automationRows.push({ name: "Create ticket or send webhook", type: "Other", + iconName: "external-link", sortOrder: 4, sortName: "", }); @@ -116,11 +123,14 @@ const PolicyAutomations = ({ onClick={onAddAutomation} variant="text-icon" disabled={disableChildren || isAddingAutomation} - isLoading={isAddingAutomation} > - <> - Add automation - + {isAddingAutomation ? ( + "Adding..." + ) : ( + <> + Add automation + + )} )} /> @@ -135,6 +145,11 @@ const PolicyAutomations = ({ className={`${baseClass}__row`} > + {row.link ? {row.name} : row.name} {row.type} diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss index fb1c0e0e8ee..5c43047f56d 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss @@ -41,7 +41,15 @@ } } + &__row-icon { + margin-right: $pad-small; + vertical-align: middle; + color: $ui-fleet-black-50; + } + &__row-name { + display: flex; + align-items: center; font-size: $x-small; color: $core-fleet-black; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index 52536c6af11..f46a61ebace 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -769,6 +769,7 @@ const PolicyForm = ({ const renderEditablePolicyForm = () => { // Save disabled for no platforms selected, query name blank on existing query, or sql errors const disableSaveFormErrors = + isAddingAutomation || (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) || (lastEditedQueryName === "" && !!lastEditedQueryId) || (selectedTargetType === "Custom" && @@ -891,6 +892,7 @@ const PolicyForm = ({ + )} + /> + + + + + ); +}; + +export default AddPatchPolicyModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/_styles.scss new file mode 100644 index 00000000000..82bd8fbc5cb --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/_styles.scss @@ -0,0 +1,3 @@ +.add-patch-policy-modal { + overflow-wrap: anywhere; // Prevent long software name overflow +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/index.ts new file mode 100644 index 00000000000..f0ea25ceef3 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPatchPolicyModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AddPatchPolicyModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tests.tsx index 4b213870812..74b12911dfa 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tests.tsx @@ -1,25 +1,57 @@ import React from "react"; -import { screen, render } from "@testing-library/react"; +import { screen, render, waitFor } from "@testing-library/react"; +import { renderWithSetup } from "test/test-utils"; +import { ISoftwareInstallPolicyUI } from "interfaces/software"; import InstallerPoliciesTable from "./InstallerPoliciesTable"; describe("InstallerPoliciesTable", () => { + const policies: ISoftwareInstallPolicyUI[] = [ + { id: 1, name: "No Gatekeeper", type: new Set(["dynamic"]) }, + { id: 2, name: "Outdated Gatekeeper", type: new Set(["patch"]) }, + ]; it("renders policy names as links and footer info", () => { - const policies = [{ id: 1, name: "No Gatekeeper" }]; - render(); // There should be two cells, each with a link const cells = screen.getAllByRole("cell"); - expect(cells).toHaveLength(1); + expect(cells).toHaveLength(2); // Each cell should contain a link with the policy name expect(cells[0].querySelector("a.link-cell")).toHaveTextContent( /No Gatekeeper/i ); - const POLICY_COUNT = /1 policy/i; - expect(screen.getByText(POLICY_COUNT)).toBeInTheDocument(); + expect(cells[1].querySelector("a.link-cell")).toHaveTextContent( + /Outdated Gatekeeper/i + ); + expect(screen.getByText(/2 policies/i)).toBeInTheDocument(); + }); + it("renders the badges for patch and dynamic policies", async () => { + const { user } = renderWithSetup( + + ); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByText(/patch/i)); + }); + + expect( + screen.getByText( + "Hosts will fail this policy if they're running an older version." + ) + ).toBeInTheDocument(); + }); + + await waitFor(() => { + waitFor(() => { + user.hover(screen.getByTestId("refresh-icon")); + }); - const FOOTER_TEXT = /Software will be installed when hosts fail/i; - expect(screen.getByText(FOOTER_TEXT)).toBeInTheDocument(); + expect( + screen.getByText( + "Software will be automatically installed when hosts fail this policy." + ) + ).toBeInTheDocument(); + }); }); }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tsx index 7465ef814a0..2ee21f96089 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTable.tsx @@ -1,20 +1,19 @@ import React, { useCallback } from "react"; import classnames from "classnames"; -import { ISoftwareInstallPolicy } from "interfaces/software"; +import { ISoftwareInstallPolicyUI } from "interfaces/software"; import TableContainer from "components/TableContainer"; import TableCount from "components/TableContainer/TableCount"; -import CustomLink from "components/CustomLink"; import generateInstallerPoliciesTableConfig from "./InstallerPoliciesTableConfig"; -const baseClass = "installer-policies-table"; +export const baseClass = "installer-policies-table"; interface IInstallerPoliciesTable { className?: string; teamId?: number; isLoading?: boolean; - policies?: ISoftwareInstallPolicy[] | null; + policies?: ISoftwareInstallPolicyUI[] | null; } const InstallerPoliciesTable = ({ className, @@ -32,21 +31,9 @@ const InstallerPoliciesTable = ({ return ; }, [policies?.length]); - const renderTableHelpText = () => ( -
- Software will be installed when hosts fail{" "} - {policies?.length === 1 ? "this policy" : "any of these policies"}.{" "} - -
- ); - return ( <>} showMarkAllPages={false} isAllPagesSelected={false} - renderTableHelpText={renderTableHelpText} + hideFooter /> ); }; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTableConfig.tsx index 254b2bb33e3..e4281462203 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerPoliciesTable/InstallerPoliciesTableConfig.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { ISoftwareInstallPolicy } from "interfaces/software"; +import { ISoftwareInstallPolicyUI } from "interfaces/software"; import PATHS from "router/paths"; import { getPathWithQueryParams } from "utilities/url"; import LinkCell from "components/TableContainer/DataTable/LinkCell"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell"; +import SoftwareInstallPolicyBadges from "components/SoftwareInstallPolicyBadges"; interface IInstallerPoliciesTableConfig { teamId?: number; @@ -15,7 +16,7 @@ interface ICellProps { value: string; }; row: { - original: ISoftwareInstallPolicy; + original: ISoftwareInstallPolicyUI; }; column: { isSortedDesc: boolean; @@ -39,12 +40,20 @@ const generateInstallerPoliciesTableConfig = ({ Cell: (cellProps: ICellProps) => ( + } /> ), }, diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx index 5e76e4899b5..af4bd478c1e 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx @@ -17,6 +17,7 @@ import { useSoftwareInstaller } from "hooks/useSoftwareInstallerMeta"; import { getSelfServiceTooltip, getAutoUpdatesTooltip, + mergePolicies, } from "pages/SoftwarePage/helpers"; import Card from "components/Card"; @@ -213,6 +214,7 @@ const SoftwareInstallerCard = ({ isIosOrIpadosApp, sha256, androidPlayStoreId, + patchPolicy, automaticInstallPolicies, gitOpsModeEnabled, repoURL, @@ -269,6 +271,11 @@ const SoftwareInstallerCard = ({ isGlobalTechnician || isTeamTechnician; + const mergedPolicies = mergePolicies({ + automaticInstallPolicies, + patchPolicy, + }); + return (
@@ -286,21 +293,6 @@ const SoftwareInstallerCard = ({ androidPlayStoreId={androidPlayStoreId} />
- {Array.isArray(automaticInstallPolicies) && - automaticInstallPolicies.length > 0 && ( - - - - )} {isSelfService && (
- {automaticInstallPolicies && ( + {mergedPolicies && (
)} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx index 4ea7ef18604..28e97939297 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareSummaryCard/SoftwareSummaryCard.tsx @@ -21,6 +21,7 @@ import EditIconModal from "../EditIconModal"; import EditSoftwareModal from "../EditSoftwareModal"; import EditConfigurationModal from "../EditConfigurationModal"; import EditAutoUpdateConfigModal from "../EditAutoUpdateConfigModal"; +import AddPatchPolicyModal from "../AddPatchPolicyModal"; interface ISoftwareSummaryCard { softwareTitle: ISoftwareTitleDetails; @@ -50,6 +51,7 @@ const SoftwareSummaryCard = ({ const [iconUploadedAt, setIconUploadedAt] = useState(""); const [showEditIconModal, setShowEditIconModal] = useState(false); const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false); + const [showAddPatchPolicyModal, setShowAddPatchPolicyModal] = useState(false); const [showEditConfigurationModal, setShowEditConfigurationModal] = useState( false ); @@ -121,6 +123,7 @@ const SoftwareSummaryCard = ({ /** Permission to manage software + Google Playstore app that's not a web app */ const canEditConfiguration = canManageSoftware && isAndroidPlayStoreApp && !isAndroidPlayStoreWebApp; + const canPatchSoftware = canManageSoftware && isFleetMaintainedApp; /** Installer modals require a specific team; hidden from "All Teams" */ const hasValidTeamId = typeof teamId === "number" && teamId >= 0; const softwareInstallerOnTeam = hasValidTeamId && softwareInstaller; @@ -130,6 +133,7 @@ const SoftwareSummaryCard = ({ const onClickEditAppearance = () => setShowEditIconModal(true); const onClickEditSoftware = () => setShowEditSoftwareModal(true); + const onClickAddPatchPolicy = () => setShowAddPatchPolicyModal(true); const onClickEditConfiguration = () => setShowEditConfigurationModal(true); const onClickEditAutoUpdateConfig = () => setShowEditAutoUpdateConfigModal(true); @@ -158,12 +162,16 @@ const SoftwareSummaryCard = ({ onClickEditSoftware={ canEditSoftware ? onClickEditSoftware : undefined } + onClickAddPatchPolicy={ + canPatchSoftware ? onClickAddPatchPolicy : undefined + } onClickEditConfiguration={ canEditConfiguration ? onClickEditConfiguration : undefined } onClickEditAutoUpdateConfig={ canEditAutoUpdateConfig ? onClickEditAutoUpdateConfig : undefined } + patchPolicyId={softwareTitle.software_package?.patch_policy?.id} // TODO: Update according to Marko /> {showVersionsTable && ( )} + {showAddPatchPolicyModal && softwareInstallerOnTeam && ( + setShowAddPatchPolicyModal(false)} + /> + )} {showEditConfigurationModal && softwareInstallerOnTeam && ( { + it("returns only Edit appearance when user cannot edit software or configuration and cannot patch or configure auto updates", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: false, + repoURL: undefined, + source: undefined, + canEditSoftware: false, + canEditConfiguration: false, + canAddPatchPolicy: false, + canConfigureAutoUpdate: false, + hasExistingPatchPolicy: false, + }); + + expect(result).toEqual([ + { + label: "Edit appearance", + value: ACTION_EDIT_APPEARANCE, + isDisabled: false, + tooltipContent: undefined, + }, + ]); + }); + + it("adds Edit software when canEditSoftware", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: false, + repoURL: undefined, + source: undefined, + canEditSoftware: true, + canEditConfiguration: false, + canAddPatchPolicy: false, + canConfigureAutoUpdate: false, + hasExistingPatchPolicy: false, + }); + + const values = result.map((o) => o.value); + expect(values).toContain(ACTION_EDIT_SOFTWARE); + + const editSoftware = result.find( + (opt) => opt.value === ACTION_EDIT_SOFTWARE + ); + expect(editSoftware).toEqual({ + label: "Edit software", + value: ACTION_EDIT_SOFTWARE, + isDisabled: false, + tooltipContent: undefined, + }); + }); + + it("adds Edit configuration when canEditConfiguration", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: false, + repoURL: undefined, + source: undefined, + canEditSoftware: false, + canEditConfiguration: true, + canAddPatchPolicy: false, + canConfigureAutoUpdate: false, + hasExistingPatchPolicy: false, + }); + + const values = result.map((o) => o.value); + expect(values).toContain(ACTION_EDIT_CONFIGURATION); + + const editConfig = result.find( + (opt) => opt.value === ACTION_EDIT_CONFIGURATION + ); + expect(editConfig).toEqual({ + label: "Edit configuration", + value: ACTION_EDIT_CONFIGURATION, + isDisabled: false, + tooltipContent: undefined, + }); + }); + + it("applies gitops tooltip to Edit appearance and Edit configuration, and to Edit software for vpp_apps", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: true, + repoURL: "https://repo.git", + source: "vpp_apps", + canEditSoftware: true, + canEditConfiguration: true, + canAddPatchPolicy: false, + canConfigureAutoUpdate: false, + hasExistingPatchPolicy: false, + }); + + const editAppearance = result.find( + (opt) => opt.value === ACTION_EDIT_APPEARANCE + ); + const editConfig = result.find( + (opt) => opt.value === ACTION_EDIT_CONFIGURATION + ); + const editSoftware = result.find( + (opt) => opt.value === ACTION_EDIT_SOFTWARE + ); + + expect(editAppearance).toMatchObject({ + isDisabled: true, + tooltipContent: expect.anything(), + }); + + expect(editConfig).toMatchObject({ + isDisabled: true, + tooltipContent: expect.anything(), + }); + + // For vpp_apps, Edit software also gets the gitops tooltip if present. + expect(editSoftware).toMatchObject({ + isDisabled: true, + tooltipContent: expect.anything(), + }); + }); + + it("adds Patch option enabled when canAddPatchPolicy and no existing patch policy", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: false, + repoURL: undefined, + source: undefined, + canEditSoftware: false, + canEditConfiguration: false, + canAddPatchPolicy: true, + canConfigureAutoUpdate: false, + hasExistingPatchPolicy: false, + }); + + const patch = result.find((opt) => opt.value === ACTION_PATCH); + + expect(patch).toEqual({ + label: "Patch", + value: ACTION_PATCH, + isDisabled: false, + tooltipContent: undefined, + }); + }); + + it("adds Patch option disabled with tooltip when hasExistingPatchPolicy", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: false, + repoURL: undefined, + source: undefined, + canEditSoftware: false, + canEditConfiguration: false, + canAddPatchPolicy: true, + canConfigureAutoUpdate: false, + hasExistingPatchPolicy: true, + }); + + const patch = result.find((opt) => opt.value === ACTION_PATCH); + + expect(patch).toEqual({ + label: "Patch", + value: ACTION_PATCH, + isDisabled: true, + tooltipContent: "Patch policy is already added.", + }); + }); + + it("adds Schedule auto updates option when canConfigureAutoUpdate", () => { + const result = buildActionOptions({ + gitOpsModeEnabled: false, + repoURL: undefined, + source: undefined, + canEditSoftware: false, + canEditConfiguration: false, + canAddPatchPolicy: false, + canConfigureAutoUpdate: true, + hasExistingPatchPolicy: false, + }); + + const autoUpdate = result.find( + (opt) => opt.value === ACTION_EDIT_AUTO_UPDATE_CONFIGURATION + ); + + expect(autoUpdate).toEqual({ + label: "Schedule auto updates", + value: ACTION_EDIT_AUTO_UPDATE_CONFIGURATION, + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index da26e03f4c9..b0f2f219ab2 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -33,21 +33,37 @@ import TooltipWrapperArchLinuxRolling from "components/TooltipWrapperArchLinuxRo import SoftwareIcon from "../../icons/SoftwareIcon"; import OSIcon from "../../icons/OSIcon"; -const ACTION_EDIT_APPEARANCE = "edit_appearance"; -const ACTION_EDIT_SOFTWARE = "edit_software"; -const ACTION_EDIT_CONFIGURATION = "edit_configuration"; -const ACTION_EDIT_AUTO_UPDATE_CONFIGURATION = "edit_auto_update_configuration"; - -const buildActionOptions = ( - gitOpsModeEnabled: boolean | undefined, - repoURL: string | undefined, - source: string | undefined, - canEditSoftware: boolean, - canEditConfiguration: boolean, - canConfigureAutoUpdate: boolean -): CustomOptionType[] => { +export const ACTION_EDIT_APPEARANCE = "edit_appearance"; +export const ACTION_EDIT_SOFTWARE = "edit_software"; +export const ACTION_EDIT_CONFIGURATION = "edit_configuration"; +export const ACTION_PATCH = "patch"; +export const ACTION_EDIT_AUTO_UPDATE_CONFIGURATION = + "edit_auto_update_configuration"; + +export interface BuildActionOptionsArgs { + gitOpsModeEnabled?: boolean; + repoURL?: string; + source?: string; + canEditSoftware: boolean; + canEditConfiguration: boolean; + canAddPatchPolicy: boolean; + canConfigureAutoUpdate: boolean; + hasExistingPatchPolicy?: boolean; +} + +export const buildActionOptions = ({ + gitOpsModeEnabled, + repoURL, + source, + canEditSoftware, + canEditConfiguration, + canAddPatchPolicy, + canConfigureAutoUpdate, + hasExistingPatchPolicy = false, +}: BuildActionOptionsArgs): CustomOptionType[] => { let disableEditAppearanceTooltipContent: TooltipContent | undefined; let disableEditSoftwareTooltipContent: TooltipContent | undefined; + let disabledPatchPolicyTooltipContent: TooltipContent | undefined; let disabledEditConfigurationTooltipContent: TooltipContent | undefined; if (gitOpsModeEnabled) { @@ -62,6 +78,10 @@ const buildActionOptions = ( } } + if (hasExistingPatchPolicy) { + disabledPatchPolicyTooltipContent = "Patch policy is already added."; + } + const options: CustomOptionType[] = [ { label: "Edit appearance", @@ -91,6 +111,16 @@ const buildActionOptions = ( }); } + // Show patch option only for fleet maintained apps + if (canAddPatchPolicy) { + options.push({ + label: "Patch", + value: ACTION_PATCH, + isDisabled: !!disabledPatchPolicyTooltipContent, + tooltipContent: disabledPatchPolicyTooltipContent, + }); + } + if (canConfigureAutoUpdate) { options.push({ label: "Schedule auto updates", @@ -128,6 +158,8 @@ interface ISoftwareDetailsSummaryProps { /** Displays an edit CTA to edit the software installer * Should only be defined for team view of an installable software */ onClickEditSoftware?: () => void; + /** Displays Patch CTA to add a patch policy */ + onClickAddPatchPolicy?: () => void; /** undefined unless previewing icon, in which case is string or null */ /** Displays an edit CTA to edit the software's icon * Should only be defined for team view of an installable software */ @@ -136,6 +168,7 @@ interface ISoftwareDetailsSummaryProps { iconPreviewUrl?: string | null; /** timestamp of when icon was last uploaded, used to force refresh of cached icon */ iconUploadedAt?: string; + patchPolicyId?: number; } const SoftwareDetailsSummary = ({ @@ -152,10 +185,12 @@ const SoftwareDetailsSummary = ({ canManageSoftware = false, onClickEditAppearance, onClickEditSoftware, + onClickAddPatchPolicy, onClickEditConfiguration, onClickEditAutoUpdateConfig, iconPreviewUrl, iconUploadedAt, + patchPolicyId, }: ISoftwareDetailsSummaryProps) => { const hostCountPath = getPathWithQueryParams(paths.MANAGE_HOSTS, queryParams); @@ -173,6 +208,9 @@ const SoftwareDetailsSummary = ({ case ACTION_EDIT_SOFTWARE: onClickEditSoftware && onClickEditSoftware(); break; + case ACTION_PATCH: + onClickAddPatchPolicy && onClickAddPatchPolicy(); + break; case ACTION_EDIT_CONFIGURATION: onClickEditConfiguration && onClickEditConfiguration(); break; @@ -213,14 +251,16 @@ const SoftwareDetailsSummary = ({ ); }; - const actionOptions = buildActionOptions( + const actionOptions = buildActionOptions({ gitOpsModeEnabled, repoURL, source, - !!onClickEditSoftware, - !!onClickEditConfiguration, - !!onClickEditAutoUpdateConfig - ); + canEditSoftware: !!onClickEditSoftware, + canEditConfiguration: !!onClickEditConfiguration, + canAddPatchPolicy: !!onClickAddPatchPolicy, + canConfigureAutoUpdate: !!onClickEditAutoUpdateConfig, + hasExistingPatchPolicy: !!patchPolicyId, + }); return ( <> diff --git a/frontend/pages/SoftwarePage/helpers.tests.tsx b/frontend/pages/SoftwarePage/helpers.tests.tsx index 98dac4416f0..c214f4a0527 100644 --- a/frontend/pages/SoftwarePage/helpers.tests.tsx +++ b/frontend/pages/SoftwarePage/helpers.tests.tsx @@ -73,6 +73,7 @@ const makePolicies = (count: number): ISoftwareInstallPolicy[] => Array.from({ length: count }, (_, i) => ({ id: i + 1, name: `Policy ${i + 1}`, + type: i % 2 === 0 ? "patch" : "dynamic" /* alternate types for variety */, })); describe("getAutomaticInstallPoliciesCount", () => { diff --git a/frontend/pages/SoftwarePage/helpers.tsx b/frontend/pages/SoftwarePage/helpers.tsx index 8996da21f6a..6d005e1148b 100644 --- a/frontend/pages/SoftwarePage/helpers.tsx +++ b/frontend/pages/SoftwarePage/helpers.tsx @@ -15,6 +15,9 @@ import { ISoftwarePackage, IAppStoreApp, ISoftwareTitle, + ISoftwareInstallPolicyUI, + ISoftwareInstallPolicy, + SoftwareInstallPolicyTypeSet, } from "interfaces/software"; import { IDropdownOption } from "interfaces/dropdownOption"; @@ -293,3 +296,50 @@ export const getDisplayedSoftwareName = ( export const isAndroidWebApp = (androidPlayStoreId?: string) => !!androidPlayStoreId && androidPlayStoreId.startsWith("com.google.enterprise.webapp"); +export interface MergePoliciesParams { + automaticInstallPolicies: + | ISoftwarePackage["automatic_install_policies"] + | null + | undefined; + patchPolicy: ISoftwarePackage["patch_policy"] | null | undefined; +} + +// const mergePolicies(params: MergePoliciesParams): ISoftwareInstallerPolicyUI[] = function (...) { ... } +export const mergePolicies = ({ + automaticInstallPolicies, + patchPolicy, +}: MergePoliciesParams): ISoftwareInstallPolicyUI[] => { + // Map keyed by policy id so we can merge dynamic and patch info for the same id. + const byId = new Map(); + + // 1. Seed the map with automatic install ("dynamic") policies. + (automaticInstallPolicies ?? []).forEach((installPolicy) => { + // Type Set with "dynamic" for automatic install policies + const type: SoftwareInstallPolicyTypeSet = new Set(["dynamic"]); + byId.set(installPolicy.id, { + ...installPolicy, + type, + }); + }); + + // 2. Merge in the patch policy by its id, updating type if there's a match. + if (patchPolicy) { + const existing = byId.get(patchPolicy.id); + + if (existing) { + // If there is already a dynamic policy with this id, just add "patch" + // to the existing Set so type becomes Set(["dynamic", "patch"]). + existing.type.add("patch"); + } else { + // If there is no dynamic policy with this id, create a new entry that + // has only "patch" in the Set. + const type: SoftwareInstallPolicyTypeSet = new Set(["patch"]); + byId.set(patchPolicy.id, { + ...((patchPolicy as unknown) as ISoftwareInstallPolicy), + type, + }); + } + } + + return Array.from(byId.values()); +}; diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx index 768148029d1..24bcbd1c19b 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/UsersPageTableConfig.tsx @@ -11,6 +11,7 @@ import ActionsDropdown from "components/ActionsDropdown"; import CustomLink from "components/CustomLink"; import TooltipWrapper from "components/TooltipWrapper"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import PillBadge from "components/PillBadge"; interface IHeaderProps { column: { @@ -60,8 +61,8 @@ export interface ITeamUsersTableData { export const renderApiUserIndicator = () => { return ( - This user was created using fleetctl and @@ -74,13 +75,7 @@ export const renderApiUserIndicator = () => { /> } - tipOffset={14} - position="top" - showArrow - underline={false} - > - API - + /> ); }; diff --git a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss index 1574a57ec28..68e98098ce9 100644 --- a/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss +++ b/frontend/pages/admin/TeamManagementPage/TeamDetailsWrapper/UsersPage/_styles.scss @@ -1,10 +1,6 @@ .team-users { @include vertical-page-tab-panel-layout; - &__api-only-user { - @include grey-badge; - } - .data-table-block { .data-table__table { thead { diff --git a/frontend/pages/admin/UserManagementPage/_styles.scss b/frontend/pages/admin/UserManagementPage/_styles.scss index 02a385a8cff..7b21a8dea02 100644 --- a/frontend/pages/admin/UserManagementPage/_styles.scss +++ b/frontend/pages/admin/UserManagementPage/_styles.scss @@ -1,8 +1,4 @@ .user-management { - &__api-only-user { - @include grey-badge; - } - .data-table-block { .data-table__table { thead { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 8314837801d..c944eb883c9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1092,7 +1092,6 @@ const HostDetailsPage = ({ }) ); }; - const navigateToSoftwareTab = (i: number): void => { const navPath = hostSoftwareSubNav[i].pathname; router.push( diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx index e0666f31a36..63044283a7e 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx @@ -16,7 +16,7 @@ import { getPathWithQueryParams } from "utilities/url"; import sortUtils from "utilities/sort"; import { DEFAULT_EMPTY_CELL_VALUE, PolicyResponse } from "utilities/constants"; -import InheritedBadge from "components/InheritedBadge"; +import PillBadge from "components/PillBadge"; import { getConditionalSelectHeaderCheckboxProps } from "components/TableContainer/utilities/config_utils"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; @@ -146,7 +146,10 @@ const generateTableHeaders = (
)} {viewingTeamPolicies && team_id === null && ( - + )} } diff --git a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx index e9741760075..ac56a74c0c0 100644 --- a/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx +++ b/frontend/pages/queries/ManageQueriesPage/components/QueriesTable/QueriesTableConfig.tsx @@ -36,7 +36,7 @@ import PlatformCell from "components/TableContainer/DataTable/PlatformCell"; import TextCell from "components/TableContainer/DataTable/TextCell"; import PerformanceImpactCell from "components/TableContainer/DataTable/PerformanceImpactCell"; import TooltipWrapper from "components/TooltipWrapper"; -import InheritedBadge from "components/InheritedBadge"; +import PillBadge from "components/PillBadge"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import { HumanTimeDiffWithDateTip } from "components/HumanTimeDiffWithDateTip"; @@ -171,7 +171,10 @@ const generateColumnConfigs = ({ {viewingTeamScope && // inherited team_id !== currentTeamId && ( - + )} } diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index dbaf10c85b7..b875f1aacf0 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -9,7 +9,7 @@ import { encodeScriptBase64, SCRIPTS_ENCODED_HEADER, } from "utilities/scripts_encoding"; -import { +import software, { ISoftwareResponse, ISoftwareCountResponse, ISoftwareVersion, diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 691e82071d0..700db636275 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -67,6 +67,8 @@ export default { software_title_id, labels_include_any, labels_exclude_any, + type, + patch_software_title_id, // note absence of automations-related fields, which are only set by the UI via update } = data; const { TEAMS } = endpoints; @@ -82,6 +84,8 @@ export default { software_title_id, labels_include_any, labels_exclude_any, + type, + patch_software_title_id, }); }, // TODO - response type Promise diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index ca931469e70..ada6a3f6ab7 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -45,17 +45,6 @@ $max-width: 2560px; padding: $pad-xxlarge 0 54px 0; } -@mixin grey-badge { - background-color: $ui-fleet-black-25; - color: $core-fleet-white; - font-size: $xx-small; - font-weight: $bold; - padding: 0 $pad-xsmall; - border-radius: $border-radius; - position: relative; - margin-left: $pad-xsmall; -} - // Used to create a list item with the item data (name, created at, etc...) on // the left and the item actions (download, delete, etc...) on the right. @mixin list-item { From 2d4b9538eb76df46746e819543aa605fd47c5501 Mon Sep 17 00:00:00 2001 From: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:35:56 -0400 Subject: [PATCH 4/7] 40314 patch policy autoupdate (#41168) **Related issue:** Resolves #40314 - New error when attempting to delete an installer that has a patch policy associated - New error when attempting to update the file for an installer associated with an FMA - Gitops runs will generate the patch policy every time so it matches the current installer version - Existing code checks if the query was changed and resets membership, which should be enough. - Added patch_policy object to software title, but we might change that based on discussion ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed --- ee/server/service/software_installers.go | 7 + server/datastore/mysql/policies.go | 74 +++++-- server/datastore/mysql/policies_test.go | 58 ++++- server/datastore/mysql/software_installers.go | 30 ++- .../mysql/software_installers_test.go | 64 +----- server/datastore/mysql/software_test.go | 4 + server/datastore/mysql/vpp.go | 2 +- server/datastore/mysql/vpp_test.go | 2 +- server/fleet/datastore.go | 2 + server/fleet/policies.go | 3 - server/fleet/software_installer.go | 9 + server/mock/datastore_mock.go | 12 ++ server/service/integration_enterprise_test.go | 199 ++++++++++++++++-- server/service/software_titles.go | 7 + server/service/testing_utils.go | 56 +++-- 15 files changed, 393 insertions(+), 136 deletions(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 597c1fc8af0..c9e68504fea 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -465,6 +465,13 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. payloadForNewInstallerFile = nil payload.InstallerFile = nil } + + if existingInstaller.FleetMaintainedAppID != nil { + return nil, &fleet.BadRequestError{ + Message: "Couldn't update. The package can't be changed for Fleet-maintained apps.", + InternalErr: ctxerr.Wrap(ctx, err, "installer file changed for fleet maintained app installer"), + } + } } if payload.InstallerFile == nil { // fill in existing existingInstaller data to payload diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 9a6dbd473db..2de1ce422e8 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -1034,21 +1034,22 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u args.Type = fleet.PolicyTypeDynamic } if args.Type == fleet.PolicyTypePatch { - installer, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &teamID, *args.PatchSoftwareTitleID, false) + generated, err := ds.generatePatchPolicy(ctx, teamID, *args.PatchSoftwareTitleID) if err != nil { - return nil, err + return nil, ctxerr.Wrap(ctx, err, "generating patch policy fields") } - if installer.FleetMaintainedAppID == nil { - return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ - Message: fmt.Sprintf("Software installer for Fleet maintained app with title ID %d does not exist for team ID %d", *args.PatchSoftwareTitleID, teamID), - }) + + if args.Name == "" { + args.Name = generated.Name + } + if args.Description == "" { + args.Description = generated.Description + } + if args.Resolution == "" { + args.Resolution = generated.Resolution } - generated := generatePatchPolicy(installer) - args.Name = generated.Name - args.Query = generated.Query args.Platform = generated.Platform - args.Description = generated.Description - args.Resolution = generated.Resolution + args.Query = generated.Query } if err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { @@ -1430,6 +1431,25 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs fmaTitleID := fmaTitleIDs[teamNameToID[spec.Team]][spec.FleetMaintainedAppSlug] + // generate new up-to-date query and other fields for patch policy + if spec.Type == fleet.PolicyTypePatch { + patch, err := ds.generatePatchPolicy(ctx, ptr.ValOrZero(teamID), *fmaTitleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "generating patch policy fields") + } + if spec.Name == "" { + spec.Name = patch.Name + } + if spec.Description == "" { + spec.Description = patch.Description + } + if spec.Resolution == "" { + spec.Resolution = patch.Resolution + } + spec.Platform = patch.Platform + spec.Query = patch.Query + } + res, err := tx.ExecContext( ctx, query, @@ -2459,7 +2479,8 @@ func (ds *Datastore) getPoliciesBySoftwareTitleIDs( SELECT p.id AS id, p.name AS name, - COALESCE(si.title_id, va.title_id) AS software_title_id + COALESCE(si.title_id, va.title_id) AS software_title_id, + p.type AS type FROM policies p LEFT JOIN software_installers si ON p.software_installer_id = si.id LEFT JOIN vpp_apps_teams vat ON p.vpp_apps_teams_id = vat.id @@ -2499,7 +2520,17 @@ type patchPolicy struct { Resolution string } -func generatePatchPolicy(installer *fleet.SoftwareInstaller) *patchPolicy { +func (ds *Datastore) generatePatchPolicy(ctx context.Context, teamID uint, titleID uint) (*patchPolicy, error) { + installer, err := ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &teamID, titleID, false) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting software installer") + } + if installer.FleetMaintainedAppID == nil { + return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{ + Message: fmt.Sprintf("Software installer for Fleet maintained app with title ID %d does not exist for team ID %d", titleID, teamID), + }) + } + var policy patchPolicy switch { case installer.Platform == string(fleet.MacOSPlatform): @@ -2524,5 +2555,20 @@ func generatePatchPolicy(installer *fleet.SoftwareInstaller) *patchPolicy { policy.Description = "Outdated software might introduce security vulnerabilities or compatibility issues." policy.Resolution = "Install the latest version from self-service." - return &policy + return &policy, nil +} + +func (ds *Datastore) GetPatchPolicy(ctx context.Context, teamID *uint, titleID uint) (*fleet.PatchPolicyData, error) { + query := `SELECT id, name FROM policies WHERE team_id = ? AND patch_software_title_id = ?` + var policy fleet.PatchPolicyData + + err := sqlx.GetContext(ctx, ds.reader(ctx), &policy, query, ptr.ValOrZero(teamID), titleID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("PatchPolicy"), "get patch policy") + } + return nil, ctxerr.Wrap(ctx, err, "get patch policy") + } + + return &policy, nil } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index a5c3145c33a..b3608d8ca5a 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -6063,8 +6063,8 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { 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}, + policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID, Type: fleet.PolicyTypeDynamic}, + policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID, Type: fleet.PolicyTypeDynamic}, } for _, got := range policies { @@ -7200,7 +7200,7 @@ func testTeamPatchPolicy(t *testing.T, ds *Datastore) { PostInstallScript: "world", StorageID: "storage1", Filename: "maintained1", - Title: "maintained1", + Title: "Maintained1", Version: "1.0", Source: "apps", Platform: "darwin", @@ -7250,4 +7250,56 @@ func testTeamPatchPolicy(t *testing.T, ds *Datastore) { Platform: "darwin", }) require.NoError(t, err) + + _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) + require.NoError(t, err) + + // everything automatically generated + p3, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Type: fleet.PolicyTypePatch, + PatchSoftwareTitleID: &titleID, + }) + require.NoError(t, err) + require.Equal(t, "macOS - Maintained1 up to date", p3.Name) + require.Equal(t, "Outdated software might introduce security vulnerabilities or compatibility issues.", p3.Description) + require.Equal(t, "Install the latest version from self-service.", *p3.Resolution) + require.Equal(t, "darwin", p3.Platform) + require.Equal(t, "SELECT 1 FROM apps WHERE bundle_identifier = 'fleet.maintained1' AND version_compare(bundle_short_version, '1.0') >= 0;", p3.Query) + + _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p3.ID}) + require.NoError(t, err) + + // some fields should not be overwritten + p4, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ + Name: "name", + Description: "description", + Resolution: "resolution", + Type: fleet.PolicyTypePatch, + PatchSoftwareTitleID: &titleID, + }) + require.NoError(t, err) + require.Equal(t, "name", p4.Name) + require.Equal(t, "description", p4.Description) + require.Equal(t, "resolution", *p4.Resolution) + require.Equal(t, "darwin", p4.Platform) + require.Equal(t, "SELECT 1 FROM apps WHERE bundle_identifier = 'fleet.maintained1' AND version_compare(bundle_short_version, '1.0') >= 0;", p4.Query) + + // test GetPatchPolicy + data, err := ds.GetPatchPolicy(ctx, &team1.ID, titleID) + require.NoError(t, err) + require.Equal(t, p4.ID, data.ID) + require.Equal(t, p4.Name, data.Name) + + payload2 := &fleet.UploadSoftwareInstallerPayload{ + Filename: "bar", + Title: "bar", + UserID: user1.ID, + TeamID: &team1.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + } + _, titleID2, err := ds.MatchOrCreateSoftwareInstaller(context.Background(), payload2) + require.NoError(t, err) + + _, err = ds.GetPatchPolicy(ctx, &team1.ID, titleID2) + require.True(t, fleet.IsNotFound(err)) } diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 152edda80f0..a77225ba148 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -681,10 +681,9 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up } var touchUploaded string - var clearFleetMaintainedAppID string // FMA becomes custom package when uploading a new installer file if payload.InstallerFile != nil { + // installer cannot be changed when associated with an FMA touchUploaded = ", uploaded_at = NOW()" - clearFleetMaintainedAppID = ", fleet_maintained_app_id = NULL" } err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { @@ -701,8 +700,8 @@ func (ds *Datastore) SaveInstallerUpdates(ctx context.Context, payload *fleet.Up upgrade_code = ?, user_id = ?, user_name = (SELECT name FROM users WHERE id = ?), - user_email = (SELECT email FROM users WHERE id = ?)%s%s - WHERE id = ?`, touchUploaded, clearFleetMaintainedAppID) + user_email = (SELECT email FROM users WHERE id = ?)%s + WHERE id = ?`, touchUploaded) args := []interface{}{ payload.StorageID, @@ -1112,14 +1111,29 @@ WHERE } var ( - errDeleteInstallerWithAssociatedPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."} - errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed during new host setup. Please remove software in Controls > Setup experience and try again."} + errDeleteInstallerWithAssociatedInstallPolicy = &fleet.ConflictError{Message: "Couldn't delete. Policy automation uses this software. Please disable policy automation for this software and try again."} + errDeleteInstallerInstalledDuringSetup = &fleet.ConflictError{Message: "Couldn't delete. This software is installed during new host setup. Please remove software in Controls > Setup experience and try again."} + errDeleteInstallerWithAssociatedPatchPolicy = &fleet.ConflictError{Message: "Couldn’t delete. This software has a patch policy. Please remove the patch policy and try again."} ) func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error { var activateAffectedHostIDs []uint - err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + // check if there is a patch policy that uses this title + var policyExists bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &policyExists, `SELECT EXISTS ( + SELECT 1 FROM policies p + JOIN software_installers si ON si.title_id = p.patch_software_title_id AND si.global_or_team_id = p.team_id + WHERE si.id = ? + )`, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking if patch policy exists for software installer") + } + if policyExists { + return errDeleteInstallerWithAssociatedPatchPolicy + } + + err = ds.withTx(ctx, func(tx sqlx.ExtContext) error { affectedHostIDs, err := ds.runInstallerUpdateSideEffectsInTransaction(ctx, tx, id, true, true) if err != nil { return ctxerr.Wrap(ctx, err, "clean up related installs and uninstalls") @@ -1136,7 +1150,7 @@ func (ds *Datastore) DeleteSoftwareInstaller(ctx context.Context, id uint) error return ctxerr.Wrapf(ctx, err, "getting reference from policies") } if count > 0 { - return errDeleteInstallerWithAssociatedPolicy + return errDeleteInstallerWithAssociatedInstallPolicy } } return ctxerr.Wrap(ctx, err, "delete software installer") diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index d002c745814..b74ed36e720 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -53,7 +53,6 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallersSetupExperienceSideEffects", testBatchSetSoftwareInstallersSetupExperienceSideEffects}, {"EditDeleteSoftwareInstallersActivateNextActivity", testEditDeleteSoftwareInstallersActivateNextActivity}, {"BatchSetSoftwareInstallersActivateNextActivity", testBatchSetSoftwareInstallersActivateNextActivity}, - {"SaveInstallerUpdatesClearsFleetMaintainedAppID", testSaveInstallerUpdatesClearsFleetMaintainedAppID}, {"SoftwareInstallerReplicaLag", testSoftwareInstallerReplicaLag}, {"SoftwareTitleDisplayName", testSoftwareTitleDisplayName}, {"AddSoftwareTitleToMatchingSoftware", testAddSoftwareTitleToMatchingSoftware}, @@ -2183,7 +2182,7 @@ func testDeleteSoftwareInstallers(t *testing.T, ds *Datastore) { err = ds.DeleteSoftwareInstaller(ctx, softwareInstallerID) require.Error(t, err) - require.ErrorIs(t, err, errDeleteInstallerWithAssociatedPolicy) + require.ErrorIs(t, err, errDeleteInstallerWithAssociatedInstallPolicy) _, err = ds.DeleteTeamPolicies(ctx, team1.ID, []uint{p1.ID}) require.NoError(t, err) @@ -3752,67 +3751,6 @@ func testBatchSetSoftwareInstallersActivateNextActivity(t *testing.T, ds *Datast checkUpcomingActivities(t, ds, host3) } -func testSaveInstallerUpdatesClearsFleetMaintainedAppID(t *testing.T, ds *Datastore) { - ctx := context.Background() - user := test.NewUser(t, ds, "Test User", "test@example.com", true) - tfr, err := fleet.NewTempFileReader(strings.NewReader("file contents"), t.TempDir) - require.NoError(t, err) - - // Create a maintained app - maintainedApp, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ - Name: "Maintained1", - Slug: "maintained1", - Platform: "darwin", - UniqueIdentifier: "fleet.maintained1", - }) - require.NoError(t, err) - - // Create an installer with a non-NULL fleet_maintained_app_id - installerID, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - Title: "testpkg", - Source: "apps", - InstallScript: "echo install", - PreInstallQuery: "SELECT 1", - UninstallScript: "echo uninstall", - InstallerFile: tfr, - StorageID: "storageid1", - Filename: "test.pkg", - Version: "1.0", - UserID: user.ID, - ValidatedLabels: &fleet.LabelIdentsWithScope{}, - FleetMaintainedAppID: ptr.Uint(maintainedApp.ID), - }) - require.NoError(t, err) - - // Prepare update payload with a new installer file (should clear FMA id) - installScript := "echo install updated" - uninstallScript := "echo uninstall updated" - preInstallQuery := "SELECT 2" - selfService := true - payload := &fleet.UpdateSoftwareInstallerPayload{ - TitleID: titleID, - InstallerID: installerID, - StorageID: "storageid2", // different storage id - Filename: "test2.pkg", - Version: "2.0", - PackageIDs: []string{"com.test.pkg"}, - InstallScript: &installScript, - UninstallScript: &uninstallScript, - PreInstallQuery: &preInstallQuery, - SelfService: &selfService, - InstallerFile: tfr, // triggers clearing - UserID: user.ID, - } - - require.NoError(t, ds.SaveInstallerUpdates(ctx, payload)) - - // Assert that fleet_maintained_app_id is now NULL - var fmaID *uint - err = sqlx.GetContext(ctx, ds.reader(ctx), &fmaID, `SELECT fleet_maintained_app_id FROM software_installers WHERE id = ?`, installerID) - require.NoError(t, err) - assert.Nil(t, fmaID, "fleet_maintained_app_id should be NULL after update") -} - func testSoftwareInstallerReplicaLag(t *testing.T, _ *Datastore) { opts := &testing_utils.DatastoreTestOptions{DummyReplica: true} ds := CreateMySQLDSWithOptions(t, opts) diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index e6562808c0b..a337a029623 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -4760,10 +4760,12 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { { ID: policy1.ID, Name: policy1.Name, + Type: fleet.PolicyTypeDynamic, }, { ID: policy2.ID, Name: policy2.Name, + Type: fleet.PolicyTypeDynamic, }, } compareResults(expectedWithPolicies, sw, true) @@ -4948,10 +4950,12 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { { ID: policy3.ID, Name: policy3.Name, + Type: fleet.PolicyTypeDynamic, }, { ID: policy4.ID, Name: policy4.Name, + Type: fleet.PolicyTypeDynamic, }, }, }, diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index cc33599a5cc..08a57633c45 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -909,7 +909,7 @@ func (ds *Datastore) DeleteVPPAppFromTeam(ctx context.Context, teamID *uint, app return ctxerr.Wrapf(ctx, err, "getting reference from policies") } if count > 0 { - return errDeleteInstallerWithAssociatedPolicy + return errDeleteInstallerWithAssociatedInstallPolicy } } diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 31f80a810a8..7d3adadf0ec 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -1765,7 +1765,7 @@ func testDeleteVPPAssignedToPolicy(t *testing.T, ds *Datastore) { err = ds.DeleteVPPAppFromTeam(ctx, ptr.Uint(0), va1.VPPAppID) require.Error(t, err) - require.ErrorIs(t, err, errDeleteInstallerWithAssociatedPolicy) + require.ErrorIs(t, err, errDeleteInstallerWithAssociatedInstallPolicy) _, err = ds.DeleteTeamPolicies(ctx, fleet.PolicyNoTeamID, []uint{p1.ID}) require.NoError(t, err) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 3b69f86dfcb..c0b4e50e2eb 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -849,6 +849,8 @@ type Datastore interface { GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) // GetPoliciesForConditionalAccess returns the team policies that are configured for "Conditional access". GetPoliciesForConditionalAccess(ctx context.Context, teamID uint) ([]uint, error) + // GetPatchPolicy returns the patch policy associated with the title id + GetPatchPolicy(ctx context.Context, teamID *uint, titleID uint) (*PatchPolicyData, error) // ConditionalAccessBypassDevice lets the host skip the conditional access check next time it fails ConditionalAccessBypassDevice(ctx context.Context, hostID uint) error diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 82bde734add..c5d745afe53 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -141,9 +141,6 @@ func (p PolicyPayload) Verify() error { if p.QueryID != nil { return errPolicyPatchAndQuerySet } - if err := verifyPolicyName(p.Name); err != nil { - return err - } if !emptyString(p.Query) { return errPolicyPatchAndQuerySet } diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 47e1a8258bf..8da81b3a8f2 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -133,6 +133,9 @@ type SoftwareInstaller struct { // DisplayName is an end-user friendly name. DisplayName string `json:"display_name"` + + // PatchPolicy is present for Fleet maintained apps with an associated patch policy + PatchPolicy *PatchPolicyData `json:"patch_policy"` } // SoftwarePackageResponse is the response type used when applying software by batch. @@ -726,6 +729,12 @@ type AutomaticInstallPolicy struct { ID uint `json:"id" db:"id"` Name string `json:"name" db:"name"` TitleID uint `json:"-" db:"software_title_id"` + Type string `json:"type" db:"type"` +} + +type PatchPolicyData struct { + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` } // SoftwarePackageOrApp provides information about a software installer diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 411ef48ec31..e71e7a2cc08 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -643,6 +643,8 @@ type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.Pol type GetPoliciesForConditionalAccessFunc func(ctx context.Context, teamID uint) ([]uint, error) +type GetPatchPolicyFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.PatchPolicyData, error) + type ConditionalAccessBypassDeviceFunc func(ctx context.Context, hostID uint) error type ConditionalAccessConsumeBypassFunc func(ctx context.Context, hostID uint) (*time.Time, error) @@ -2716,6 +2718,9 @@ type DataStore struct { GetPoliciesForConditionalAccessFunc GetPoliciesForConditionalAccessFunc GetPoliciesForConditionalAccessFuncInvoked bool + GetPatchPolicyFunc GetPatchPolicyFunc + GetPatchPolicyFuncInvoked bool + ConditionalAccessBypassDeviceFunc ConditionalAccessBypassDeviceFunc ConditionalAccessBypassDeviceFuncInvoked bool @@ -6602,6 +6607,13 @@ func (s *DataStore) GetPoliciesForConditionalAccess(ctx context.Context, teamID return s.GetPoliciesForConditionalAccessFunc(ctx, teamID) } +func (s *DataStore) GetPatchPolicy(ctx context.Context, teamID *uint, titleID uint) (*fleet.PatchPolicyData, error) { + s.mu.Lock() + s.GetPatchPolicyFuncInvoked = true + s.mu.Unlock() + return s.GetPatchPolicyFunc(ctx, teamID, titleID) +} + func (s *DataStore) ConditionalAccessBypassDevice(ctx context.Context, hostID uint) error { s.mu.Lock() s.ConditionalAccessBypassDeviceFuncInvoked = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 64a1a47584e..3acbafa03db 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -26406,6 +26406,42 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "Team 1" + t.Name()}) require.NoError(t, err) + // mock an installer being uploaded through FMA + updateInstallerFMAID := func(fmaID, teamID, titleID uint) { + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err = q.ExecContext(ctx, `UPDATE software_installers SET fleet_maintained_app_id = ? WHERE global_or_team_id = ? AND title_id = ?`, fmaID, teamID, titleID) + require.NoError(t, err) + return nil + }) + } + + // resetFMAState resets an fmaTestState to the given version and installer bytes. + resetFMAState := func(state *fmaTestState, version string, installerBytes []byte) { + state.version = version + state.installerBytes = installerBytes + state.ComputeSHA(installerBytes) + } + + checkPolicy := func(policy *fleet.Policy, name, version string, titleID uint) { + require.NotNil(t, policy.PatchSoftware) + require.Equal(t, titleID, policy.PatchSoftware.SoftwareTitleID) + require.Equal(t, fleet.PolicyTypePatch, policy.Type) + require.Equal(t, fmt.Sprintf(`SELECT 1 FROM programs WHERE name = 'Zoom Workplace (X64)' AND version_compare(bundle_short_version, '%s') >= 0;`, version), policy.Query) + require.Equal(t, name, policy.Name) + } + + createHostPolicyResults := func(host *fleet.Host, policy *fleet.Policy) { + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + } + t.Run("no_team_patch_policy", func(t *testing.T) { // add a regular "No team" policy tpParams := teamPolicyRequest{ @@ -26436,12 +26472,7 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { TeamID: nil, } s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") - - var titleID uint - mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(context.Background(), q, &titleID, `SELECT title_id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, 0, payload.Filename) - }) - require.NotZero(t, titleID) + titleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps") // try to add a patch policy that matches to an installer, but not an FMA params = map[string]any{ @@ -26472,15 +26503,15 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { // add the same patch policy, but this time it belongs to an FMA policyResp := teamPolicyResponse{} s.DoJSON("POST", "/api/latest/fleet/fleets/0/policies", teamPolicyRequest{ - Name: "test-4", + Name: "", Query: "", - Description: "um", + Description: "", Type: ptr.String("patch"), PatchSoftwareTitleID: &titleID, }, http.StatusOK, &policyResp) - require.Equal(t, policyResp.Policy.Name, "macOS - DummyApp up to date") - require.Equal(t, policyResp.Policy.Description, "Outdated software might introduce security vulnerabilities or compatibility issues.") - require.Equal(t, *policyResp.Policy.Resolution, "Install the latest version from self-service.") + require.Equal(t, "macOS - DummyApp up to date", policyResp.Policy.Name) + require.Equal(t, "Outdated software might introduce security vulnerabilities or compatibility issues.", policyResp.Policy.Description) + require.Equal(t, "Install the latest version from self-service.", *policyResp.Policy.Resolution) require.Contains(t, policyResp.Policy.Query, "SELECT 1 FROM apps WHERE bundle_identifier =") require.NotNil(t, policyResp.Policy.PatchSoftware) require.Equal(t, titleID, policyResp.Policy.PatchSoftware.SoftwareTitleID) @@ -26567,8 +26598,76 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { require.Empty(t, getPolicyResp.Policy.PatchSoftwareTitleID) }) + t.Run("patch_policy_lifecycle", func(t *testing.T) { + // Test 1: delete software installer when there is an associatd patch policy + + resp := teamResponse{} + s.DoJSON("POST", "/api/latest/fleet/fleets", &createTeamRequest{ + TeamPayload: fleet.TeamPayload{Name: ptr.String("team_1")}, + }, http.StatusOK, &resp) + teamID := resp.Team.ID + + // upload a software installer + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some install script", + Filename: "dummy_installer.pkg", + TeamID: &teamID, + } + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") + titleID := getSoftwareTitleID(t, s.ds, "DummyApp", "apps") + fmaID := getFleetMaintainedAppID(t, s.ds, "dummy/darwin") + + // add a fleet maintained app and associate the installer with it + updateInstallerFMAID(fmaID, teamID, titleID) + + // add a patch policy + policyResp := teamPolicyResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", teamID), teamPolicyRequest{ + Type: ptr.String("patch"), + PatchSoftwareTitleID: &titleID, + }, http.StatusOK, &policyResp) + + // attempt to delete installer + res := s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusConflict, "team_id", strconv.Itoa(int(teamID))) //nolint:gosec // dismiss G115 + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, `Couldn’t delete. This software has a patch policy. Please remove the patch policy and try again.`) + res.Body.Close() + + // remove patch policy and delete installer + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies/delete", teamID), deleteTeamPoliciesRequest{ + TeamID: teamID, + IDs: []uint{policyResp.Policy.ID}}, http.StatusOK, &deleteTeamPoliciesResponse{}) + + // can delete installer successfully + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", strconv.Itoa(int(teamID))) //nolint:gosec // dismiss G115 + + // Test 2: Updating an FMA installer with a new file fails + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") + updateInstallerFMAID(fmaID, teamID, titleID) + + dummyBytes, err := os.ReadFile("testdata/software-installers/dummy_installer.pkg") + require.NoError(t, err) + body, headers := generateMultipartRequest(t, "software", "dummy_installer.pkg", dummyBytes, s.token, map[string][]string{"team_id": {strconv.Itoa(int(teamID))}}) //nolint:gosec // dismiss G115 + res = s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusBadRequest, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, `Couldn't update. The package can't be changed for Fleet-maintained apps.`) + res.Body.Close() + }) + t.Run("batch team patch policy", func(t *testing.T) { - startFMAServers(t, s.ds) + // initialize FMA server + states := make(map[string]*fmaTestState, 1) + states["/zoom/windows.json"] = &fmaTestState{ + version: "1.0", + installerBytes: []byte("xyz"), + installerPath: "/zoom.msi", + } + startFMAServers(t, s.ds, states) + + // Add some hosts + teamHosts := s.createHosts(t, "windows", "windows") + require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{teamHosts[0].ID}))) + require.NoError(t, s.ds.AddHostsToTeam(context.Background(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{teamHosts[1].ID}))) getActiveTitleForTeam := func(teamID uint, titleName string) fleet.SoftwareTitleListResult { var resp listSoftwareTitlesResponse @@ -26614,9 +26713,7 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { var listPolResp = listTeamPoliciesResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", team.ID), listTeamPoliciesRequest{}, http.StatusOK, &listPolResp, "page", "0") require.Len(t, listPolResp.Policies, 1) - require.NotNil(t, listPolResp.Policies[0].PatchSoftware) - require.Equal(t, title.ID, listPolResp.Policies[0].PatchSoftware.SoftwareTitleID) - require.Equal(t, fleet.PolicyTypePatch, listPolResp.Policies[0].Type) + checkPolicy(listPolResp.Policies[0], spec.Name, "1.0", title.ID) // now enable automation on the policy spec = &fleet.PolicySpec{ @@ -26639,9 +26736,7 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { listPolResp = listTeamPoliciesResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", team.ID), listTeamPoliciesRequest{}, http.StatusOK, &listPolResp, "page", "0") require.Len(t, listPolResp.Policies, 1) - require.NotNil(t, listPolResp.Policies[0].PatchSoftware) - require.Equal(t, title.ID, listPolResp.Policies[0].PatchSoftware.SoftwareTitleID) - require.Equal(t, fleet.PolicyTypePatch, listPolResp.Policies[0].Type) + checkPolicy(listPolResp.Policies[0], spec.Name, "1.0", title.ID) // This is only set if the automation is enable require.Equal(t, title.ID, listPolResp.Policies[0].InstallSoftware.SoftwareTitleID) @@ -26665,9 +26760,71 @@ func (s *integrationEnterpriseTestSuite) TestPatchPolicies() { listPolResp = listTeamPoliciesResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", team.ID), listTeamPoliciesRequest{}, http.StatusOK, &listPolResp, "page", "0") require.Len(t, listPolResp.Policies, 1) - require.NotNil(t, listPolResp.Policies[0].PatchSoftware) - require.Equal(t, title.ID, listPolResp.Policies[0].PatchSoftware.SoftwareTitleID) - require.Equal(t, fleet.PolicyTypePatch, listPolResp.Policies[0].Type) + checkPolicy(listPolResp.Policies[0], spec.Name, "1.0", title.ID) require.Nil(t, listPolResp.Policies[0].InstallSoftware) + + // check policy membership + createHostPolicyResults(teamHosts[0], listPolResp.Policies[0]) + listPolResp.Policies[0], err = s.ds.Policy(ctx, listPolResp.Policies[0].ID) + require.NoError(t, err) + require.Equal(t, uint(1), listPolResp.Policies[0].FailingHostCount) + + // Test 2: FMA Version is updated (query should use new version) + resetFMAState(states["/zoom/windows.json"], "1.2", []byte("abc")) + + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows")}}, TeamName: team.Name}, + http.StatusAccepted, &resp, + "team_name", team.Name, "team_id", fmt.Sprint(team.ID), + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, team.Name, resp.RequestUUID) + + applyResp = applyPolicySpecsResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/policies", + applyPolicySpecsRequest{Specs: []*fleet.PolicySpec{spec}}, + http.StatusOK, &applyResp, + ) + title = getActiveTitleForTeam(team.ID, "zoom") + + listPolResp = listTeamPoliciesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", team.ID), listTeamPoliciesRequest{}, http.StatusOK, &listPolResp, "page", "0") + require.Len(t, listPolResp.Policies, 1) + checkPolicy(listPolResp.Policies[0], spec.Name, "1.2", title.ID) + + // check policy membership + listPolResp.Policies[0], err = s.ds.Policy(ctx, listPolResp.Policies[0].ID) + require.NoError(t, err) + require.Equal(t, uint(0), listPolResp.Policies[0].FailingHostCount) + + // Test 3: FMA Version is pinned back after update? (query should use old version) + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{ + {Slug: ptr.String("zoom/windows"), SelfService: true, RollbackVersion: "1.0"}, + }, TeamName: team.Name}, + http.StatusAccepted, &resp, + "team_name", team.Name, "team_id", fmt.Sprint(team.ID), + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, team.Name, resp.RequestUUID) + + applyResp = applyPolicySpecsResponse{} + s.DoJSON("POST", "/api/latest/fleet/spec/policies", + applyPolicySpecsRequest{Specs: []*fleet.PolicySpec{spec}}, + http.StatusOK, &applyResp, + ) + title = getActiveTitleForTeam(team.ID, "zoom") + + listPolResp = listTeamPoliciesResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/fleets/%d/policies", team.ID), listTeamPoliciesRequest{}, http.StatusOK, &listPolResp, "page", "0") + require.Len(t, listPolResp.Policies, 1) + checkPolicy(listPolResp.Policies[0], spec.Name, "1.0", title.ID) + }) } + +func getFleetMaintainedAppID(t *testing.T, ds *mysql.Datastore, slug string) uint { + var id uint + mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(context.Background(), q, &id, `SELECT id FROM fleet_maintained_apps WHERE slug = ?`, slug) + }) + return id +} diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 82673fde84c..37e0d24349b 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -208,6 +208,13 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") } meta.FleetMaintainedVersions = fmaVersions + + // Populate PatchPolicy if there is one + patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get patch policy") + } + meta.PatchPolicy = patchPolicy } } diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index 6e0eaf48faa..33e48048ba1 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -1420,43 +1420,55 @@ func messageWithAndroidIdentifiers(t *testing.T, notificationType android.Notifi } } -func startFMAServers(t *testing.T, ds fleet.Datastore) { - // TODO: allow for configurable FMAs - type fmaTestState struct { - version string - installerBytes []byte - sha256 string - } +type fmaTestState struct { + version string + installerBytes []byte + sha256 string + installerPath string +} - computeSHA := func(b []byte) string { - h := sha256.New() - h.Write(b) - return hex.EncodeToString(h.Sum(nil)) - } +func (s *fmaTestState) ComputeSHA(b []byte) { + h := sha256.New() + h.Write(b) + s.sha256 = hex.EncodeToString(h.Sum(nil)) +} - zoomState := &fmaTestState{version: "1.0", installerBytes: []byte("xyz")} - zoomState.sha256 = computeSHA(zoomState.installerBytes) +func startFMAServers(t *testing.T, ds fleet.Datastore, states map[string]*fmaTestState) { + if len(states) == 0 { + states = make(map[string]*fmaTestState, 1) + states["/zoom/windows.json"] = &fmaTestState{ + version: "1.0", + installerBytes: []byte("xyz"), + installerPath: "/zoom.msi", + } + } + statesByInstallerPath := make(map[string]*fmaTestState, len(states)) + for _, state := range states { + state.ComputeSHA(state.installerBytes) + statesByInstallerPath[state.installerPath] = state + } var downloadMu sync.Mutex // Mock installer server — routes by path to serve per-FMA bytes. installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { downloadMu.Lock() defer downloadMu.Unlock() - _, _ = w.Write(zoomState.installerBytes) + state, found := statesByInstallerPath[r.URL.Path] + if !found { + http.NotFound(w, r) + return + } + _, _ = w.Write(state.installerBytes) })) maintained_apps.SyncApps(t, ds) manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var state *fmaTestState - var installerPath string - switch r.URL.Path { - case "/zoom/windows.json": - state = zoomState - installerPath = "/zoom.msi" - default: + state, found := states[r.URL.Path] + if !found { http.NotFound(w, r) return } @@ -1465,7 +1477,7 @@ func startFMAServers(t *testing.T, ds fleet.Datastore) { { Version: state.version, Queries: ma.FMAQueries{Exists: "SELECT 1 FROM osquery_info;"}, - InstallerURL: installerServer.URL + installerPath, + InstallerURL: installerServer.URL + state.installerPath, InstallScriptRef: "foobaz", UninstallScriptRef: "foobaz", SHA256: state.sha256, From 5929d6aafa65dafb682991611b6d1be761d91e6b Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:34:08 -0400 Subject: [PATCH 5/7] Fleet UI: Update Patch pill to use PillBadge (#41266) --- .../SoftwareInstallPolicyBadges.tsx | 16 +++++++--------- .../PoliciesTable/PoliciesTableConfig.tsx | 6 ++++-- .../components/PoliciesTable/_styles.scss | 14 -------------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx b/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx index c3e641cf9e7..42bf349ab97 100644 --- a/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx +++ b/frontend/components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges.tsx @@ -8,21 +8,19 @@ import PillBadge from "components/PillBadge"; const baseClass = "software-install-policy-badges"; +export const PATCH_TOOLTIP_CONTENT = ( + <> + Hosts will fail this policy if they're
+ running an older version. + +); interface IPatchBadgesProps { policyType?: SoftwareInstallPolicyTypeSet; } const SoftwareInstallPolicyBadges = ({ policyType }: IPatchBadgesProps) => { const renderPatchBadge = () => ( - - Hosts will fail this policy if they're
- running an older version. - - } - /> + ); const renderAutomaticInstallBadge = () => ( diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx index 63044283a7e..d02b7af1dda 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesTable/PoliciesTableConfig.tsx @@ -17,6 +17,7 @@ import sortUtils from "utilities/sort"; import { DEFAULT_EMPTY_CELL_VALUE, PolicyResponse } from "utilities/constants"; import PillBadge from "components/PillBadge"; +import { PATCH_TOOLTIP_CONTENT } from "components/SoftwareInstallPolicyBadges/SoftwareInstallPolicyBadges"; import { getConditionalSelectHeaderCheckboxProps } from "components/TableContainer/utilities/config_utils"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; @@ -117,8 +118,9 @@ const generateTableHeaders = ( value={cellProps.cell.value} suffix={ <> - {/* TODO: Replace with component once available */} - {type === "patch" && Patch} + {type === "patch" && ( + + )} {isPremiumTier && critical && (
Date: Wed, 11 Mar 2026 13:46:45 -0400 Subject: [PATCH 6/7] styles pass --- .../PolicyAutomations/PolicyAutomations.tsx | 22 +++++++++++++------ .../components/PolicyAutomations/_styles.scss | 20 +++++++++++------ .../components/PolicyForm/PolicyForm.tsx | 18 ++++++++++----- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx index b979fb45611..d046f755ea4 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/PolicyAutomations.tsx @@ -8,6 +8,7 @@ import { getPathWithQueryParams } from "utilities/url"; import Button from "components/buttons/Button"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import Icon from "components/Icon/Icon"; +import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; const baseClass = "policy-automations"; @@ -22,7 +23,8 @@ interface IPolicyAutomationsProps { interface IAutomationRow { name: string; type: string; - iconName: IconNames; + iconName?: IconNames; + isSoftware?: boolean; link?: string; sortOrder: number; sortName: string; @@ -47,7 +49,7 @@ const PolicyAutomations = ({ automationRows.push({ name: storedPolicy.install_software.name, type: "Software", - iconName: "install", + isSoftware: true, link: getPathWithQueryParams( PATHS.SOFTWARE_TITLE_DETAILS( storedPolicy.install_software.software_title_id.toString() @@ -145,11 +147,17 @@ const PolicyAutomations = ({ className={`${baseClass}__row`} > - + {row.isSoftware ? ( + + ) : ( + row.iconName && ( + + ) + )} {row.link ? {row.name} : row.name} {row.type} diff --git a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss index 5c43047f56d..36c29489000 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss +++ b/frontend/pages/policies/PolicyPage/components/PolicyAutomations/_styles.scss @@ -1,11 +1,13 @@ .policy-automations { margin-top: $pad-medium; + max-width: 600px; &__cta-card { display: flex; align-items: center; justify-content: space-between; padding: $pad-medium; + background-color: $ui-fleet-black-5; border: 1px solid $ui-fleet-black-10; border-radius: 8px; margin-bottom: $pad-medium; @@ -19,6 +21,9 @@ &__list { margin-top: $pad-small; + border: 1px solid $ui-fleet-black-10; + border-radius: 8px; + padding: 0 $pad-medium; } &__list-header { @@ -26,19 +31,16 @@ font-weight: $bold; text-transform: uppercase; color: $ui-fleet-black-50; - margin-bottom: $pad-small; + padding-top: $pad-medium; + padding-bottom: $pad-small; } &__row { display: flex; align-items: center; justify-content: space-between; - padding: $pad-small 0; - border-bottom: 1px solid $ui-fleet-black-10; - - &:last-child { - border-bottom: none; - } + padding: 12px 0; + border-top: 1px solid $ui-fleet-black-10; } &__row-icon { @@ -47,6 +49,10 @@ color: $ui-fleet-black-50; } + .software-icon { + margin-right: $pad-small; + } + &__row-name { display: flex; align-items: center; diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx index f46a61ebace..50bedb50b99 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tsx @@ -791,7 +791,18 @@ const PolicyForm = ({ value={lastEditedQueryBody} error={errors.query} label="Query" - labelActionComponent={renderLabelComponent()} + labelActionComponent={ + isPatchPolicy ? ( + + + + ) : ( + renderLabelComponent() + ) + } name="query editor" onLoad={onLoad} wrapperClassName={`${baseClass}__text-editor-wrapper form-field`} @@ -800,11 +811,6 @@ const PolicyForm = ({ wrapEnabled focus={!isExistingPolicy} readOnly={isPatchPolicy} - helpText={ - isPatchPolicy - ? "This query is automatically managed by Fleet." - : undefined - } /> {renderPlatformCompatibility()} {isExistingPolicy && !isPatchPolicy && platformSelector.render()} From e9d3d7207ecaae1016d4f56aeb2938792e9f3798 Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Wed, 11 Mar 2026 14:51:43 -0400 Subject: [PATCH 7/7] add tests --- .../PolicyForm/PolicyForm.tests.tsx | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx index 016d100e13f..062eb8ed9ad 100644 --- a/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx +++ b/frontend/pages/policies/PolicyPage/components/PolicyForm/PolicyForm.tests.tsx @@ -448,6 +448,121 @@ describe("PolicyForm - component", () => { expect(onUpdate.mock.calls[0][0].labels_exclude_any).toEqual([]); }); }); + + describe("patch policy behavior", () => { + const patchPolicy = createMockPolicy({ + type: "patch", + platform: "darwin", + patch_software: { name: "Firefox", software_title_id: 42 }, + install_software: undefined, + }); + + const patchPolicyProps = { + ...defaultProps, + storedPolicy: patchPolicy, + }; + + const renderPatchPolicy = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: createMockUser(), + isGlobalObserver: false, + isGlobalAdmin: true, + isGlobalMaintainer: false, + isOnGlobalTeam: true, + isPremiumTier: true, + isSandboxMode: false, + config: createMockConfig(), + }, + policy: { + policyTeamId: undefined, + lastEditedQueryId: patchPolicy.id, + lastEditedQueryName: patchPolicy.name, + lastEditedQueryDescription: patchPolicy.description, + lastEditedQueryBody: patchPolicy.query, + lastEditedQueryResolution: patchPolicy.resolution, + lastEditedQueryCritical: patchPolicy.critical, + lastEditedQueryPlatform: patchPolicy.platform, + lastEditedQueryLabelsIncludeAny: [], + lastEditedQueryLabelsExcludeAny: [], + defaultPolicy: false, + setLastEditedQueryName: jest.fn(), + setLastEditedQueryDescription: jest.fn(), + setLastEditedQueryBody: jest.fn(), + setLastEditedQueryResolution: jest.fn(), + setLastEditedQueryCritical: jest.fn(), + setLastEditedQueryPlatform: jest.fn(), + }, + }, + }); + + it("hides platform selector", () => { + renderPatchPolicy(); + expect(screen.queryByLabelText("macOS")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Windows")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Linux")).not.toBeInTheDocument(); + }); + + it("hides target label selector", () => { + renderPatchPolicy(); + expect(screen.queryByLabelText("All hosts")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument(); + }); + + it("submits only editable fields on save", async () => { + const onUpdate = jest.fn(); + renderPatchPolicy( + + ); + + const saveButton = await screen.findByRole("button", { name: "Save" }); + await userEvent.click(saveButton); + + expect(onUpdate).toHaveBeenCalledTimes(1); + const payload = onUpdate.mock.calls[0][0]; + expect(payload).toHaveProperty("name"); + expect(payload).toHaveProperty("description"); + expect(payload).toHaveProperty("resolution"); + expect(payload).toHaveProperty("critical"); + expect(payload).not.toHaveProperty("query"); + expect(payload).not.toHaveProperty("platform"); + expect(payload).not.toHaveProperty("labels_include_any"); + }); + + it("shows 'Add automation' CTA when patch policy has no install_software", async () => { + renderPatchPolicy(); + await waitFor(() => { + expect( + screen.getByText(/Automatically patch Firefox/) + ).toBeInTheDocument(); + expect(screen.getByText(/Add automation/)).toBeInTheDocument(); + }); + }); + + it("hides 'Add automation' CTA when automation already exists", async () => { + const automatedPatchPolicy = createMockPolicy({ + type: "patch", + platform: "darwin", + patch_software: { name: "Firefox", software_title_id: 42 }, + install_software: { name: "Firefox", software_title_id: 42 }, + }); + renderPatchPolicy( + + ); + + // Wait for the component to fully render, then assert CTA is absent + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Save" }) + ).toBeInTheDocument(); + }); + expect(screen.queryByText(/Add automation/)).not.toBeInTheDocument(); + }); + }); }); // TODO: Consider testing save button is disabled for a sql error // Trickiness is in modifying react-ace using react-testing library