From 2f20a0f2b9fb91fbdf57900acf1c13313af90e80 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 11 Feb 2026 11:35:00 -0500 Subject: [PATCH] RAA: Support removing an android app from managed google play (self-service) on deletion (#39343) --- ...766-uninstall-android-apps-on-fleet-remove | 1 + .../fleetctl/testing_utils/testing_utils.go | 7 + ee/server/service/software_installers.go | 26 +++ ee/server/service/vpp.go | 43 ++-- server/mdm/android/mock/client.go | 12 + server/mdm/android/service.go | 1 + .../mdm/android/service/androidmgmt/client.go | 2 + .../service/androidmgmt/google_client.go | 15 ++ .../service/androidmgmt/proxy_client.go | 18 ++ server/mdm/android/service/requests.go | 16 ++ server/mdm/android/service/service.go | 24 ++ .../integration_android_software_test.go | 220 ++++++++++++++++++ server/worker/software_worker.go | 42 +++- 13 files changed, 413 insertions(+), 14 deletions(-) create mode 100644 changes/38766-uninstall-android-apps-on-fleet-remove diff --git a/changes/38766-uninstall-android-apps-on-fleet-remove b/changes/38766-uninstall-android-apps-on-fleet-remove new file mode 100644 index 0000000000..e83523e09e --- /dev/null +++ b/changes/38766-uninstall-android-apps-on-fleet-remove @@ -0,0 +1 @@ +- Implemented uninstall of Android apps on the device (and removal from self-service in the managed Google Play store) when an app is removed from Fleet. diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index 25de5be412..8f53bbe5e8 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -22,6 +22,7 @@ import ( "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/dev_mode" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/android" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" @@ -151,6 +152,12 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) { return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil } + ds.GetVPPAppsFunc = func(ctx context.Context, teamID *uint) ([]fleet.VPPAppResponse, error) { + return []fleet.VPPAppResponse{}, nil + } + ds.GetEnterpriseFunc = func(ctx context.Context) (*android.Enterprise, error) { + return nil, nil + } var cachedDS fleet.Datastore if len(opts) > 0 && opts[0].NoCacheDatastore { cachedDS = ds diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 4da6e3c48b..4a243739b8 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -30,6 +30,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/worker" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/google/uuid" @@ -802,10 +803,35 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. return fleet.ErrNoContext } + var androidHostsUUIDToPolicyID map[string]string + if meta.Platform == fleet.AndroidPlatform { + // if this is an Android app we're deleting, collect the host uuids that should have it removed + // (as we uninstall Android apps on delete). We can't do this in the worker as it will be too late, + // the vpp_apps_teams entry will have been deleted. + hosts, err := svc.ds.GetIncludedHostUUIDMapForAppStoreApp(ctx, meta.VPPAppsTeamsID) + if err != nil { + return ctxerr.Wrap(ctx, err, "delete app store app: getting android hosts in scope") + } + androidHostsUUIDToPolicyID = hosts + } + if err := svc.ds.DeleteVPPAppFromTeam(ctx, teamID, meta.VPPAppID); err != nil { return ctxerr.Wrap(ctx, err, "deleting VPP app") } + // if this is an android app, remove the self-service app from the managed Google Play store + // and uninstall it from the hosts. + if meta.Platform == fleet.AndroidPlatform && len(androidHostsUUIDToPolicyID) > 0 { + enterprise, err := svc.ds.GetEnterprise(ctx) + if err != nil { + return &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err} + } + err = worker.QueueMakeAndroidAppUnavailableJob(ctx, svc.ds, svc.logger, meta.VPPAppID.AdamID, androidHostsUUIDToPolicyID, enterprise.Name()) + if err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing job to make android app unavailable") + } + } + var teamName *string if teamID != nil && *teamID != 0 { t, err := svc.ds.TeamLite(ctx, *teamID) diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index ecb8e65fb5..0b082e58fa 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -18,7 +18,6 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/mdm/android" "github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/ptr" @@ -264,11 +263,31 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, appStoreApps = append(appStoreApps, apps...) } - var enterprise *android.Enterprise - if len(incomingAndroidApps) > 0 { - var err error - enterprise, err = svc.ds.GetEnterprise(ctx) + enterprise, err := svc.ds.GetEnterprise(ctx) + if err != nil && !fleet.IsNotFound(err) { + return nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err} + } + + androidHostPoliciesToUpdate := map[string]string{} + if len(incomingAndroidApps) == 0 { + // get the currently available VPP apps, and the hosts that have them in scope, + // to update their set of apps to the empty set (and remove/uninstall the apps). + removedApps, err := svc.ds.GetVPPApps(ctx, teamID) if err != nil { + return nil, err + } + + for _, app := range removedApps { + if app.Platform == fleet.AndroidPlatform { + hostsInScope, err := svc.ds.GetIncludedHostUUIDMapForAppStoreApp(ctx, app.AppTeamID) + if err != nil { + return nil, err + } + maps.Copy(androidHostPoliciesToUpdate, hostsInScope) + } + } + } else { + if enterprise == nil { return nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err} } @@ -387,16 +406,11 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, return nil, err // returned error already includes context that we could include here } - if len(allPlatformApps) == 0 { - return []fleet.VPPAppResponse{}, nil - } - addedApps, err := svc.ds.GetVPPApps(ctx, teamID) if err != nil { return nil, err } - policiesToUpdate := map[string]string{} var appIDs []string for _, app := range addedApps { if app.Platform == fleet.AndroidPlatform { @@ -405,13 +419,13 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, return nil, err } - maps.Copy(policiesToUpdate, hostsInScope) + maps.Copy(androidHostPoliciesToUpdate, hostsInScope) appIDs = append(appIDs, app.AppStoreID) } } - if len(policiesToUpdate) > 0 && enterprise != nil { - for hostUUID, policyID := range policiesToUpdate { + if len(androidHostPoliciesToUpdate) > 0 && enterprise != nil { + for hostUUID, policyID := range androidHostPoliciesToUpdate { err := worker.QueueBulkSetAndroidAppsAvailableForHost(ctx, svc.ds, svc.logger, hostUUID, policyID, appIDs, enterprise.Name()) if err != nil { return nil, ctxerr.WrapWithData( @@ -435,6 +449,9 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, } } + if len(addedApps) == 0 { + return []fleet.VPPAppResponse{}, nil + } return addedApps, nil } diff --git a/server/mdm/android/mock/client.go b/server/mdm/android/mock/client.go index 671a508b7e..0ac90d51cf 100644 --- a/server/mdm/android/mock/client.go +++ b/server/mdm/android/mock/client.go @@ -39,6 +39,8 @@ type EnterprisesApplicationsFunc func(ctx context.Context, enterpriseName string type EnterprisesPoliciesModifyPolicyApplicationsFunc func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) +type EnterprisesPoliciesRemovePolicyApplicationsFunc func(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) + type Client struct { SignupURLsCreateFunc SignupURLsCreateFunc SignupURLsCreateFuncInvoked bool @@ -79,6 +81,9 @@ type Client struct { EnterprisesPoliciesModifyPolicyApplicationsFunc EnterprisesPoliciesModifyPolicyApplicationsFunc EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked bool + EnterprisesPoliciesRemovePolicyApplicationsFunc EnterprisesPoliciesRemovePolicyApplicationsFunc + EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked bool + mu sync.Mutex } @@ -172,3 +177,10 @@ func (p *Client) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context p.mu.Unlock() return p.EnterprisesPoliciesModifyPolicyApplicationsFunc(ctx, policyName, appPolicies) } + +func (p *Client) EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) { + p.mu.Lock() + p.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked = true + p.mu.Unlock() + return p.EnterprisesPoliciesRemovePolicyApplicationsFunc(ctx, policyName, packageNames) +} diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go index f1c8a2ab38..ec13335273 100644 --- a/server/mdm/android/service.go +++ b/server/mdm/android/service.go @@ -23,6 +23,7 @@ type Service interface { EnterprisesApplications(ctx context.Context, enterpriseName, applicationID string) (*androidmanagement.Application, error) AddAppsToAndroidPolicy(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, hostUUIDs map[string]string) (map[string]*MDMAndroidPolicyRequest, error) + RemoveAppsFromAndroidPolicy(ctx context.Context, enterpriseName string, packageNames []string, hostUUIDs map[string]string) (map[string]*MDMAndroidPolicyRequest, error) // SetAppsForAndroidPolicy sets the available apps for the given hosts' Android MDM policy to the given list of apps. // Note that unlike AddAppsToAndroidPolicy, this method replaces the existing app list with the given one, it is // not additive/PATCH semantics. diff --git a/server/mdm/android/service/androidmgmt/client.go b/server/mdm/android/service/androidmgmt/client.go index 68f5fdde35..593a0de794 100644 --- a/server/mdm/android/service/androidmgmt/client.go +++ b/server/mdm/android/service/androidmgmt/client.go @@ -64,6 +64,8 @@ type Client interface { EnterprisesApplications(ctx context.Context, enterpriseName, packageName string) (*androidmanagement.Application, error) EnterprisesPoliciesModifyPolicyApplications(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) + + EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) } type EnterprisesCreateRequest struct { diff --git a/server/mdm/android/service/androidmgmt/google_client.go b/server/mdm/android/service/androidmgmt/google_client.go index f9c3cd99d1..12dbc2e430 100644 --- a/server/mdm/android/service/androidmgmt/google_client.go +++ b/server/mdm/android/service/androidmgmt/google_client.go @@ -377,3 +377,18 @@ func (g *GoogleClient) EnterprisesPoliciesModifyPolicyApplications(ctx context.C } return ret.Policy, nil } + +func (g *GoogleClient) EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) { + req := androidmanagement.RemovePolicyApplicationsRequest{ + PackageNames: packageNames, + } + ret, err := g.mgmt.Enterprises.Policies.RemovePolicyApplications(policyName, &req).Context(ctx).Do() + switch { + case googleapi.IsNotModified(err): + g.logger.Log("msg", "Android application policy not modified", "policy_name", policyName) + return nil, err + case err != nil: + return nil, ctxerr.Wrapf(ctx, err, "removing packages from application policy %s", policyName) + } + return ret.Policy, nil +} diff --git a/server/mdm/android/service/androidmgmt/proxy_client.go b/server/mdm/android/service/androidmgmt/proxy_client.go index b6c4816d8c..a8f6144cd9 100644 --- a/server/mdm/android/service/androidmgmt/proxy_client.go +++ b/server/mdm/android/service/androidmgmt/proxy_client.go @@ -341,3 +341,21 @@ func (p *ProxyClient) EnterprisesPoliciesModifyPolicyApplications(ctx context.Co } return ret.Policy, nil } + +func (p *ProxyClient) EnterprisesPoliciesRemovePolicyApplications(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) { + req := androidmanagement.RemovePolicyApplicationsRequest{ + PackageNames: packageNames, + } + + call := p.mgmt.Enterprises.Policies.RemovePolicyApplications(policyName, &req).Context(ctx) + call.Header().Set("Authorization", "Bearer "+p.fleetServerSecret) + ret, err := call.Do() + switch { + case googleapi.IsNotModified(err): + p.logger.Log("msg", "Android application policy not modified", "policy_name", policyName) + return nil, err + case err != nil: + return nil, ctxerr.Wrapf(ctx, err, "removing packages from application policy %s", policyName) + } + return ret.Policy, nil +} diff --git a/server/mdm/android/service/requests.go b/server/mdm/android/service/requests.go index 0604fbc212..aa23cb8a25 100644 --- a/server/mdm/android/service/requests.go +++ b/server/mdm/android/service/requests.go @@ -49,6 +49,22 @@ func newAndroidPolicyApplicationsRequest(policyID, policyName string, apps []*an }, nil } +func newAndroidPolicyRemoveApplicationsRequest(policyID, policyName string, packageNames []string) (*android.MDMAndroidPolicyRequest, error) { + req := androidmanagement.RemovePolicyApplicationsRequest{ + PackageNames: packageNames, + } + + b, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal remove policy applications to json: %w", err) + } + return &android.MDMAndroidPolicyRequest{ + RequestName: policyName, + PolicyID: policyID, + Payload: b, + }, nil +} + func newAndroidPolicyRequest(policyID, policyName string, policy *androidmanagement.Policy, metadata map[string]string) (*android.MDMAndroidPolicyRequest, error) { // save the payload with metadata about what setting comes from what profile m := fleet.AndroidPolicyRequestPayload{ diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go index 0ffe504708..b222165238 100644 --- a/server/mdm/android/service/service.go +++ b/server/mdm/android/service/service.go @@ -903,6 +903,30 @@ func (svc *Service) AddAppsToAndroidPolicy(ctx context.Context, enterpriseName s return hostToPolicyRequest, errors.Join(errs...) } +func (svc *Service) RemoveAppsFromAndroidPolicy(ctx context.Context, enterpriseName string, packageNames []string, hostUUIDs map[string]string) (map[string]*android.MDMAndroidPolicyRequest, error) { + var errs []error + hostToPolicyRequest := make(map[string]*android.MDMAndroidPolicyRequest, len(hostUUIDs)) + for uuid, policyID := range hostUUIDs { + policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, policyID) + policyRequest, err := newAndroidPolicyRemoveApplicationsRequest(policyID, policyName, packageNames) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "prepare policy request %s", policyName) + } + + policy, apiErr := svc.androidAPIClient.EnterprisesPoliciesRemovePolicyApplications(ctx, policyName, packageNames) + if _, err := recordAndroidRequestResult(ctx, svc.fleetDS, policyRequest, policy, nil, apiErr); err != nil { + return nil, ctxerr.Wrapf(ctx, err, "save android policy request for host %s", uuid) + } + + if apiErr != nil { + errs = append(errs, ctxerr.Wrapf(ctx, apiErr, "google api: remove policy applications for host %s", uuid)) + } + hostToPolicyRequest[uuid] = policyRequest + } + + return hostToPolicyRequest, errors.Join(errs...) +} + // getFleetAgentPackageInfo returns the Fleet agent package name and SHA256 fingerprint. // Returns empty strings if the package is not configured. func (svc *Service) getFleetAgentPackageInfo() (packageName, sha256Fingerprint string) { diff --git a/server/service/integration_android_software_test.go b/server/service/integration_android_software_test.go index b58561a424..a926626569 100644 --- a/server/service/integration_android_software_test.go +++ b/server/service/integration_android_software_test.go @@ -603,6 +603,10 @@ func (s *integrationMDMTestSuite) enableAndroidMDM(t *testing.T) string { return &androidmanagement.Policy{}, nil } + s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFunc = func(ctx context.Context, policyName string, packageNames []string) (*androidmanagement.Policy, error) { + return &androidmanagement.Policy{}, nil + } + s.androidAPIClient.EnterprisesDevicesPatchFunc = func(ctx context.Context, deviceName string, device *androidmanagement.Device) (*androidmanagement.Device, error) { return &androidmanagement.Device{}, nil } @@ -867,3 +871,219 @@ func (s *integrationMDMTestSuite) TestBatchAndroidApps() { require.Equal(t, 3, count) }) } + +func (s *integrationMDMTestSuite) TestAndroidAppsUninstallOnDelete() { + ctx := context.Background() + t := s.T() + + s.setSkipWorkerJobs(t) + s.setVPPTokenForTeam(0) + appConf, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + appConf.MDM.AndroidEnabledAndConfigured = false + err = s.ds.SaveAppConfig(ctx, appConf) + require.NoError(t, err) + + // create a team for the test host that will not be affected + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{Name: "test"}, http.StatusOK, &createTeamResp) + teamID := createTeamResp.Team.ID + + s.enableAndroidMDM(t) + + amapiConfig := struct { + AppIDsToNames map[string]string + EnterprisesPoliciesPatchValidator func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) + }{ + AppIDsToNames: map[string]string{}, + EnterprisesPoliciesPatchValidator: func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) {}, + } + + s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) { + title := amapiConfig.AppIDsToNames[packageName] + return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: title}, nil + } + + s.androidAPIClient.EnterprisesPoliciesPatchFunc = func(ctx context.Context, policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) (*androidmanagement.Policy, error) { + amapiConfig.EnterprisesPoliciesPatchValidator(policyName, policy, opts) + return &androidmanagement.Policy{}, nil + } + + // add some Android apps + androidApps := make([]*fleet.VPPApp, 5) + titleIDs := make([]uint, len(androidApps)) + for i := range androidApps { + androidApps[i] = &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{AdamID: "com.app" + fmt.Sprint(i), Platform: fleet.AndroidPlatform}, + }, + Name: "App" + fmt.Sprint(i), + BundleIdentifier: "com.app" + fmt.Sprint(i), + IconURL: "https://example.com/images/" + fmt.Sprint(i), + } + amapiConfig.AppIDsToNames[androidApps[i].AdamID] = androidApps[i].Name + + var addAppResp addAppStoreAppResponse + + // last app goes on the team, will not affect the test + var teamIDPtr *uint + if i == len(androidApps)-1 { + teamIDPtr = &teamID + } + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: androidApps[i].AdamID, + Platform: fleet.AndroidPlatform, + TeamID: teamIDPtr, + }, http.StatusOK, &addAppResp) + titleIDs[i] = addAppResp.TitleID + } + + // delete app [0], does not affect any host so no removeApplicationsPolicy call + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleIDs[0]), nil, http.StatusNoContent, "team_id", "0") + s.runWorkerUntilDone() + require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + + // enroll a few Android devices + secrets, err := s.ds.GetEnrollSecrets(ctx, nil) + require.NoError(t, err) + require.Len(t, secrets, 1) + + assets, err := s.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidPubSubToken}, nil) + require.NoError(t, err) + pubsubToken := assets[fleet.MDMAssetAndroidPubSubToken] + require.NotEmpty(t, pubsubToken.Value) + + deviceID1 := createAndroidDeviceID("test-android") + deviceID2 := createAndroidDeviceID("test-android-2") + deviceID3 := createAndroidDeviceID("test-android-3") + + enterpriseSpecificID1 := strings.ToUpper(uuid.New().String()) + enterpriseSpecificID2 := strings.ToUpper(uuid.New().String()) + enterpriseSpecificID3 := strings.ToUpper(uuid.New().String()) + var req android_service.PubSubPushRequest + for _, d := range []struct { + id string + esi string + }{{deviceID1, enterpriseSpecificID1}, {deviceID2, enterpriseSpecificID2}, {deviceID3, enterpriseSpecificID3}} { + enrollmentMessage := enrollmentMessageWithEnterpriseSpecificID(t, androidmanagement.Device{ + Name: d.id, + EnrollmentTokenData: fmt.Sprintf(`{"EnrollSecret": "%s"}`, secrets[0].Secret), + }, d.esi) + req = android_service.PubSubPushRequest{PubSubMessage: *enrollmentMessage} + s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubsubToken.Value)) + } + + var hosts listHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts) + require.Len(t, hosts.Hosts, 3) + host1 := hosts.Hosts[0] + host2 := hosts.Hosts[1] + host3 := hosts.Hosts[2] // isolated host, not affected by test + + // transfer host3 to the team + s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{ + TeamID: &teamID, + HostIDs: []uint{host3.ID}, + }, http.StatusOK, &addHostsToTeamResponse{}) + + // run the worker, the hosts get the 3 remaining android apps as self-service-available + s.runWorkerUntilDone() + require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked) + require.True(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked = false + s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false // from now on (after device enrollment), this gets called only when we expect it to + + for _, host := range []fleet.HostResponse{host1, host2} { + var getHostSw getHostSoftwareResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, + "available_for_install", "true", "order_key", "name") + require.Len(t, getHostSw.Software, 3) + require.NotNil(t, getHostSw.Software[0].AppStoreApp) + require.Equal(t, androidApps[1].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID) + require.NotNil(t, getHostSw.Software[1].AppStoreApp) + require.Equal(t, androidApps[2].AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID) + require.NotNil(t, getHostSw.Software[2].AppStoreApp) + require.Equal(t, androidApps[3].AdamID, getHostSw.Software[2].AppStoreApp.AppStoreID) + } + + // delete app [1], should trigger remove from both hosts + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleIDs[1]), nil, http.StatusNoContent, "team_id", "0") + s.runWorkerUntilDone() + require.True(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked) + s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked = false + + for _, host := range []fleet.HostResponse{host1, host2} { + var getHostSw getHostSoftwareResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, + "available_for_install", "true", "order_key", "name") + require.Len(t, getHostSw.Software, 2) + require.NotNil(t, getHostSw.Software[0].AppStoreApp) + require.Equal(t, androidApps[2].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID) + require.NotNil(t, getHostSw.Software[1].AppStoreApp) + require.Equal(t, androidApps[3].AdamID, getHostSw.Software[1].AppStoreApp.AppStoreID) + } + + // batch-set to keep only app [3] (effectively deletes app[2]), as per a gitops run + var batchResp batchAssociateAppStoreAppsResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{ + {AppStoreID: androidApps[3].AdamID, SelfService: true, Platform: fleet.AndroidPlatform, Configuration: json.RawMessage("{}")}, + }, + }, http.StatusOK, &batchResp) + + s.runWorkerUntilDone() // calls policies patch, which sets (replaces) the list of apps + require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + require.True(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked) + s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false + + for _, host := range []fleet.HostResponse{host1, host2} { + var getHostSw getHostSoftwareResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, + "available_for_install", "true", "order_key", "name") + require.Len(t, getHostSw.Software, 1) + require.NotNil(t, getHostSw.Software[0].AppStoreApp) + require.Equal(t, androidApps[3].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID) + } + + // batch-set to remove all apps + batchResp = batchAssociateAppStoreAppsResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{}, + }, http.StatusOK, &batchResp) + + s.runWorkerUntilDone() // calls policies patch, which sets (replaces) the list of apps + require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + require.True(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked) + s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false + + for _, host := range []fleet.HostResponse{host1, host2} { + var getHostSw getHostSoftwareResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, + "available_for_install", "true", "order_key", "name") + require.Len(t, getHostSw.Software, 0) + } + + // batch-set again without any app is a no-op + batchResp = batchAssociateAppStoreAppsResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps/batch", batchAssociateAppStoreAppsRequest{ + Apps: []fleet.VPPBatchPayload{}, + }, http.StatusOK, &batchResp) + + s.runWorkerUntilDone() + require.False(t, s.androidAPIClient.EnterprisesPoliciesRemovePolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked) + require.False(t, s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked) + + // isolated host was unaffected, still has the same team app + var getHostSw getHostSoftwareResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host3.ID), nil, http.StatusOK, &getHostSw, + "available_for_install", "true", "order_key", "name") + require.Len(t, getHostSw.Software, 1) + require.NotNil(t, getHostSw.Software[0].AppStoreApp) + require.Equal(t, androidApps[4].AdamID, getHostSw.Software[0].AppStoreApp.AppStoreID) +} diff --git a/server/worker/software_worker.go b/server/worker/software_worker.go index ac6ebc58cf..2d2f09859e 100644 --- a/server/worker/software_worker.go +++ b/server/worker/software_worker.go @@ -31,8 +31,9 @@ func (v *SoftwareWorker) Name() string { } const ( - makeAndroidAppsAvailableForHostTask SoftwareWorkerTask = "make_android_apps_available_for_host" + makeAndroidAppsAvailableForHostTask SoftwareWorkerTask = "make_android_apps_available_for_host" // deprecated makeAndroidAppAvailableTask SoftwareWorkerTask = "make_android_app_available" + makeAndroidAppUnavailableTask SoftwareWorkerTask = "make_android_app_unavailable" runAndroidSetupExperienceTask SoftwareWorkerTask = "run_android_setup_experience" bulkSetAndroidAppsAvailableForHostTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_host" bulkSetAndroidAppsAvailableForHostsTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_hosts" @@ -62,6 +63,10 @@ type softwareWorkerArgs struct { // of the action that triggered this task. AppConfigChanged bool `json:"app_config_changed,omitempty"` UUIDsToIDs map[string]uint `json:"uuids_to_ids,omitempty"` + + // HostUUIDToPolicyID is a map of host UUID as key to policy ID as value + // for which the app to make unavailable applies. + HostUUIDToPolicyID map[string]string `json:"host_uuid_to_policy_id,omitempty"` } func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) error { @@ -89,6 +94,14 @@ func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) erro makeAndroidAppAvailableTask, ) + case makeAndroidAppUnavailableTask: + return ctxerr.Wrapf( + ctx, + v.makeAndroidAppUnavailable(ctx, args.ApplicationID, args.HostUUIDToPolicyID, args.EnterpriseName), + "running %s task", + makeAndroidAppUnavailableTask, + ) + case runAndroidSetupExperienceTask: return ctxerr.Wrapf( ctx, @@ -167,6 +180,16 @@ func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicatio return nil } +// this is called when an app is removed from Fleet. +func (v *SoftwareWorker) makeAndroidAppUnavailable(ctx context.Context, applicationID string, hostUUIDToPolicyID map[string]string, enterpriseName string) error { + // Update Android MDM policy to remove the app from the hosts + _, err := v.AndroidModule.RemoveAppsFromAndroidPolicy(ctx, enterpriseName, []string{applicationID}, hostUUIDToPolicyID) + if err != nil { + return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy") + } + return nil +} + func (v *SoftwareWorker) ensureHostSpecificPolicyIsApplied(ctx context.Context, hostUUID string, enterpriseName, policyID string) error { if policyID == fmt.Sprint(android.DefaultAndroidPolicyID) { var policy androidmanagement.Policy @@ -448,6 +471,23 @@ func QueueMakeAndroidAppAvailableJob(ctx context.Context, ds fleet.Datastore, lo return nil } +func QueueMakeAndroidAppUnavailableJob(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, applicationID string, hostsUUIDToPolicyID map[string]string, enterpriseName string) error { + args := &softwareWorkerArgs{ + Task: makeAndroidAppUnavailableTask, + ApplicationID: applicationID, + HostUUIDToPolicyID: hostsUUIDToPolicyID, + EnterpriseName: enterpriseName, + } + + job, err := QueueJob(ctx, ds, softwareWorkerJobName, args) + if err != nil { + return ctxerr.Wrap(ctx, err, "queueing job") + } + + level.Debug(logger).Log("job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task) + return nil +} + func QueueBulkSetAndroidAppsAvailableForHost( ctx context.Context, ds fleet.Datastore,