) =>
- 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
|