diff --git a/changes/feat-ui-creat-policies-fleet-apps-title-details b/changes/feat-ui-creat-policies-fleet-apps-title-details new file mode 100644 index 000000000000..e69ff76e1852 --- /dev/null +++ b/changes/feat-ui-creat-policies-fleet-apps-title-details @@ -0,0 +1 @@ +- Adds functionality for creating an automatic install policy for Fleet-maintained apps \ No newline at end of file diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 76798a16be6b..d18949192172 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -26,19 +27,19 @@ func (svc *Service) AddFleetMaintainedApp( appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, -) error { +) (titleID uint, err error) { if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { - return err + return 0, err } vc, ok := viewer.FromContext(ctx) if !ok { - return fleet.ErrNoContext + return 0, fleet.ErrNoContext } app, err := svc.ds.GetMaintainedAppByID(ctx, appID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting maintained app by id") + return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id") } // Download installer from the URL @@ -50,13 +51,13 @@ func (svc *Service) AddFleetMaintainedApp( client := fleethttp.NewClient(fleethttp.WithTimeout(timeout)) installerTFR, filename, err := maintainedapps.DownloadInstaller(ctx, app.InstallerURL, client) if err != nil { - return ctxerr.Wrap(ctx, err, "downloading app installer") + return 0, ctxerr.Wrap(ctx, err, "downloading app installer") } defer installerTFR.Close() extension, err := maintainedapps.ExtensionForBundleIdentifier(app.BundleIdentifier) if err != nil { - return ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier) + return 0, ctxerr.Errorf(ctx, "getting extension from bundle identifier %q", app.BundleIdentifier) } // Validate the bytes we got are what we expected, if homebrew supports @@ -68,11 +69,11 @@ func (svc *Service) AddFleetMaintainedApp( gotHash := hex.EncodeToString(h.Sum(nil)) if gotHash != app.SHA256 { - return ctxerr.New(ctx, "mismatch in maintained app SHA256 hash") + return 0, ctxerr.New(ctx, "mismatch in maintained app SHA256 hash") } if err := installerTFR.Rewind(); err != nil { - return ctxerr.Wrap(ctx, err, "rewind installer reader") + return 0, ctxerr.Wrap(ctx, err, "rewind installer reader") } } @@ -120,12 +121,12 @@ func (svc *Service) AddFleetMaintainedApp( // Create record in software installers table _, err = svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload) if err != nil { - return ctxerr.Wrap(ctx, err, "setting downloaded installer") + return 0, ctxerr.Wrap(ctx, err, "setting downloaded installer") } // Save in S3 if err := svc.storeSoftware(ctx, payload); err != nil { - return ctxerr.Wrap(ctx, err, "upload maintained app installer to S3") + return 0, ctxerr.Wrap(ctx, err, "upload maintained app installer to S3") } // Create activity @@ -133,7 +134,7 @@ func (svc *Service) AddFleetMaintainedApp( if payload.TeamID != nil && *payload.TeamID != 0 { t, err := svc.ds.Team(ctx, *payload.TeamID) if err != nil { - return ctxerr.Wrap(ctx, err, "getting team") + return 0, ctxerr.Wrap(ctx, err, "getting team") } teamName = &t.Name } @@ -145,10 +146,17 @@ func (svc *Service) AddFleetMaintainedApp( TeamID: payload.TeamID, SelfService: payload.SelfService, }); err != nil { - return ctxerr.Wrap(ctx, err, "creating activity for added software") + return 0, ctxerr.Wrap(ctx, err, "creating activity for added software") } - return nil + // Use the writer for this query; we need the software installer that might have just been + // created above + titleId, err := svc.ds.GetSoftwareTitleIDByMaintainedAppID(ctxdb.RequirePrimary(ctx, true), app.ID, payload.TeamID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "getting software title id by app id") + } + + return titleId, nil } func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index 66a193046fcd..d01745f06880 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -212,6 +212,10 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { pending_uninstall: 1, failed_uninstall: 1, }, + automatic_install_policies: [], + last_install: null, + last_uninstall: null, + package_url: "", }; export const createMockSoftwarePackage = ( diff --git a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx index 2b1ce645fc15..ca9bcf5065cb 100644 --- a/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx +++ b/frontend/components/TableContainer/DataTable/SoftwareNameCell/SoftwareNameCell.tsx @@ -1,23 +1,71 @@ import React from "react"; import { InjectedRouter } from "react-router"; import ReactTooltip from "react-tooltip"; - import { uniqueId } from "lodash"; -import { ISoftwarePackage } from "interfaces/software"; - import Icon from "components/Icon"; +import { IconNames } from "components/icons"; import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon"; import LinkCell from "../LinkCell"; const baseClass = "software-name-cell"; +type InstallType = + | "manual" + | "selfService" + | "automatic" + | "automaticSelfService"; + +interface installIconConfig { + iconName: IconNames; + tooltip: JSX.Element; +} + +const installIconMap: Record = { + manual: { + iconName: "install", + tooltip: <>Software can be installed on Host details page., + }, + selfService: { + iconName: "user", + tooltip: ( + <> + End users can install from Fleet Desktop {">"} Self-service. + + ), + }, + automatic: { + iconName: "refresh", + tooltip: <>Software will be automatically installed on each host., + }, + automaticSelfService: { + iconName: "automatic-self-service", + tooltip: ( + <> + Software will be automatically installed on each host. End users can + reinstall from Fleet Desktop {">"} Self-service. + + ), + }, +}; + +interface IInstallIconWithTooltipProps { + isSelfService: boolean; + installType?: "manual" | "automatic"; +} + const InstallIconWithTooltip = ({ isSelfService, -}: { - isSelfService: ISoftwarePackage["self_service"]; -}) => { + installType, +}: IInstallIconWithTooltipProps) => { + let iconType: InstallType = "manual"; + if (installType === "automatic") { + iconType = isSelfService ? "automaticSelfService" : "automatic"; + } else if (isSelfService) { + iconType = "selfService"; + } + const tooltipId = uniqueId(); return (
@@ -27,8 +75,9 @@ const InstallIconWithTooltip = ({ data-for={tooltipId} >
- {isSelfService ? ( - <> - End users can install from Fleet Desktop {">"} Self-service - . - - ) : ( - <> - Install manually on Host details page or automatically with - policy automations. - - )} + {installIconMap[iconType].tooltip} @@ -65,6 +104,7 @@ interface ISoftwareNameCellProps { router?: InjectedRouter; hasPackage?: boolean; isSelfService?: boolean; + installType?: "manual" | "automatic"; iconUrl?: string; } @@ -75,6 +115,7 @@ const SoftwareNameCell = ({ router, hasPackage = false, isSelfService = false, + installType, iconUrl, }: ISoftwareNameCellProps) => { // NO path or router means it's not clickable. return @@ -104,7 +145,10 @@ const SoftwareNameCell = ({ {name} {hasPackage && ( - + )} } diff --git a/frontend/components/Tag/Tag.tsx b/frontend/components/Tag/Tag.tsx new file mode 100644 index 000000000000..4688b1b31cc4 --- /dev/null +++ b/frontend/components/Tag/Tag.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import classnames from "classnames"; + +import Icon from "components/Icon"; +import { IconNames } from "components/icons"; + +const baseClass = "tag"; + +interface ITagProps { + icon: IconNames; + text: string; + className?: string; + onClick?: () => void; +} + +const Tag = ({ icon, text, className, onClick }: ITagProps) => { + const classNames = classnames( + baseClass, + className, + onClick && `${baseClass}__clickable-tag` + ); + + const content = ( + <> + + {text} + + ); + + return onClick ? ( + // use a button element so that the tag can be focused and clicked + // with the keyboard + + ) : ( +
{content}
+ ); +}; + +export default Tag; diff --git a/frontend/components/Tag/_styles.scss b/frontend/components/Tag/_styles.scss new file mode 100644 index 000000000000..6832637eda76 --- /dev/null +++ b/frontend/components/Tag/_styles.scss @@ -0,0 +1,27 @@ +.tag { + display: flex; + height: 18px; + padding: 3px 6px; + align-items: center; + gap: $pad-xsmall; + border-radius: $border-radius; + border: 1px solid $ui-fleet-black-10; + color: $ui-fleet-black-75; + font-size: $xx-small; + font-weight: $bold; + white-space: nowrap; + + // styles to override the default + + + + ); +}; + +export default AutomaticInstallModal; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss new file mode 100644 index 000000000000..40aea908a2b5 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/_styles.scss @@ -0,0 +1,23 @@ +.automatic-install-modal { + + &__description { + margin: 0 0 $pad-large + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid $ui-fleet-black-10; + border-radius: $border-radius-medium; + } + + &__list-item { + border-bottom: 1px solid $ui-fleet-black-10; + padding: $pad-small $pad-large; + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts new file mode 100644 index 000000000000..adb3cad5bfb2 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AutomaticInstallModal/index.ts @@ -0,0 +1 @@ +export { default } from "./AutomaticInstallModal"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx index 633a2219931f..0327da423971 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx @@ -67,7 +67,7 @@ const DeleteSoftwareModal = ({

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

You cannot undo this action.

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