From 2c9fa3767e54714dba8310d67dd4a03e816a869e Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:26:20 -0700 Subject: [PATCH] =?UTF-8?q?Fleet=20UI:=20Multi-package=20secondary=20UI=20?= =?UTF-8?q?=E2=80=94=20policy=20automation,=20setup=20experience,=20instal?= =?UTF-8?q?l-details=20hash=20(#49079)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SoftwareInstallDetailsModal.tests.tsx | 58 +++ .../SoftwareInstallDetailsModal.tsx | 27 ++ .../SoftwareInstallDetailsModal/_styles.scss | 13 + .../DropdownWrapper/DropdownWrapper.tsx | 12 + frontend/interfaces/policy.ts | 8 + frontend/interfaces/software.ts | 4 + .../InstallSoftwareTableConfig.tsx | 16 +- .../LibraryItemAccordion.tsx | 14 +- .../PolicyAutomationsFields.tests.tsx | 340 ++++++++++++++++++ .../PolicyAutomationsFields.tsx | 141 +++++++- .../PolicyAutomationsFields/_styles.scss | 48 ++- .../edit/components/PolicyForm/_styles.scss | 23 ++ frontend/pages/policies/helpers.tests.tsx | 55 +++ frontend/pages/policies/helpers.ts | 51 ++- .../hooks/useUpdatePolicyAutomations.ts | 1 + frontend/services/entities/team_policies.ts | 2 + frontend/test/handlers/software-handlers.ts | 15 + server/datastore/mysql/policies.go | 1 + server/datastore/mysql/policies_test.go | 14 +- server/datastore/mysql/software_titles.go | 35 +- .../datastore/mysql/software_titles_test.go | 58 +++ server/fleet/policies.go | 7 + server/fleet/software_installer.go | 18 +- server/service/software_titles.go | 18 +- server/service/team_policies.go | 9 +- server/service/team_policies_test.go | 8 + 26 files changed, 947 insertions(+), 49 deletions(-) create mode 100644 frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx index 4e07eb4945..8f3f663aea 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tests.tsx @@ -7,6 +7,7 @@ import { getDefaultSoftwareInstallHandler, getSoftwareInstallHandlerNoOutputs, getSoftwareInstallHandlerOnlyInstallOutput, + getSoftwareInstallHandlerWithHash, getSoftwareInstallHandlerWithPreInstall, getSoftwareInstallHandlerOnlyPreInstallOutput, getSoftwareInstallResultHandlerPremiumRequired, @@ -414,4 +415,61 @@ describe("SoftwareInstallDetailsModal", () => { ); }); }); + + // The Package SHA-256 hash row is guarded on the payload's `hash_sha256` + // field. Backend hydrates it for package-backed installs; VPP / older + // results carry no hash and the row must stay out of the DOM. + describe("Package SHA-256 hash row", () => { + afterEach(() => { + mockServer.resetHandlers(); + }); + + it("renders the label, hash, and a copy button when the install result carries hash_sha256", async () => { + mockServer.use(getSoftwareInstallHandlerWithHash); + const renderWithServer = createCustomRenderer({ withBackendMock: true }); + + renderWithServer( + + ); + + expect( + await screen.findByText("Package SHA-256 hash:") + ).toBeInTheDocument(); + expect( + screen.getByText( + "e6ddb2dd089ecea38ab73ed12812df269f1447e750cf4355703340bb8aa1ad" + ) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Copy hash to clipboard/i }) + ).toBeInTheDocument(); + }); + + it("does not render the hash row when the install result has no hash_sha256", async () => { + mockServer.use(getDefaultSoftwareInstallHandler); + const renderWithServer = createCustomRenderer({ withBackendMock: true }); + + renderWithServer( + + ); + + // Wait for the modal to finish loading (status message is a good + // anchor — it renders after the useQuery resolves). + await screen.findByText(/Fleet installed/); + expect( + screen.queryByText("Package SHA-256 hash:") + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Copy hash to clipboard/i }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx index a4048c0f18..b03bfdc182 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx @@ -27,14 +27,17 @@ import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers"; import Modal from "components/Modal"; import ModalFooter from "components/ModalFooter"; import Button from "components/buttons/Button"; +import CopyButton from "components/buttons/CopyButton"; import IconStatusMessage from "components/IconStatusMessage"; import Textarea from "components/Textarea"; import DataError from "components/DataError/DataError"; +import DataSet from "components/DataSet"; import DeviceUserError from "components/DeviceUserError"; import Spinner from "components/Spinner/Spinner"; import RevealButton from "components/buttons/RevealButton"; import CustomLink from "components/CustomLink"; import PremiumFeatureMessage from "components/PremiumFeatureMessage"; +import TooltipTruncatedText from "components/TooltipTruncatedText"; import { INSTALL_DETAILS_STATUS_ICONS, @@ -452,6 +455,30 @@ export const SoftwareInstallDetailsModal = ({ canOverrideFailureWithInstalled={canOverrideFailureWithInstalled} /> + {/* Package SHA-256 hash — backend hydrates `hash_sha256` on the + install result. Guarded so the row stays out of the DOM for + older results and VPP/App-Store paths whose payload doesn't + carry a package hash. */} + {swInstallResult?.hash_sha256 && ( +
+ + + + + } + /> +
+ )} + {shouldShowInventoryVersions && renderInventoryVersionsSection()} {isInstalledByFleet && !overrideFailedMessageWithInstalledMessage && diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss index 1eaf381b1d..1be821a3ca 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/_styles.scss @@ -13,4 +13,17 @@ .reveal-button { width: min-content; } + + // Hash + copy button sit inline. `min-width: 0` on the truncated text is + // load-bearing — flex items don't shrink below their content by default, + // which would push the copy button off-screen for long hashes. + &__hash-row .data-set dd { + align-items: center; + gap: $pad-xsmall; + } + + &__hash { + min-width: 0; + flex: 1; + } } diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 63c7ae6a83..7fa7035ce6 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -125,6 +125,12 @@ export interface IDropdownWrapper { * aligning right to fit text on screen */ nowrapMenu?: boolean; customNoOptionsMessage?: string; + /** Explicit accessible name for the combobox. When omitted, the resolved + * aria-label falls back to `placeholder`, then `name`, so existing call + * sites get at least a rough label without opting in. react-select does + * not infer any of these on its own; without a value here screen readers + * announce a bare "combobox". */ + ariaLabel?: string; } const getOptionBackgroundColor = ( @@ -452,6 +458,7 @@ const DropdownWrapper = ({ variant, nowrapMenu, customNoOptionsMessage, + ariaLabel, }: IDropdownWrapper) => { const wrapperClassNames = classnames(baseClass, className, { [`${baseClass}__table-filter`]: variant === "table-filter", @@ -559,6 +566,11 @@ const DropdownWrapper = ({ placeholder={placeholder} onMenuOpen={onMenuOpen} controlShouldRenderValue={variant !== "button"} // Control doesn't change placeholder to selected value + // Resolve accessible name: explicit prop wins, otherwise fall back + // to the placeholder (usually "Select X"), otherwise the required + // `name` (often a kebab-case identifier — least readable but + // guaranteed present). + aria-label={ariaLabel ?? placeholder ?? name} /> ); diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index c9e9e26f64..bd8973851c 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -80,6 +80,10 @@ export interface IPolicySoftwareToInstall { display_name?: string; software_title_id: number; icon_url?: string | null; + /** Present when the policy pins a specific package on a multi-package + * title. Absent for VPP-backed policies. When absent the automations UI + * falls back to auto-selecting the title's first-added package. */ + software_installer_id?: number; } // Used on the manage hosts page and other places where aggregate stats are displayed @@ -141,6 +145,10 @@ export interface IPolicyFormData { conditional_access_enabled?: boolean; continuous_automations_enabled?: boolean; software_title_id?: number | null; + /** Pins the policy to a specific package on a multi-package title. `null` + * on PATCH lets the backend fall back to the title's first-added package + * (mirrors `software_title_id`'s unset asymmetry). */ + software_installer_id?: number | null; // null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script script_id?: number | null; labels_include_any?: string[]; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 04e2d2ac98..e8b086b771 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -546,6 +546,10 @@ export interface ISoftwareInstallResult { created_at: string; updated_at: string | null; self_service: boolean; + /** SHA-256 of the installer package. Present when the payload was + * hydrated from a package-backed install; absent for VPP / older results + * whose backend join hasn't been extended. */ + hash_sha256?: string; } // Script results are only install results, never uninstall diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx index f5a7247dd4..de51a53866 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareTable/InstallSoftwareTableConfig.tsx @@ -9,6 +9,7 @@ import TextCell from "components/TableContainer/DataTable/TextCell"; import SoftwareNameCell from "components/TableContainer/DataTable/SoftwareNameCell"; import Checkbox from "components/forms/fields/Checkbox"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import TooltipWrapper from "components/TooltipWrapper"; import { SetupExperiencePlatform } from "interfaces/platform"; import AndroidLatestVersionWithTooltip from "components/MDM/AndroidLatestVersionWithTooltip"; @@ -82,7 +83,20 @@ const generateTableConfig = ( sortType: "caseInsensitive", }, { - Header: "Version", + id: "version", + Header: () => ( + + For custom packages, the first +
+ added version will be installed. + + } + > + Version +
+ ), disableSortBy: true, Cell: (cellProps: ITableStringCellProps) => { if (platform === "android") { diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx index 3b38666ee4..b16b7c46d9 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx @@ -573,11 +573,17 @@ const LibraryItemAccordion = ({ ); - // Only FMA and App Store / Play Store rows are GitOps-locked (those - // installer types can't be managed via YAML); custom packages stay - // deletable. The `software` entity exception is honored via the wrapper. + // GitOps-lock the trash button for installer types whose mutations should + // flow through YAML rather than the UI: + // - FMA and App Store / Play Store: can't be managed via YAML in the + // ordinary sense, so UI mutations would just be reverted on next run. + // - Custom multi-package titles: the Edit modal is already visible-but- + // disabled for these in GitOps mode; delete follows the same lock so + // the row's mutation affordances stay consistent. + // Single-package custom titles keep the shipped behavior — deletable via + // UI with a GitOps banner in the delete modal. const isAppStore = installerType === "app-store"; - const lockedByGitOpsMode = isFma || isAppStore; + const lockedByGitOpsMode = isFma || isAppStore || canActivateMultiplePackages; const renderTrashButton = () => lockedByGitOpsMode ? ( diff --git a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx new file mode 100644 index 0000000000..2f72b46a86 --- /dev/null +++ b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tests.tsx @@ -0,0 +1,340 @@ +import React from "react"; +import { screen } from "@testing-library/react"; + +import { createCustomRenderer } from "test/test-utils"; +import createMockUser from "__mocks__/userMock"; +import { + createMockSoftwareTitle, + createMockSoftwarePackage, + createMockAppStoreApp, +} from "__mocks__/softwareMock"; + +import { IPolicy } from "interfaces/policy"; +import { ISoftwareTitle } from "interfaces/software"; + +import PolicyAutomationsFields, { + IPolicyAutomationsFieldsHandle, +} from "./PolicyAutomationsFields"; +import useSoftwareTitles from "./hooks/useSoftwareTitles"; +import useScripts from "./hooks/useScripts"; + +jest.mock("./hooks/useSoftwareTitles"); +jest.mock("./hooks/useScripts"); +jest.mock("hooks/useGitOpsMode", () => ({ + __esModule: true, + default: () => ({ gitOpsModeEnabled: false }), +})); + +const mockedUseSoftwareTitles = useSoftwareTitles as jest.MockedFunction< + typeof useSoftwareTitles +>; +const mockedUseScripts = useScripts as jest.MockedFunction; + +const setSoftwareTitles = (titles: ISoftwareTitle[]) => { + mockedUseSoftwareTitles.mockReturnValue({ + data: { + count: titles.length, + counts_updated_at: null, + meta: { has_next_results: false, has_previous_results: false }, + software_titles: titles, + }, + } as ReturnType); +}; + +const emptyScriptsResponse = ({ + data: { + count: 0, + scripts: [], + meta: { has_next_results: false, has_previous_results: false }, + }, +} as unknown) as ReturnType; + +const createMockPolicy = (overrides?: Partial): IPolicy => ({ + id: 1, + name: "Test policy", + query: "SELECT 1;", + description: "", + author_id: 1, + author_name: "Admin", + author_email: "admin@example.com", + resolution: "", + platform: "darwin", + team_id: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + critical: false, + calendar_events_enabled: false, + conditional_access_enabled: false, + type: "dynamic", + ...overrides, +}); + +// Titles used across tests +const singlePackageTitle: ISoftwareTitle = createMockSoftwareTitle({ + id: 10, + name: "Single App", + source: "apps", + software_package: createMockSoftwarePackage({ + installer_id: 100, + name: "single-app.pkg", + version: "1.0.0", + uploaded_at: "2026-06-01T00:00:00Z", + }), + packages: [ + createMockSoftwarePackage({ + installer_id: 100, + name: "single-app.pkg", + version: "1.0.0", + uploaded_at: "2026-06-01T00:00:00Z", + }), + ], +}); + +const multiPackageTitle: ISoftwareTitle = createMockSoftwareTitle({ + id: 20, + name: "Multi App", + source: "apps", + software_package: createMockSoftwarePackage({ + installer_id: 200, + name: "multi-app-1.0.0.pkg", + version: "1.0.0", + uploaded_at: "2026-06-01T00:00:00Z", + }), + packages: [ + createMockSoftwarePackage({ + installer_id: 201, + name: "multi-app-2.0.0.pkg", + version: "2.0.0", + uploaded_at: "2026-06-15T00:00:00Z", + }), + // Out of order to prove `findFirstAddedPackage` picks by smallest id. + createMockSoftwarePackage({ + installer_id: 200, + name: "multi-app-1.0.0.pkg", + version: "1.0.0", + uploaded_at: "2026-06-01T00:00:00Z", + }), + createMockSoftwarePackage({ + installer_id: 202, + name: "multi-app-3.0.0.pkg", + version: "3.0.0", + uploaded_at: "2026-06-20T00:00:00Z", + }), + ], +}); + +const vppTitle: ISoftwareTitle = createMockSoftwareTitle({ + id: 30, + name: "VPP App", + source: "apps", + software_package: null, + app_store_app: createMockAppStoreApp({ version: "5.0.0" }), + packages: null, +}); + +const render = createCustomRenderer({ + context: { + app: { + currentUser: createMockUser({ global_role: "admin" }), + isGlobalAdmin: true, + isPremiumTier: true, + }, + }, +}); + +/** Renders the field, forwarding the passed-in ref directly to the + * component's `useImperativeHandle` so tests can call + * `getAutomationsPayload()` after auto-select effects settle. Passing the + * ref directly (vs copying it in a useEffect) avoids stale-closure reads: + * `useImperativeHandle` reassigns `ref.current` on every render, so the + * external `handleRef` always sees the latest closure. */ +const renderWithHandle = ( + policyOverrides?: Partial, + handleRef?: React.MutableRefObject +) => { + return render( + + ); +}; + +describe("PolicyAutomationsFields — Install software row", () => { + beforeEach(() => { + mockedUseScripts.mockReturnValue(emptyScriptsResponse); + setSoftwareTitles([singlePackageTitle, multiPackageTitle, vppTitle]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("does not render the Select software dropdown when Install software is off", () => { + renderWithHandle(); + expect( + screen.queryByRole("combobox", { name: /Select package/i }) + ).not.toBeInTheDocument(); + // The outer dropdown's accessible name comes from react-select's default; + // easier to check that the placeholder isn't in the DOM. + expect(screen.queryByText("Select software")).not.toBeInTheDocument(); + }); + + it("surfaces the Select package dropdown for a multi-package title and auto-selects the first-added (smallest installer_id)", () => { + renderWithHandle({ + install_software: { + name: "Multi App", + software_title_id: 20, + }, + }); + + // Multi-package title has 3 packages — second dropdown must render, and + // its selected option should be `multi-app-1.0.0.pkg` (installer_id 200 — + // smallest even though it's not first in the packages[] array). + const selectPackage = screen.getByRole("combobox", { + name: /Select package/i, + }); + expect(selectPackage).toBeInTheDocument(); + expect(screen.getByText("multi-app-1.0.0.pkg")).toBeInTheDocument(); + }); + + it("does not surface the Select package dropdown for a single-package title", () => { + renderWithHandle({ + install_software: { + name: "Single App", + software_title_id: 10, + }, + }); + + expect( + screen.queryByRole("combobox", { name: /Select package/i }) + ).not.toBeInTheDocument(); + }); + + it("does not surface the Select package dropdown for a VPP / App Store title (no packages[])", () => { + renderWithHandle({ + install_software: { + name: "VPP App", + software_title_id: 30, + }, + }); + + expect( + screen.queryByRole("combobox", { name: /Select package/i }) + ).not.toBeInTheDocument(); + }); + + it("surfaces the Select package dropdown at the exact 2-package threshold and preselects the first-added package", async () => { + // Pins the `packageOptions.length > 1` gate: two-package titles must + // show the picker, one-package titles must not. Guards against a + // future refactor accidentally moving the boundary to `>= 2` (same + // effective behavior) but also against `> 2` (which would silently + // hide the picker on the smallest multi-package title). Also confirms + // the auto-select effect preselects the first-added (smallest + // installer_id) — order-independent regardless of packages[] order. + setSoftwareTitles([ + createMockSoftwareTitle({ + id: 40, + name: "Duo App", + source: "apps", + packages: [ + // Out of order to prove first-added is picked by installer_id, + // not by array position. + createMockSoftwarePackage({ + installer_id: 401, + name: "duo-app-2.0.0.pkg", + version: "2.0.0", + uploaded_at: "2026-06-15T00:00:00Z", + }), + createMockSoftwarePackage({ + installer_id: 400, + name: "duo-app-1.0.0.pkg", + version: "1.0.0", + uploaded_at: "2026-06-01T00:00:00Z", + }), + ], + }), + ]); + renderWithHandle({ + install_software: { + name: "Duo App", + software_title_id: 40, + }, + }); + + expect( + screen.getByRole("combobox", { name: /Select package/i }) + ).toBeInTheDocument(); + // Auto-select is set by a useEffect (async post-commit); wait for the + // preselected label to appear rather than reading state synchronously. + expect(await screen.findByText("duo-app-1.0.0.pkg")).toBeInTheDocument(); + }); +}); + +describe("PolicyAutomationsFields — payload", () => { + beforeEach(() => { + mockedUseScripts.mockReturnValue(emptyScriptsResponse); + setSoftwareTitles([singlePackageTitle, multiPackageTitle, vppTitle]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("carries software_installer_id (auto-selected first-added) for a multi-package title", async () => { + const handleRef: React.MutableRefObject = { + current: null, + }; + renderWithHandle( + { + install_software: { + name: "Multi App", + software_title_id: 20, + }, + }, + handleRef + ); + + // Wait for the auto-select useEffect to hydrate the second dropdown + // (visible value = first-added filename) before reading the payload — + // otherwise we're reading state from the initial commit, before the + // effect has run. + await screen.findByText("multi-app-1.0.0.pkg"); + + const payload = handleRef.current?.getAutomationsPayload(); + expect(payload?.isValid).toBe(true); + // First-added by smallest installer_id = 200 + expect(payload?.policyUpdate?.software_installer_id).toBe(200); + expect(payload?.policyUpdate?.software_title_id).toBe(20); + }); + + it("does not error on save for a VPP title (must-fix: previously required non-null software_installer_id even without packages[])", () => { + const handleRef: React.MutableRefObject = { + current: null, + }; + renderWithHandle( + { + install_software: { + name: "VPP App", + software_title_id: 30, + }, + }, + handleRef + ); + + const payload = handleRef.current?.getAutomationsPayload(); + // Regression guard for the VPP path: validate() must NOT flag the + // missing installer_id when the selected title has no packages[]. The + // payload can still be dirty on legacy-load (form pre-fill logic); the + // point of this test is that isValid stays true so the parent can save. + expect(payload?.isValid).toBe(true); + // Backend picks the VPP install target from software_title_id; we send + // installer_id as null on the wire. + expect(payload?.policyUpdate?.software_installer_id ?? null).toBeNull(); + }); +}); diff --git a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx index 179d8bb1e8..a5bbc3a74f 100644 --- a/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx +++ b/frontend/pages/policies/components/PolicyAutomationsFields/PolicyAutomationsFields.tsx @@ -3,6 +3,7 @@ import React, { forwardRef, useContext, + useEffect, useImperativeHandle, useMemo, useState, @@ -26,7 +27,9 @@ import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; import TooltipWrapper from "components/TooltipWrapper"; import { + findFirstAddedPackage, generateSoftwareOptionHelpText, + generateSoftwarePackageOptionHelpText, getTicketOrWebhookInfo, getTicketOrWebhookLabel, } from "pages/policies/helpers"; @@ -163,6 +166,13 @@ const PolicyAutomationsFields = forwardRef< const [softwareTitleId, setSoftwareTitleId] = useState( policy.install_software?.software_title_id ?? null ); + // Pins the automation to a specific package on a multi-package title. + // When the policy payload doesn't carry `software_installer_id` (VPP + // titles never do; single-package titles didn't need it), the + // auto-select effect below resolves to first-added. + const [softwareInstallerId, setSoftwareInstallerId] = useState< + number | null + >(policy.install_software?.software_installer_id ?? null); const [scriptId, setScriptId] = useState( policy.run_script?.id ?? null ); @@ -181,6 +191,18 @@ const PolicyAutomationsFields = forwardRef< const newErrors: IAutomationsErrors = {}; if (installSoftware && softwareTitleId === null) { newErrors.install_software = "Please select software to install."; + } else if ( + installSoftware && + softwareTitleId !== null && + (selectedTitlePackages?.length ?? 0) > 0 && + softwareInstallerId === null + ) { + // Only reachable when a custom title (with packages[]) is selected + // but its packages haven't hydrated yet — the auto-select effect + // resolves this as soon as the softwareTitlesData query returns. + // VPP / App Store titles carry no packages[] and legitimately have + // no installer id, so the gate above excludes them. + newErrors.install_software = "Please select a package to install."; } if (runScript && scriptId === null) { newErrors.run_script = "Please select a script to run."; @@ -198,6 +220,13 @@ const PolicyAutomationsFields = forwardRef< }; const handleSelectSoftware = (id: number | null) => { setSoftwareTitleId(id); + // A title change invalidates the pinned installer — reset so the + // auto-select effect can pick first-added on the new title's packages. + setSoftwareInstallerId(null); + if (id !== null) clearError("install_software"); + }; + const handleSelectPackage = (id: number | null) => { + setSoftwareInstallerId(id); if (id !== null) clearError("install_software"); }; const handleSelectScript = (id: number | null) => { @@ -226,6 +255,49 @@ const PolicyAutomationsFields = forwardRef< [softwareTitlesData] ); + // Packages on the currently-selected title. Non-null only for custom + // multi-package titles — VPP / App Store titles carry no packages[]. + const selectedTitlePackages = useMemo(() => { + if (softwareTitleId === null) return null; + const selected = softwareTitlesData?.software_titles?.find( + (t) => t.id === softwareTitleId + ); + return selected?.packages ?? null; + }, [softwareTitleId, softwareTitlesData]); + + const packageOptions: CustomOptionType[] = useMemo( + () => + (selectedTitlePackages ?? []).map((pkg) => ({ + label: pkg.name, + value: String(pkg.installer_id), + helpText: generateSoftwarePackageOptionHelpText(pkg), + })), + [selectedTitlePackages] + ); + + // Auto-select the first-added package whenever the current selection + // isn't valid for the resolved packages list — covers three cases: + // 1. Fresh title selection: installer id was reset to null in + // handleSelectSoftware; pick first-added. + // 2. Legacy policy load: hydrated with software_title_id but no + // software_installer_id (e.g., policies created before backend + // surfaced the field); resolve to first-added on the title's packages. + // 3. Stale selection: an installer id that no longer appears on the + // title's packages (rare — e.g., a race where the package was + // deleted server-side); fall back to first-added rather than saving + // a broken pin. + useEffect(() => { + if (!selectedTitlePackages || selectedTitlePackages.length === 0) return; + const stillValid = + softwareInstallerId !== null && + selectedTitlePackages.some( + (p) => p.installer_id === softwareInstallerId + ); + if (stillValid) return; + const first = findFirstAddedPackage(selectedTitlePackages); + if (first) setSoftwareInstallerId(first.installer_id); + }, [selectedTitlePackages, softwareInstallerId]); + const scriptOptions: CustomOptionType[] = useMemo( () => (scriptsData?.scripts ?? []).map((s) => ({ @@ -248,6 +320,8 @@ const PolicyAutomationsFields = forwardRef< (installSoftware !== initialInstallSoftware || softwareTitleId !== (policy.install_software?.software_title_id ?? null) || + softwareInstallerId !== + (policy.install_software?.software_installer_id ?? null) || runScript !== initialRunScript || scriptId !== (policy.run_script?.id ?? null) || calendarEvent !== initialCalendar || @@ -261,6 +335,13 @@ const PolicyAutomationsFields = forwardRef< policyUpdate: perPolicyDirty ? { software_title_id: installSoftware ? softwareTitleId : null, + // Send the pinned installer id when install-software is on; + // omit when unchecked so the backend can clear it. Null + // (title selected, no packages hydrated yet) is a validation + // error and shouldn't reach here. + software_installer_id: installSoftware + ? softwareInstallerId + : null, script_id: runScript ? scriptId : null, // When the team has the feature disabled, the row is locked // and the user can't toggle it — so we omit the field instead @@ -310,21 +391,42 @@ const PolicyAutomationsFields = forwardRef< onToggle: handleToggleInstallSoftware, isDisabled: false, picker: installSoftware ? ( - o.value === String(softwareTitleId ?? "") - ) ?? null - } - options={softwareOptions} - placeholder="Select software" - onChange={(opt: SingleValue) => - handleSelectSoftware(opt ? Number(opt.value) : null) - } - /> +
+ o.value === String(softwareTitleId ?? "") + ) ?? null + } + options={softwareOptions} + placeholder="Select software" + onChange={(opt: SingleValue) => + handleSelectSoftware(opt ? Number(opt.value) : null) + } + /> + {/* Only surfaces for multi-package titles; first-added is + auto-selected above, so this is pin-adjustment. */} + {packageOptions.length > 1 && ( + o.value === String(softwareInstallerId ?? "") + ) ?? null + } + options={packageOptions} + placeholder="Select package" + onChange={(opt: SingleValue) => + handleSelectPackage(opt ? Number(opt.value) : null) + } + /> + )} +
) : undefined, }, { @@ -412,7 +514,14 @@ const PolicyAutomationsFields = forwardRef< : "" }`} > - + ( * { + margin-left: 0; + } + } + &__learn-more { font-size: $x-small; color: $ui-fleet-black-75; diff --git a/frontend/pages/policies/edit/components/PolicyForm/_styles.scss b/frontend/pages/policies/edit/components/PolicyForm/_styles.scss index 92a76d8d41..1d78ae5376 100644 --- a/frontend/pages/policies/edit/components/PolicyForm/_styles.scss +++ b/frontend/pages/policies/edit/components/PolicyForm/_styles.scss @@ -181,4 +181,27 @@ margin-bottom: 0; } } + +} + +// Responsive tweaks that only apply when the schema sidebar is open on the +// edit-policy page — that's when the automations table gets tight. Anchored +// on `:has(.side-panel-content)` so the sidebar's own render presence is +// the signal (no JS class needed). Modal-hosted PolicyAutomationsFields is +// never inside a SidePanelPage, so these rules can't accidentally target it. +.side-panel-page:has(.side-panel-content) { + // Below md, shrink the picker so both dropdowns still fit side-by-side. + @media (max-width: $break-md) { + .policy-automations-fields__row-picker { + width: 175px; + } + } + + // Below the table-controls breakpoint, stack the pickers vertically + // (each keeps its natural width) since even the shrunken pair won't fit. + @media (max-width: $table-controls-break) { + .policy-automations-fields__software-pickers { + flex-direction: column; + } + } } diff --git a/frontend/pages/policies/helpers.tests.tsx b/frontend/pages/policies/helpers.tests.tsx index f3a3fe7edd..4920c27041 100644 --- a/frontend/pages/policies/helpers.tests.tsx +++ b/frontend/pages/policies/helpers.tests.tsx @@ -35,6 +35,61 @@ describe("generateSoftwareOptionHelpText", () => { expect(generateSoftwareOptionHelpText(title)).toBe("macOS (.pkg) • 1.2.3"); }); + it("shows the pluralized version count when a custom title has multiple packages", () => { + // The outer "Select software" dropdown swaps the version string for a + // count on multi-package titles — the per-package picker below the + // outer dropdown carries the actual version. + const title = createMockSoftwareTitle({ + source: "apps", + app_store_app: null, + software_package: createMockSoftwarePackage({ + name: "TestPackage-1.2.3.pkg", + version: "1.2.3", + }), + packages: [ + createMockSoftwarePackage({ + installer_id: 1, + name: "TestPackage-1.2.3.pkg", + version: "1.2.3", + }), + createMockSoftwarePackage({ + installer_id: 2, + name: "TestPackage-2.0.0.pkg", + version: "2.0.0", + }), + createMockSoftwarePackage({ + installer_id: 3, + name: "TestPackage-3.0.0.pkg", + version: "3.0.0", + }), + ], + }); + + expect(generateSoftwareOptionHelpText(title)).toBe( + "macOS (.pkg) • 3 versions" + ); + }); + + it("keeps the single-version treatment when a title has exactly one package", () => { + const title = createMockSoftwareTitle({ + source: "apps", + app_store_app: null, + software_package: createMockSoftwarePackage({ + name: "TestPackage-1.2.3.pkg", + version: "1.2.3", + }), + packages: [ + createMockSoftwarePackage({ + installer_id: 1, + name: "TestPackage-1.2.3.pkg", + version: "1.2.3", + }), + ], + }); + + expect(generateSoftwareOptionHelpText(title)).toBe("macOS (.pkg) • 1.2.3"); + }); + it("labels App Store (VPP) apps and uses the app_store_app version", () => { const title = createMockSoftwareTitle({ source: "apps", diff --git a/frontend/pages/policies/helpers.ts b/frontend/pages/policies/helpers.ts index dcffd60977..7681b3ff13 100644 --- a/frontend/pages/policies/helpers.ts +++ b/frontend/pages/policies/helpers.ts @@ -3,10 +3,13 @@ import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform"; import { TicketOrWebhookState } from "interfaces/policy"; import { INSTALLABLE_SOURCE_PLATFORM_CONVERSION, + ISoftwarePackage, ISoftwareTitle, } from "interfaces/software"; import { ITeamConfig } from "interfaces/team"; +import { addedFromNow } from "utilities/date_format"; import { getExtensionFromFileName } from "utilities/file/fileUtils"; +import { pluralize } from "utilities/strings/stringUtils"; export interface ITicketOrWebhookInfo { /** "webhook" or "ticket" when an "other workflow" automation is configured @@ -54,6 +57,12 @@ export const getTicketOrWebhookLabel = ( return "Send webhook or create ticket"; }; +/** Help-text shown under each option in the default "Select software" dropdown + * on the policy automations modal. Renders `platform (type) • ` for + * VPP / App Store and single-package custom titles, or `platform (type) • + * N versions` for multi-package custom titles. For the "Select package" + * dropdown that surfaces when a multi-package title is picked, see + * `generateSoftwarePackageOptionHelpText`. */ export const generateSoftwareOptionHelpText = ( title: ISoftwareTitle ): string => { @@ -75,8 +84,44 @@ export const generateSoftwareOptionHelpText = ( platform && extension ? `${PLATFORM_DISPLAY_NAMES[platform]} (.${extension})` : ""; - const version = title.software_package?.version ?? ""; - const separator = platformString && version ? " • " : ""; - return `${platformString}${separator}${version}`; + // Multi-package custom titles show a version count ("3 versions") in the + // outer dropdown; the per-package picker below the outer dropdown carries + // the actual version + upload date. Single-package titles keep the + // existing "version string" treatment since there's nothing to count. + const packageCount = title.packages?.length ?? 0; + const versionOrCount = + packageCount > 1 + ? `${packageCount} ${pluralize(packageCount, "version")}` + : title.software_package?.version ?? ""; + const separator = platformString && versionOrCount ? " • " : ""; + + return `${platformString}${separator}${versionOrCount}`; +}; + +/** Help-text shown under each option in the "Select package" dropdown + * that appears when a multi-package title is picked. Mirrors the Library + * row's "version • Added X ago" secondary line. For the default "Select + * software" dropdown that lists titles, see `generateSoftwareOptionHelpText`. */ +export const generateSoftwarePackageOptionHelpText = ( + pkg: ISoftwarePackage +): string => { + const separator = pkg.version && pkg.uploaded_at ? " • " : ""; + // `addedFromNow` already prepends "Added " — do not double-wrap. + const added = pkg.uploaded_at ? addedFromNow(pkg.uploaded_at) : ""; + return `${pkg.version ?? ""}${separator}${added}`; +}; + +/** Returns the "first-added" package on a multi-package title, defined as the + * smallest `installer_id`. The API returns `packages[]` in that order today, + * but we `Math.min` defensively so the auto-select doesn't drift if the + * response order ever changes. Returns `null` for titles with no packages + * (e.g. VPP / App Store titles). */ +export const findFirstAddedPackage = ( + packages: ISoftwarePackage[] | null | undefined +): ISoftwarePackage | null => { + if (!packages || packages.length === 0) return null; + return packages.reduce((first, pkg) => + pkg.installer_id < first.installer_id ? pkg : first + ); }; diff --git a/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts b/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts index 9c844da889..3258837094 100644 --- a/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts +++ b/frontend/pages/policies/hooks/useUpdatePolicyAutomations.ts @@ -13,6 +13,7 @@ import teamsAPI from "services/entities/teams"; export type IPolicyAutomationUpdate = Pick< IPolicyFormData, | "software_title_id" + | "software_installer_id" | "script_id" | "calendar_events_enabled" | "conditional_access_enabled" diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 06d7b8077a..a0191e0f82 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -120,6 +120,7 @@ export default { conditional_access_enabled, continuous_automations_enabled, software_title_id, + software_installer_id, script_id, labels_include_any, labels_include_all, @@ -140,6 +141,7 @@ export default { conditional_access_enabled, continuous_automations_enabled, software_title_id, + software_installer_id, script_id, labels_include_any, labels_include_all, diff --git a/frontend/test/handlers/software-handlers.ts b/frontend/test/handlers/software-handlers.ts index 9209b00fad..764e4ebb75 100644 --- a/frontend/test/handlers/software-handlers.ts +++ b/frontend/test/handlers/software-handlers.ts @@ -113,6 +113,21 @@ export const getSoftwareInstallHandlerOnlyPreInstallOutput = http.get( } ); +// Installed, with SHA-256 hash +export const getSoftwareInstallHandlerWithHash = http.get( + baseUrl("/software/install/:install_uuid/results"), + ({ params }) => { + return HttpResponse.json({ + results: createMockSoftwareInstallResult({ + install_uuid: params.install_uuid as string, + status: "installed", + hash_sha256: + "e6ddb2dd089ecea38ab73ed12812df269f1447e750cf4355703340bb8aa1ad", + }), + }); + } +); + // ---- MDM Command Handlers ---- /** This is used for testing command results of IPA custom packages */ diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index caf9cd7539..e9f471a722 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -2893,6 +2893,7 @@ func (ds *Datastore) getPoliciesBySoftwareTitleIDs( p.id AS id, p.name AS name, COALESCE(si.title_id, va.title_id) AS software_title_id, + p.software_installer_id AS software_installer_id, p.type AS type FROM policies p LEFT JOIN software_installers si ON p.software_installer_id = si.id diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 1f934e6654..cd95905be4 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -5021,6 +5021,9 @@ func testTeamPoliciesWithVPP(t *testing.T, ds *Datastore) { automaticPolicies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{team1App3.TitleID}, team1.ID) require.NoError(t, err) require.Len(t, automaticPolicies, 1) + // VPP-backed policies dispatch to `AppStoreApp.AutomaticInstallPolicies` + // at the title level, not via InstallerID — the field stays nil. + require.Nil(t, automaticPolicies[0].InstallerID) policyWithVPP, err := ds.Policy(ctx, automaticPolicies[0].ID) require.NoError(t, err) @@ -6212,6 +6215,11 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { require.Len(t, policies, 1) require.Equal(t, policy1.ID, policies[0].ID) require.Equal(t, policy1.Name, policies[0].Name) + // InstallerID is the join key used by the software-titles list to + // dispatch policies to the specific package on a multi-package title; + // verify it's populated so per-package attribution works. + require.NotNil(t, policies[0].InstallerID) + require.Equal(t, installer1ID, *policies[0].InstallerID) // software title 1 should not have any policies when filtering by team 2 policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer1.TitleID}, team2.ID) @@ -6224,6 +6232,8 @@ func testPoliciesBySoftwareTitleID(t *testing.T, ds *Datastore) { require.Len(t, policies, 1) require.Equal(t, policy2.ID, policies[0].ID) require.Equal(t, policy2.Name, policies[0].Name) + require.NotNil(t, policies[0].InstallerID) + require.Equal(t, installer2ID, *policies[0].InstallerID) // software title 2 should not have any policies when filtering by team 1 policies, err = ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{*installer2.TitleID}, team1.ID) @@ -6290,8 +6300,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, Type: fleet.PolicyTypeDynamic}, - policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID, Type: fleet.PolicyTypeDynamic}, + policy3.ID: {ID: policy3.ID, Name: policy3.Name, TitleID: *installer3.TitleID, InstallerID: new(installer3ID), Type: fleet.PolicyTypeDynamic}, + policy4.ID: {ID: policy4.ID, Name: policy4.Name, TitleID: *installer4.TitleID, InstallerID: new(installer4ID), Type: fleet.PolicyTypeDynamic}, } for _, got := range policies { diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 8758049cd5..e64a25e21f 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -489,10 +489,17 @@ func (ds *Datastore) processSoftwareTitleResults( if err != nil { return nil, 0, nil, ctxerr.Wrap(ctx, err, "get packages for software titles") } - // Automatic install policies are title-level for now, so attach the same set to every package. - policiesByTitle := make(map[uint][]fleet.AutomaticInstallPolicy, len(policies)) + // Key policies by installer_id so each package on a multi-package + // title only shows the policies actually bound to it — not the + // aggregated title-level list. Custom-package-backed policies + // always carry a non-nil InstallerID; VPP-backed policies do not + // (they're already attached above via softwareList[i].AppStoreApp). + policiesByInstaller := make(map[uint][]fleet.AutomaticInstallPolicy) for _, p := range policies { - policiesByTitle[p.TitleID] = append(policiesByTitle[p.TitleID], p) + if p.InstallerID == nil { + continue + } + policiesByInstaller[*p.InstallerID] = append(policiesByInstaller[*p.InstallerID], p) } for titleID, pkgs := range packagesByTitle { i, ok := titleIndex[titleID] @@ -500,7 +507,7 @@ func (ds *Datastore) processSoftwareTitleResults( continue } for j := range pkgs { - pkgs[j].AutomaticInstallPolicies = policiesByTitle[titleID] + pkgs[j].AutomaticInstallPolicies = policiesByInstaller[pkgs[j].InstallerID] } softwareList[i].Packages = pkgs } @@ -588,11 +595,13 @@ func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *u const stmt = ` SELECT si.title_id, + si.id AS installer_id, si.filename AS name, si.version, si.platform, si.self_service, - si.url AS package_url + si.url AS package_url, + si.uploaded_at FROM software_installers si WHERE @@ -600,12 +609,14 @@ WHERE ORDER BY si.id ASC` type packageRow struct { - TitleID uint `db:"title_id"` - Name string `db:"name"` - Version string `db:"version"` - Platform string `db:"platform"` - SelfService bool `db:"self_service"` - PackageURL *string `db:"package_url"` + TitleID uint `db:"title_id"` + InstallerID uint `db:"installer_id"` + Name string `db:"name"` + Version string `db:"version"` + Platform string `db:"platform"` + SelfService bool `db:"self_service"` + PackageURL *string `db:"package_url"` + UploadedAt time.Time `db:"uploaded_at"` } ret := make(map[uint][]fleet.SoftwarePackageListItem) @@ -622,11 +633,13 @@ ORDER BY si.id ASC` for _, r := range rows { selfService := r.SelfService ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageListItem{ + InstallerID: r.InstallerID, Name: r.Name, Version: r.Version, Platform: r.Platform, SelfService: &selfService, PackageURL: r.PackageURL, + UploadedAt: r.UploadedAt, }) } return nil diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 68cc11dcdd..117d2c9c3d 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -46,6 +46,7 @@ func TestSoftwareTitles(t *testing.T) { {"UpdateAutoUpdateConfig", testUpdateAutoUpdateConfig}, {"ListSoftwareTitlesSortByDisplayName", testListSoftwareTitlesSortByDisplayName}, {"ListSoftwareTitlesMultiplePackages", testListSoftwareTitlesMultiplePackages}, + {"ListSoftwareTitlesPolicyDispatchPerInstaller", testListSoftwareTitlesPolicyDispatchPerInstaller}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -941,6 +942,63 @@ func testListSoftwareTitlesMultiplePackages(t *testing.T, ds *Datastore) { require.Equal(t, 2, title.SoftwareInstallersCount) } +// Regression guard for the per-installer policy dispatch loop in +// ListSoftwareTitles: a policy pinned to one specific package on a +// multi-package title must only surface on that package's +// AutomaticInstallPolicies, not on every package (title-level aggregate). +func testListSoftwareTitlesPolicyDispatchPerInstaller(t *testing.T, ds *Datastore) { + ctx := context.Background() + user := test.NewUser(t, ds, "Dispatch", "dispatch@example.com", true) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "policy-dispatch-team"}) + require.NoError(t, err) + + mk := func(storage, filename string) *fleet.UploadSoftwareInstallerPayload { + return &fleet.UploadSoftwareInstallerPayload{ + Title: "Dispatch App", + Source: "apps", + BundleIdentifier: "com.example.dispatch", + Platform: "darwin", + Extension: "pkg", + Version: "1.0", + InstallScript: "echo", + Filename: filename, + StorageID: storage, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + } + } + + installer1ID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("dispatch-a", "a.pkg")) + require.NoError(t, err) + installer2ID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("dispatch-b", "b.pkg")) + require.NoError(t, err) + + // Pin a policy to installer 1 only. Installer 2 must NOT see it. + pol, err := ds.NewTeamPolicy(ctx, team.ID, &user.ID, fleet.PolicyPayload{ + Name: "dispatch-policy", + Query: "SELECT 1;", + }) + require.NoError(t, err) + pol.SoftwareInstallerID = new(installer1ID) + require.NoError(t, ds.SavePolicy(ctx, pol, false, false)) + + adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}} + titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, adminFilter) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Len(t, titles[0].Packages, 2) + + // packages[] is ordered by installer_id ASC — package[0] is installer 1 + // (pinned), package[1] is installer 2 (not pinned). + require.Equal(t, installer1ID, titles[0].Packages[0].InstallerID) + require.Len(t, titles[0].Packages[0].AutomaticInstallPolicies, 1, "installer 1 should carry the pinned policy") + assert.Equal(t, pol.ID, titles[0].Packages[0].AutomaticInstallPolicies[0].ID) + + require.Equal(t, installer2ID, titles[0].Packages[1].InstallerID) + assert.Empty(t, titles[0].Packages[1].AutomaticInstallPolicies, "installer 2 should NOT carry any policies (regression: aggregate title-level list)") +} + func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 7203be1029..783ee02a54 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -591,6 +591,13 @@ type PolicySpec struct { type PolicySoftwareTitle struct { // SoftwareTitleID is the ID of the title associated to the policy. SoftwareTitleID uint `json:"software_title_id" db:"title_id"` + // SoftwareInstallerID is the ID of the specific package the policy pins + // on a multi-package title. Nil for VPP-backed policies (which pin via + // vpp_apps_teams_id, not an installer). The multi-package policy + // automation UI reads this on load to reflect the user's non-default + // package choice; when nil, the UI falls back to the title's first-added + // package. + SoftwareInstallerID *uint `json:"software_installer_id,omitempty"` // Name is the associated installer title name // (not the package name, but the installed software title). Name string `json:"name" db:"name"` diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 29e73f7dd5..0e53728d44 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -813,10 +813,17 @@ func (h *HostSoftwareWithInstaller) ForMyDevicePage(token string) { } 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"` + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` + // TitleID and InstallerID are join keys used to dispatch a policy to + // the right software title / specific package on the list response. + // Neither is exposed on the wire. + TitleID uint `json:"-" db:"software_title_id"` + // InstallerID is nil for VPP-app-backed policies (they carry + // vpp_apps_teams_id instead). For custom-package-backed policies it + // points at the specific package the policy triggers install on. + InstallerID *uint `json:"-" db:"software_installer_id"` + Type string `json:"type" db:"type"` } type PatchPolicyData struct { @@ -852,12 +859,15 @@ type SoftwarePackageOrApp struct { // SoftwarePackageListItem is the trimmed list-response package shape; it omits the // host-only last_install/last_uninstall fields that SoftwarePackageOrApp carries. type SoftwarePackageListItem struct { + // InstallerID is the per-package id used to pin a policy to a specific package. + InstallerID uint `json:"installer_id"` Name string `json:"name"` AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"` Version string `json:"version"` Platform string `json:"platform"` SelfService *bool `json:"self_service,omitempty"` PackageURL *string `json:"package_url"` + UploadedAt time.Time `json:"uploaded_at"` } func (s *SoftwarePackageOrApp) GetPlatform() string { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index c3f47a5153..80b2db95a7 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -197,7 +197,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get software packages") } if len(pkgs) > 0 { - // Display name, icon, and policies are title-level; fetch once from the first-added package. + // Display name and icon are title-level; fetch once from the first-added package. titleMeta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) if err != nil && !fleet.IsNotFound(err) { return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") @@ -213,6 +213,19 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get categories for software packages") } + // Key policies by installer_id so each package on a multi-package + // title only surfaces the ones actually bound to it. VPP-backed + // policies have nil InstallerID and dispatch via AppStoreApp. + policiesByInstaller := make(map[uint][]fleet.AutomaticInstallPolicy) + if titleMeta != nil { + for _, p := range titleMeta.AutomaticInstallPolicies { + if p.InstallerID == nil { + continue + } + policiesByInstaller[*p.InstallerID] = append(policiesByInstaller[*p.InstallerID], p) + } + } + for _, pkg := range pkgs { summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, pkg.InstallerID) if err != nil { @@ -224,9 +237,8 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint if titleMeta != nil { pkg.DisplayName = titleMeta.DisplayName pkg.IconUrl = titleMeta.IconUrl - // Automatic install policies are title-level for now. - pkg.AutomaticInstallPolicies = titleMeta.AutomaticInstallPolicies } + pkg.AutomaticInstallPolicies = policiesByInstaller[pkg.InstallerID] // Populate FleetMaintainedVersions/pin/patch policy for FMA titles. // An FMA title has a single active package, so this runs on it. diff --git a/server/service/team_policies.go b/server/service/team_policies.go index e9f59e4c5c..0ab8b650e8 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -173,9 +173,10 @@ func (svc *Service) populatePolicyInstallSoftware(ctx context.Context, p *fleet. return ctxerr.Wrap(ctx, err, "get software installer metadata by id") } p.InstallSoftware = &fleet.PolicySoftwareTitle{ - SoftwareTitleID: *installerMetadata.TitleID, - Name: installerMetadata.SoftwareTitle, - DisplayName: installerMetadata.DisplayName, + SoftwareTitleID: *installerMetadata.TitleID, + SoftwareInstallerID: new(installerMetadata.InstallerID), + Name: installerMetadata.SoftwareTitle, + DisplayName: installerMetadata.DisplayName, } return nil } else if p.VPPAppsTeamsID != nil { @@ -207,6 +208,8 @@ func (svc *Service) populatePolicyPatchSoftware(ctx context.Context, p *fleet.Po if err != nil { return ctxerr.Wrap(ctx, err, "get software installer metadata by title id") } + // SoftwareInstallerID intentionally omitted — patch policies target FMA + // titles (single installer per title) so per-package pinning doesn't apply. p.PatchSoftware = &fleet.PolicySoftwareTitle{ SoftwareTitleID: *installerMetadata.TitleID, Name: installerMetadata.SoftwareTitle, diff --git a/server/service/team_policies_test.go b/server/service/team_policies_test.go index de02fef22e..21a814650b 100644 --- a/server/service/team_policies_test.go +++ b/server/service/team_policies_test.go @@ -272,6 +272,7 @@ func TestTeamPolicyAutomationsPopulated(t *testing.T) { ds.GetSoftwareInstallerMetadataByIDFunc = func(ctx context.Context, id uint) (*fleet.SoftwareInstaller, error) { require.Equal(t, softwareInstallerID, id) return &fleet.SoftwareInstaller{ + InstallerID: softwareInstallerID, TitleID: ptr.Uint(softwareInstallerTitle), SoftwareTitle: installerSoftwareTitle, DisplayName: installerDisplayName, @@ -309,6 +310,10 @@ func TestTeamPolicyAutomationsPopulated(t *testing.T) { assert.Equal(t, softwareInstallerTitle, p.InstallSoftware.SoftwareTitleID) assert.Equal(t, installerSoftwareTitle, p.InstallSoftware.Name) assert.Equal(t, installerDisplayName, p.InstallSoftware.DisplayName) + // SoftwareInstallerID lets the FE pre-fill the "Select package" pin + // on reload instead of always re-deriving first-added. + require.NotNil(t, p.InstallSoftware.SoftwareInstallerID, "install_software.software_installer_id should be populated") + assert.Equal(t, softwareInstallerID, *p.InstallSoftware.SoftwareInstallerID) require.NotNil(t, p.RunScript, "run_script should be populated") assert.Equal(t, scriptID, p.RunScript.ID) @@ -318,6 +323,9 @@ func TestTeamPolicyAutomationsPopulated(t *testing.T) { assert.Equal(t, patchInstallerTitleID, p.PatchSoftware.SoftwareTitleID) assert.Equal(t, patchSoftwareTitleName, p.PatchSoftware.Name) assert.Equal(t, patchSoftwareDisplay, p.PatchSoftware.DisplayName) + // Patch policies target FMA titles (single installer per title), so + // per-package pinning doesn't apply and the field stays nil. + assert.Nil(t, p.PatchSoftware.SoftwareInstallerID, "patch_software.software_installer_id should stay nil") } // requireSoftwareIconURLs verifies that install_software.icon_url is set to the