From 7582a084f6e20e64adacbbfc7e00baddb4bf768c Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 9 Dec 2025 14:48:55 -0500 Subject: [PATCH] Android config: policy reconciliation/application (#36627) --- changes/35494-apply-android-app-configuration | 1 + ee/server/service/vpp.go | 48 +++-- server/datastore/mysql/android.go | 90 +++++++++ server/datastore/mysql/android_test.go | 104 ++++++++++ server/datastore/mysql/software_installers.go | 12 +- server/fleet/android.go | 2 +- server/fleet/android_test.go | 10 +- server/fleet/datastore.go | 6 + server/mdm/android/service.go | 6 +- server/mdm/android/service/service.go | 42 ++-- server/mock/datastore_mock.go | 36 ++++ .../integration_mdm_setup_experience_test.go | 182 ++++++++++++++++++ server/worker/software_worker.go | 156 ++++++++++++--- 13 files changed, 621 insertions(+), 74 deletions(-) create mode 100644 changes/35494-apply-android-app-configuration diff --git a/changes/35494-apply-android-app-configuration b/changes/35494-apply-android-app-configuration new file mode 100644 index 0000000000..615f3ae2b5 --- /dev/null +++ b/changes/35494-apply-android-app-configuration @@ -0,0 +1 @@ +* Applied the Android app configuration to the devices. diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index de341671a8..a869467980 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -506,6 +506,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee isAndroidAppID := androidApplicationID.MatchString(appID.AdamID) var app *fleet.VPPApp + var androidEnterpriseName string // Different flows based on platform switch appID.Platform { @@ -520,8 +521,9 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee if err != nil { return 0, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err} } + androidEnterpriseName = enterprise.Name() - androidApp, err := svc.androidModule.EnterprisesApplications(ctx, enterprise.Name(), appID.AdamID) + androidApp, err := svc.androidModule.EnterprisesApplications(ctx, androidEnterpriseName, appID.AdamID) if err != nil { if fleet.IsNotFound(err) { return 0, fleet.NewInvalidArgumentError("app_store_id", "Couldn't add software. The application ID isn't available in Play Store. Please find ID on the Play Store and try again.") @@ -537,11 +539,6 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee TeamID: teamID, } - err = worker.QueueMakeAndroidAppAvailableJob(context.Background(), svc.ds, svc.logger, appID.AdamID, app.AppTeamID, enterprise.Name()) - if err != nil { - return 0, ctxerr.Wrap(ctx, err, "enqueuing job to make android app available") - } - default: if isAndroidAppID { return 0, fleet.NewInvalidArgumentError( @@ -623,13 +620,18 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee Name: assetMD.TrackName, LatestVersion: assetMD.Version, } - } addedApp, err := svc.ds.InsertVPPAppWithTeam(ctx, app, teamID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "writing VPP app to db") } + if appID.Platform == fleet.AndroidPlatform { + err := worker.QueueMakeAndroidAppAvailableJob(ctx, svc.ds, svc.logger, appID.AdamID, addedApp.AppTeamID, androidEnterpriseName) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "enqueuing job to make android app available") + } + } actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(addedApp.ValidatedLabels) @@ -663,7 +665,6 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee } return addedApp.TitleID, nil - } func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.VPPApp, error) { @@ -784,6 +785,9 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID if payload.SelfService != nil && meta.Platform != fleet.AndroidPlatform { selfServiceVal = *payload.SelfService } + if payload.Configuration != nil && meta.Platform != fleet.AndroidPlatform { + payload.Configuration = nil + } appToWrite := &fleet.VPPApp{ VPPAppTeam: fleet.VPPAppTeam{ @@ -822,10 +826,6 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID appToWrite.CategoryIDs = catIDs } - if payload.Configuration != nil { - appToWrite.Configuration = payload.Configuration - } - // check if labels have changed var existingLabels fleet.LabelIdentsWithScope switch { @@ -857,12 +857,34 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID } } + var androidConfigChanged bool + if !labelsChanged && meta.Platform == fleet.AndroidPlatform { + // check if configuration has changed + androidConfigChanged, err = svc.ds.HasAndroidAppConfigurationChanged(ctx, meta.AdamID, ptr.ValOrZero(teamID), payload.Configuration) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: checking if android app configuration changed") + } + } + // Update the app - _, err = svc.ds.InsertVPPAppWithTeam(ctx, appToWrite, teamID) + insertedApp, err := svc.ds.InsertVPPAppWithTeam(ctx, appToWrite, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: write app to db") } + // if labelsChanged, new hosts may require having the app made available, and if config + // changed, the app policy must be updated. + if meta.Platform == fleet.AndroidPlatform && (labelsChanged || androidConfigChanged) { + enterprise, err := svc.ds.GetEnterprise(ctx) + if err != nil { + return nil, &fleet.BadRequestError{Message: "Android MDM is not enabled", InternalErr: err} + } + err = worker.QueueMakeAndroidAppAvailableJob(ctx, svc.ds, svc.logger, appToWrite.AdamID, insertedApp.AppTeamID, enterprise.Name()) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "enqueuing job to make android app available") + } + } + if labelsChanged { // Get the hosts that are now IN label scope (after the update) hostsInScope, err := svc.ds.GetIncludedHostIDMapForVPPApp(ctx, meta.VPPAppsTeamsID) diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index b681d4b0f9..15b135a969 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -1632,6 +1632,37 @@ WHERE }) } +// HasAndroidAppConfigurationChanged checks if the new configuration for an Android app +// identified by adam_id and global_or_team_id is different from the existing one. This +// is a datastore method so that we rely on mysql's canonicalisation of JSON for comparison. +func (ds *Datastore) HasAndroidAppConfigurationChanged(ctx context.Context, applicationID string, globalOrTeamID uint, newConfig json.RawMessage) (bool, error) { + const stmt = ` +SELECT + CAST(? AS JSON) != configuration AS has_changed +FROM + android_app_configurations +WHERE + application_id = ? AND + global_or_team_id = ? +` + + newConfigStr := string(newConfig) + if len(newConfigStr) == 0 { + newConfigStr = "{}" // consider an empty config as an empty JSON for comparison's sake + } + + var hasChanged bool + err := sqlx.GetContext(ctx, ds.reader(ctx), &hasChanged, stmt, newConfigStr, applicationID, globalOrTeamID) + if err != nil { + if err == sql.ErrNoRows { + // old config does not exist, so old one is changed if not empty + return len(newConfig) > 0, nil + } + return false, ctxerr.Wrap(ctx, err, "compare android app configuration") + } + return hasChanged, nil +} + // GetAndroidAppConfiguration retrieves the configuration for an Android app // identified by adam_id and global_or_team_id. func (ds *Datastore) GetAndroidAppConfiguration(ctx context.Context, adamID string, globalOrTeamID uint) (*fleet.AndroidAppConfiguration, error) { @@ -1660,6 +1691,65 @@ func (ds *Datastore) GetAndroidAppConfiguration(ctx context.Context, adamID stri return &config, nil } +func (ds *Datastore) GetAndroidAppConfigurationByAppTeamID(ctx context.Context, vppAppTeamID uint) (*fleet.AndroidAppConfiguration, error) { + stmt := ` + SELECT + aac.id, + aac.application_id, + aac.team_id, + aac.global_or_team_id, + aac.configuration, + aac.created_at, + aac.updated_at + FROM android_app_configurations aac + JOIN vpp_apps_teams vat + ON vat.adam_id = aac.application_id AND vat.global_or_team_id = aac.global_or_team_id + WHERE vat.id = ? +` + + var config fleet.AndroidAppConfiguration + err := sqlx.GetContext(ctx, ds.reader(ctx), &config, stmt, vppAppTeamID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("AndroidAppConfiguration")) + } + return nil, ctxerr.Wrap(ctx, err, "get android app configuration") + } + + return &config, nil +} + +func (ds *Datastore) BulkGetAndroidAppConfigurations(ctx context.Context, appIDs []string, globalOrTeamID uint) (map[string]json.RawMessage, error) { + const bulkGetStmt = ` + SELECT + application_id, + configuration + FROM android_app_configurations + WHERE application_id IN (?) AND global_or_team_id = ? + ` + + if len(appIDs) == 0 { + return nil, nil + } + + stmt, args, err := sqlx.In(bulkGetStmt, appIDs, globalOrTeamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "building bulk get android app configurations query") + } + + var configs []*fleet.AndroidAppConfiguration + err = sqlx.SelectContext(ctx, ds.reader(ctx), &configs, stmt, args...) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "bulk get android app configurations") + } + + m := make(map[string]json.RawMessage, len(configs)) + for _, c := range configs { + m[c.ApplicationID] = c.Configuration + } + return m, nil +} + // InsertAndroidAppConfiguration creates a new Android app configuration entry. func (ds *Datastore) InsertAndroidAppConfiguration(ctx context.Context, config *fleet.AndroidAppConfiguration) error { stmt := ` diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index 57b485af88..8237c405ef 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -60,6 +60,7 @@ func TestAndroid(t *testing.T) { {"AndroidAppConfiguration_CascadeDeleteTeam", testAndroidAppConfigurationCascadeDeleteTeam}, {"AndroidAppConfiguration_GlobalVsTeam", testAndroidAppConfigurationGlobalVsTeam}, {"AddDeleteAndroidAppWithConfiguration", testAddDeleteAndroidAppWithConfiguration}, + {"HasAndroidAppConfigurationChanged", testHasAndroidAppConfigurationChanged}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -2366,6 +2367,18 @@ func testInsertAndGetAndroidAppConfiguration(t *testing.T, ds *Datastore) { require.NotZero(t, retrieved.ID) require.NotZero(t, retrieved.CreatedAt) require.NotZero(t, retrieved.UpdatedAt) + + // test bulk-get configuration + configsByAppID, err := ds.BulkGetAndroidAppConfigurations(testCtx(), []string{appID}, 0) + require.NoError(t, err) + require.Len(t, configsByAppID, 1) + require.Equal(t, string(retrieved.Configuration), string(configsByAppID[appID])) + + // bulk-get configuration returns any known app config, ignores others + configsByAppID, err = ds.BulkGetAndroidAppConfigurations(testCtx(), []string{appID, "no-such-app"}, 0) + require.NoError(t, err) + require.Len(t, configsByAppID, 1) + require.Equal(t, string(retrieved.Configuration), string(configsByAppID[appID])) } func testUpdateAndroidAppConfiguration(t *testing.T, ds *Datastore) { @@ -2607,3 +2620,94 @@ func testAddDeleteAndroidAppWithConfiguration(t *testing.T, ds *Datastore) { _, err = ds.GetAndroidAppConfiguration(ctx, app1.AdamID, team1.ID) require.ErrorContains(t, err, "not found") } + +func testHasAndroidAppConfigurationChanged(t *testing.T, ds *Datastore) { + ctx := context.Background() + + appID := "com.example.testapp" + setupTestApp(t, ds, appID) + + config := &fleet.AndroidAppConfiguration{ + ApplicationID: appID, + TeamID: nil, + GlobalOrTeamID: 0, + Configuration: json.RawMessage(`{"managedConfiguration": {"a": 1}}`), + } + err := ds.InsertAndroidAppConfiguration(ctx, config) + require.NoError(t, err) + + cases := []struct { + desc string + newConfig string + compareAppID string + changed bool + }{ + { + desc: "empty new config", + newConfig: "", + compareAppID: appID, + changed: true, + }, + { + desc: "empty object", + newConfig: "{}", + compareAppID: appID, + changed: true, + }, + { + desc: "boolean instead of object", + newConfig: "false", + compareAppID: appID, + changed: true, + }, + { + desc: "empty managedConfiguration", + newConfig: `{"managedConfiguration": {}}`, + compareAppID: appID, + changed: true, + }, + { + desc: "same config", + newConfig: `{"managedConfiguration": {"a":1}}`, + compareAppID: appID, + changed: false, + }, + { + desc: "slightly different config", + newConfig: `{"managedConfiguration": {"a":"b"}}`, + compareAppID: appID, + changed: true, + }, + { + desc: "expanded different config", + newConfig: `{"managedConfiguration": {"a":1, "b":2}}`, + compareAppID: appID, + changed: true, + }, + { + desc: "very different config", + newConfig: `{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`, + compareAppID: appID, + changed: true, + }, + { + desc: "empty compared to non-existing", + newConfig: ``, + compareAppID: "com.no-such.app", + changed: false, + }, + { + desc: "some config compared to non-existing", + newConfig: `{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`, + compareAppID: "com.no-such.app", + changed: true, + }, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + got, err := ds.HasAndroidAppConfigurationChanged(ctx, c.compareAppID, 0, json.RawMessage(c.newConfig)) + require.NoError(t, err) + require.Equal(t, c.changed, got) + }) + } +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index ea2d780c9c..fc4943f59b 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1947,11 +1947,11 @@ WHERE (unique_identifier, source, extension_for) IN (%s) ` const getSoftwareTitle = ` -SELECT - id -FROM - software_titles -WHERE +SELECT + id +FROM + software_titles +WHERE unique_identifier = ? AND source = ? AND extension_for = '' ` @@ -2167,7 +2167,7 @@ INSERT INTO software_installers ( install_during_setup, fleet_maintained_app_id ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT name FROM users WHERE id = ?), (SELECT email FROM users WHERE id = ?), ?, ?, COALESCE(?, false), ? ) ON DUPLICATE KEY UPDATE diff --git a/server/fleet/android.go b/server/fleet/android.go index 749b13c7d6..73b8b1b6b8 100644 --- a/server/fleet/android.go +++ b/server/fleet/android.go @@ -193,7 +193,7 @@ func ValidateAndroidAppConfiguration(config json.RawMessage) error { type androidAppConfig struct { ManagedConfiguration json.RawMessage `json:"managedConfiguration"` - WorkProfileWidgets json.RawMessage `json:"workProfileWidgets"` + WorkProfileWidgets string `json:"workProfileWidgets"` } var cfg androidAppConfig diff --git a/server/fleet/android_test.go b/server/fleet/android_test.go index 4f3cdbbb66..8928f8b625 100644 --- a/server/fleet/android_test.go +++ b/server/fleet/android_test.go @@ -24,7 +24,7 @@ func TestValidateAndroidAppConfiguration(t *testing.T) { }{ { name: "valid - both keys", - config: json.RawMessage(`{"managedConfiguration": {"key": "value"}, "workProfileWidgets": true}`), + config: json.RawMessage(`{"managedConfiguration": {"key": "value"}, "workProfileWidgets": ""}`), expectError: false, }, { @@ -34,9 +34,15 @@ func TestValidateAndroidAppConfiguration(t *testing.T) { }, { name: "valid - workProfileWidgets only", - config: json.RawMessage(`{"workProfileWidgets": false}`), + config: json.RawMessage(`{"workProfileWidgets": "WORK_PROFILE_WIDGETS_ALLOWED"}`), expectError: false, }, + { + name: "invalid - workProfileWidgets bad type", + config: json.RawMessage(`{"workProfileWidgets": false}`), + expectError: true, + errorMsg: "Couldn't update configuration. Invalid JSON.", + }, { name: "valid - empty object", config: json.RawMessage(`{}`), diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 8ef8d02f31..c28bdf2e25 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2376,6 +2376,12 @@ type Datastore interface { // GetAndroidAppConfiguration retrieves the configuration for an Android app // identified by adam_id and global_or_team_id. GetAndroidAppConfiguration(ctx context.Context, adamID string, globalOrTeamID uint) (*AndroidAppConfiguration, error) + GetAndroidAppConfigurationByAppTeamID(ctx context.Context, vppAppTeamID uint) (*AndroidAppConfiguration, error) + HasAndroidAppConfigurationChanged(ctx context.Context, applicationID string, globalOrTeamID uint, newConfig json.RawMessage) (bool, error) + + // BulkGetAndroidAppConfigurations retrieves Android app configurations for + // all provided apps and returns them indexed by the app id. + BulkGetAndroidAppConfigurations(ctx context.Context, appIDs []string, globalOrTeamID uint) (map[string]json.RawMessage, error) // InsertAndroidAppConfiguration creates a new Android app configuration entry. InsertAndroidAppConfiguration(ctx context.Context, config *AndroidAppConfiguration) error diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go index 2161537b06..d34df426aa 100644 --- a/server/mdm/android/service.go +++ b/server/mdm/android/service.go @@ -22,9 +22,11 @@ type Service interface { UnenrollAndroidHost(ctx context.Context, hostID uint) error EnterprisesApplications(ctx context.Context, enterpriseName, applicationID string) (*androidmanagement.Application, error) - AddAppsToAndroidPolicy(ctx context.Context, enterpriseName string, applicationIDs []string, hostUUIDs map[string]string, installType string) (map[string]*MDMAndroidPolicyRequest, error) + AddAppsToAndroidPolicy(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, 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. - SetAppsForAndroidPolicy(ctx context.Context, enterpriseName string, applicationIDs []string, hostUUIDs map[string]string, installType string) error + // Note that unlike AddAppsToAndroidPolicy, this method replaces the existing app list with the given one, it is + // not additive/PATCH semantics. + SetAppsForAndroidPolicy(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, hostUUIDs map[string]string) error AddFleetAgentToAndroidPolicy(ctx context.Context, enterpriseName string, hostConfigs map[string]AgentManagedConfiguration) error // BuildAndSendFleetAgentConfig builds the complete AgentManagedConfiguration for the given hosts // (including certificate templates) and sends it to the Android Management API. diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go index 7fb8256c21..87506cb08b 100644 --- a/server/mdm/android/service/service.go +++ b/server/mdm/android/service/service.go @@ -870,15 +870,7 @@ func (svc *Service) EnterprisesApplications(ctx context.Context, enterpriseName, // Adds the specified apps to the host-specific Android policy of the provided hosts, and // returns a map of host UUID to the policy request object of their updated policy on success. -func (svc *Service) AddAppsToAndroidPolicy(ctx context.Context, enterpriseName string, applicationIDs []string, hostUUIDs map[string]string, installType string) (map[string]*android.MDMAndroidPolicyRequest, error) { - var appPolicies []*androidmanagement.ApplicationPolicy - for _, a := range applicationIDs { - appPolicies = append(appPolicies, &androidmanagement.ApplicationPolicy{ - PackageName: a, - InstallType: installType, - }) - } - +func (svc *Service) AddAppsToAndroidPolicy(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, 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 { @@ -1291,20 +1283,28 @@ func (svc *Service) BuildAndSendFleetAgentConfig(ctx context.Context, enterprise return nil } -func (svc *Service) SetAppsForAndroidPolicy(ctx context.Context, enterpriseName string, applicationIDs []string, hostUUIDs map[string]string, installType string) error { - var appPolicies []*androidmanagement.ApplicationPolicy - for _, a := range applicationIDs { - appPolicies = append(appPolicies, &androidmanagement.ApplicationPolicy{PackageName: a, InstallType: "AVAILABLE"}) - } - for _, policyID := range hostUUIDs { - - policy := &androidmanagement.Policy{Applications: appPolicies} +// 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. +func (svc *Service) SetAppsForAndroidPolicy(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, hostUUIDs map[string]string) error { + var errs []error + for uuid, policyID := range hostUUIDs { policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, policyID) - - _, err := svc.androidAPIClient.EnterprisesPoliciesPatch(ctx, policyName, policy, androidmgmt.PoliciesPatchOpts{OnlyUpdateApps: true}) + policyRequest, err := newAndroidPolicyApplicationsRequest(policyID, policyName, appPolicies) if err != nil { - return ctxerr.Wrap(ctx, err, "setting apps list for android policy") + return ctxerr.Wrapf(ctx, err, "prepare policy request %s", policyName) + } + + var apiErr error + policy := &androidmanagement.Policy{Applications: appPolicies} + policy, apiErr = svc.androidAPIClient.EnterprisesPoliciesPatch(ctx, policyName, policy, androidmgmt.PoliciesPatchOpts{OnlyUpdateApps: true}) + if _, err := recordAndroidRequestResult(ctx, svc.fleetDS, policyRequest, policy, nil, apiErr); err != nil { + return ctxerr.Wrapf(ctx, err, "save android policy request for host %s", uuid) + } + + if apiErr != nil { + errs = append(errs, ctxerr.Wrapf(ctx, apiErr, "google api: modify policy applications for host %s", uuid)) } } - return nil + return errors.Join(errs...) } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 7bc293d70a..1e0ad21468 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1547,6 +1547,12 @@ type InsertAndroidSetupExperienceSoftwareInstallFunc func(ctx context.Context, p type GetAndroidAppConfigurationFunc func(ctx context.Context, adamID string, globalOrTeamID uint) (*fleet.AndroidAppConfiguration, error) +type GetAndroidAppConfigurationByAppTeamIDFunc func(ctx context.Context, vppAppTeamID uint) (*fleet.AndroidAppConfiguration, error) + +type HasAndroidAppConfigurationChangedFunc func(ctx context.Context, applicationID string, globalOrTeamID uint, newConfig json.RawMessage) (bool, error) + +type BulkGetAndroidAppConfigurationsFunc func(ctx context.Context, appIDs []string, globalOrTeamID uint) (map[string]json.RawMessage, error) + type InsertAndroidAppConfigurationFunc func(ctx context.Context, config *fleet.AndroidAppConfiguration) error type UpdateAndroidAppConfigurationFunc func(ctx context.Context, config *fleet.AndroidAppConfiguration) error @@ -3952,6 +3958,15 @@ type DataStore struct { GetAndroidAppConfigurationFunc GetAndroidAppConfigurationFunc GetAndroidAppConfigurationFuncInvoked bool + GetAndroidAppConfigurationByAppTeamIDFunc GetAndroidAppConfigurationByAppTeamIDFunc + GetAndroidAppConfigurationByAppTeamIDFuncInvoked bool + + HasAndroidAppConfigurationChangedFunc HasAndroidAppConfigurationChangedFunc + HasAndroidAppConfigurationChangedFuncInvoked bool + + BulkGetAndroidAppConfigurationsFunc BulkGetAndroidAppConfigurationsFunc + BulkGetAndroidAppConfigurationsFuncInvoked bool + InsertAndroidAppConfigurationFunc InsertAndroidAppConfigurationFunc InsertAndroidAppConfigurationFuncInvoked bool @@ -9466,6 +9481,27 @@ func (s *DataStore) GetAndroidAppConfiguration(ctx context.Context, adamID strin return s.GetAndroidAppConfigurationFunc(ctx, adamID, globalOrTeamID) } +func (s *DataStore) GetAndroidAppConfigurationByAppTeamID(ctx context.Context, vppAppTeamID uint) (*fleet.AndroidAppConfiguration, error) { + s.mu.Lock() + s.GetAndroidAppConfigurationByAppTeamIDFuncInvoked = true + s.mu.Unlock() + return s.GetAndroidAppConfigurationByAppTeamIDFunc(ctx, vppAppTeamID) +} + +func (s *DataStore) HasAndroidAppConfigurationChanged(ctx context.Context, applicationID string, globalOrTeamID uint, newConfig json.RawMessage) (bool, error) { + s.mu.Lock() + s.HasAndroidAppConfigurationChangedFuncInvoked = true + s.mu.Unlock() + return s.HasAndroidAppConfigurationChangedFunc(ctx, applicationID, globalOrTeamID, newConfig) +} + +func (s *DataStore) BulkGetAndroidAppConfigurations(ctx context.Context, appIDs []string, globalOrTeamID uint) (map[string]json.RawMessage, error) { + s.mu.Lock() + s.BulkGetAndroidAppConfigurationsFuncInvoked = true + s.mu.Unlock() + return s.BulkGetAndroidAppConfigurationsFunc(ctx, appIDs, globalOrTeamID) +} + func (s *DataStore) InsertAndroidAppConfiguration(ctx context.Context, config *fleet.AndroidAppConfiguration) error { s.mu.Lock() s.InsertAndroidAppConfigurationFuncInvoked = true diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index baac41b5eb..5f102decbb 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -30,6 +30,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/api/androidmanagement/v1" + "google.golang.org/api/googleapi" ) func (s *integrationMDMTestSuite) TestSetupExperienceScript() { @@ -3766,6 +3767,187 @@ func (s *integrationMDMTestSuite) TestSetupExperienceAndroidCancelOnUnenroll() { require.Equal(t, 0, countOther) } +func (s *integrationMDMTestSuite) TestAndroidAppConfiguration() { + t := s.T() + s.setSkipWorkerJobs(t) + + s.enableAndroidMDM(t) + + // add some android apps + app1 := &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "com.test1", + Platform: fleet.AndroidPlatform, + }, + }, + Name: "Test1", + BundleIdentifier: "com.test1", + IconURL: "https://example.com/1", + } + app2 := &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "com.test2", + Platform: fleet.AndroidPlatform, + }, + }, + Name: "Test2", + BundleIdentifier: "com.test2", + IconURL: "https://example.com/2", + } + app3 := &fleet.VPPApp{ + VPPAppTeam: fleet.VPPAppTeam{ + VPPAppID: fleet.VPPAppID{ + AdamID: "com.test3", + Platform: fleet.AndroidPlatform, + }, + }, + Name: "Test3", + BundleIdentifier: "com.test3", + IconURL: "https://example.com/3", + } + + androidApps := []*fleet.VPPApp{app1, app2, app3} + s.androidAPIClient.EnterprisesApplicationsFunc = func(ctx context.Context, enterpriseName string, packageName string) (*androidmanagement.Application, error) { + for _, app := range androidApps { + if app.AdamID == packageName { + return &androidmanagement.Application{IconUrl: app.IconURL, Title: app.Name}, nil + } + } + return nil, ¬FoundError{} + } + + // vars have no need for a mutex, protected via runWorkerUntilDone + var ( + // records the appPolicies received in the ModifyPolicyApplications calls + patchAppsPolicies [][]*androidmanagement.ApplicationPolicy + patchAppsCallCount int + ) + s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFunc = func(ctx context.Context, policyName string, appPolicies []*androidmanagement.ApplicationPolicy) (*androidmanagement.Policy, error) { + patchAppsCallCount++ + patchAppsPolicies = append(patchAppsPolicies, appPolicies) + + return &androidmanagement.Policy{Version: int64(patchAppsCallCount)}, nil + } + + // add Android app 1 + var addAppResp addAppStoreAppResponse + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: app1.AdamID, + Platform: fleet.AndroidPlatform, + }, http.StatusOK, &addAppResp) + app1TitleID := addAppResp.TitleID + + // add Android app 2 + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: app2.AdamID, + Platform: fleet.AndroidPlatform, + }, http.StatusOK, &addAppResp) + app2TitleID := addAppResp.TitleID + + require.NotEqual(t, app1TitleID, app2TitleID) + + s.runWorkerUntilDone() + + // worker should have done nothing (no host to add apps to yet) + require.Len(t, patchAppsPolicies, 0) + patchAppsPolicies = nil + + var patchAppResp updateAppStoreAppResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app1TitleID), &updateAppStoreAppRequest{ + TeamID: nil, + Configuration: json.RawMessage(`{"managedConfiguration": 1}`), + }, http.StatusOK, &patchAppResp) + + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app2TitleID), &updateAppStoreAppRequest{ + TeamID: nil, + Configuration: json.RawMessage(`{"managedConfiguration": 2}`), + }, http.StatusOK, &patchAppResp) + + // add app 1 and 2 to Android setup experience + var putResp putSetupExperienceSoftwareResponse + s.DoJSON("PUT", "/api/latest/fleet/setup_experience/software", &putSetupExperienceSoftwareRequest{ + Platform: string(fleet.AndroidPlatform), + TeamID: 0, + TitleIDs: []uint{app1TitleID, app2TitleID}, + }, http.StatusOK, &putResp) + + s.createAndEnrollAndroidDevice(t, "test-android", nil) + + s.runWorkerUntilDone() + + // worker should have: + // 1. made each app available to the included hosts (for self-service), so 2 entries for that (from the PATCH apps to set the config) + // (this is because I made the worker run after host enrollment, if there were no host, the task would have nothing to do) + // 2. made all apps available to the enrolled host (for self-service), from the host enrollment + // 3. installed the apps, from the host enrollment + require.Len(t, patchAppsPolicies, 4) + require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{ + {PackageName: app1.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`1`)}, + }, patchAppsPolicies[0]) + require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{ + {PackageName: app2.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`2`)}, + }, patchAppsPolicies[1]) + require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{ + {PackageName: app1.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`1`)}, + {PackageName: app2.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`2`)}, + }, patchAppsPolicies[2]) + require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{ + {PackageName: app1.VPPAppID.AdamID, InstallType: "PREINSTALLED", ManagedConfiguration: googleapi.RawMessage(`1`)}, + {PackageName: app2.VPPAppID.AdamID, InstallType: "PREINSTALLED", ManagedConfiguration: googleapi.RawMessage(`2`)}, + }, patchAppsPolicies[3]) + + patchAppsPolicies = nil + + // add app3 to Fleet + addAppResp = addAppStoreAppResponse{} + s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", &addAppStoreAppRequest{ + AppStoreID: app3.AdamID, + Platform: fleet.AndroidPlatform, + }, http.StatusOK, &addAppResp) + app3TitleID := addAppResp.TitleID + + s.runWorkerUntilDone() + + // worker should have: + // 1. made the apps available to the host (for self-service) + require.Len(t, patchAppsPolicies, 1) + require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{ + {PackageName: app3.VPPAppID.AdamID, InstallType: "AVAILABLE"}, + }, patchAppsPolicies[0]) + + patchAppsPolicies = nil + + // set a configuration for the app3 + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app3TitleID), &updateAppStoreAppRequest{ + TeamID: nil, + Configuration: json.RawMessage(`{"managedConfiguration": 3}`), + }, http.StatusOK, &patchAppResp) + + s.runWorkerUntilDone() + + // worker should have: + // 1. made the app available with its config + require.Len(t, patchAppsPolicies, 1) + require.ElementsMatch(t, []*androidmanagement.ApplicationPolicy{ + {PackageName: app3.VPPAppID.AdamID, InstallType: "AVAILABLE", ManagedConfiguration: googleapi.RawMessage(`3`)}, + }, patchAppsPolicies[0]) + + patchAppsPolicies = nil + + // patch but no change to the configuration for the app3 + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", app3TitleID), &updateAppStoreAppRequest{ + TeamID: nil, + Configuration: json.RawMessage(`{"managedConfiguration": 3}`), + }, http.StatusOK, &patchAppResp) + + s.runWorkerUntilDone() + + require.Len(t, patchAppsPolicies, 0) +} + func (s *integrationMDMTestSuite) createAndEnrollAndroidDevice(t *testing.T, name string, teamID *uint) (host *fleet.Host, deviceInfo androidmanagement.Device, pubSubToken fleet.MDMConfigAsset) { ctx := t.Context() diff --git a/server/worker/software_worker.go b/server/worker/software_worker.go index 8813556298..4a89de019b 100644 --- a/server/worker/software_worker.go +++ b/server/worker/software_worker.go @@ -8,10 +8,12 @@ import ( "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/ptr" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/google/uuid" "google.golang.org/api/androidmanagement/v1" + "google.golang.org/api/googleapi" ) const softwareWorkerJobName = "software_worker" @@ -41,8 +43,10 @@ type softwareWorkerArgs struct { ApplicationID string `json:"application_id,omitempty"` ApplicationIDs []string `json:"application_ids,omitempty"` EnterpriseName string `json:"enterprise_name,omitempty"` - AppTeamID uint `json:"app_team_id,omitempty"` - HostID uint `json:"host_id,omitempty"` + // AppTeamID is *not* a team ID, it is the vpp_apps_teams.id value. This is a bit confusing + // as a name, but that is what is expected in this field. + AppTeamID uint `json:"app_team_id,omitempty"` + HostID uint `json:"host_id,omitempty"` // HostEnrollTeamID is the team ID associated with the host at the time // of enrollment, which is the one used to run the setup experience. @@ -102,14 +106,33 @@ func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) erro } } +// this is called when a new app is added to Fleet and when an existing app is updated +// (either its scope of affected hosts changed due to labels conditions, or its +// configuration changed). func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicationID string, appTeamID uint, enterpriseName string) error { hosts, err := v.Datastore.GetIncludedHostUUIDMapForAppStoreApp(ctx, appTeamID) if err != nil { return ctxerr.Wrap(ctx, err, "add app store app: getting android hosts in scope") } + config, err := v.Datastore.GetAndroidAppConfigurationByAppTeamID(ctx, appTeamID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, err, "get android app configuration") + } + var configByAppID map[string]json.RawMessage + if config != nil && config.Configuration != nil { + configByAppID = map[string]json.RawMessage{ + applicationID: config.Configuration, + } + } + + appPolicies, err := buildApplicationPolicyWithConfig(ctx, []string{applicationID}, configByAppID, "AVAILABLE") + if err != nil { + return ctxerr.Wrap(ctx, err, "building application policies with config") + } + // Update Android MDM policy to include the app in self service - _, err = v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, []string{applicationID}, hosts, "AVAILABLE") + _, err = v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, hosts) if err != nil { return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy") } @@ -117,25 +140,7 @@ func (v *SoftwareWorker) makeAndroidAppAvailable(ctx context.Context, applicatio return nil } -func QueueMakeAndroidAppAvailableJob(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, applicationID string, appTeamID uint, enterpriseName string) error { - args := &softwareWorkerArgs{ - Task: makeAndroidAppAvailableTask, - ApplicationID: applicationID, - AppTeamID: appTeamID, - 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", makeAndroidAppAvailableTask) - return nil -} - -func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, hostUUID string, hostID uint, enterpriseName, policyID string) error { - +func (v *SoftwareWorker) ensureHostSpecificPolicyIsApplied(ctx context.Context, hostUUID string, enterpriseName, policyID string) error { if policyID == fmt.Sprint(android.DefaultAndroidPolicyID) { var policy androidmanagement.Policy policy.StatusReportingSettings = &androidmanagement.StatusReportingSettings{ @@ -186,6 +191,18 @@ func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, ho return err } } + return nil +} + +func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, hostUUID string, hostID uint, enterpriseName, policyID string) error { + if err := v.ensureHostSpecificPolicyIsApplied(ctx, hostUUID, enterpriseName, policyID); err != nil { + return ctxerr.Wrapf(ctx, err, "ensuring host-specific policy is applied for host %s", hostUUID) + } + + androidHost, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", hostUUID) + } appIDs, err := v.Datastore.GetAndroidAppsInScopeForHost(ctx, hostID) if err != nil { @@ -196,7 +213,17 @@ func (v *SoftwareWorker) makeAndroidAppsAvailableForHost(ctx context.Context, ho return nil } - _, err = v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appIDs, map[string]string{hostUUID: hostUUID}, "AVAILABLE") + configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, ptr.ValOrZero(androidHost.TeamID)) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk get android app configurations") + } + + appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "AVAILABLE") + if err != nil { + return ctxerr.Wrap(ctx, err, "building application policies with config") + } + + _, err = v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{hostUUID: hostUUID}) if err != nil { return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy") } @@ -240,8 +267,22 @@ func (v *SoftwareWorker) runAndroidSetupExperience(ctx context.Context, } if len(appIDs) > 0 { + // NOTE: from my tests, we do need to re-apply the app configs when installing apps, + // even if they were already applied when making the apps available for self-service. + // However, once installed, if the app config changes it is applied automatically by the + // policy change (no need to re-install). + configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, appIDs, hostEnrollTeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk get android app configurations") + } + + appPolicies, err := buildApplicationPolicyWithConfig(ctx, appIDs, configsByAppID, "PREINSTALLED") + if err != nil { + return ctxerr.Wrap(ctx, err, "building application policies with config") + } + // assign those apps to the host's Android policy - hostToPolicyRequest, err := v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appIDs, map[string]string{hostUUID: hostUUID}, "PREINSTALLED") + hostToPolicyRequest, err := v.AndroidModule.AddAppsToAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{hostUUID: hostUUID}) if err != nil { return ctxerr.Wrap(ctx, err, "add app store app: add app to android policy") } @@ -276,6 +317,56 @@ func (v *SoftwareWorker) runAndroidSetupExperience(ctx context.Context, return nil } +func (v *SoftwareWorker) bulkMakeAndroidAppsAvailableForHost(ctx context.Context, hostUUID, policyID string, applicationIDs []string, enterpriseName string) error { + host, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, hostUUID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "getting android host lite by uuid %s", hostUUID) + } + + configsByAppID, err := v.Datastore.BulkGetAndroidAppConfigurations(ctx, applicationIDs, ptr.ValOrZero(host.Host.TeamID)) + if err != nil { + return ctxerr.Wrap(ctx, err, "bulk get android app configurations") + } + + appPolicies, err := buildApplicationPolicyWithConfig(ctx, applicationIDs, configsByAppID, "AVAILABLE") + if err != nil { + return ctxerr.Wrap(ctx, err, "building application policies with config") + } + + // Update Android MDM policy to include the apps in self service + err = v.AndroidModule.SetAppsForAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{hostUUID: policyID}) + if err != nil { + return ctxerr.Wrap(ctx, err, "make android apps available") + } + + return nil +} + +func buildApplicationPolicyWithConfig(ctx context.Context, appIDs []string, + configsByAppID map[string]json.RawMessage, installType string) ([]*androidmanagement.ApplicationPolicy, error) { + + appPolicies := make([]*androidmanagement.ApplicationPolicy, 0, len(appIDs)) + for _, appID := range appIDs { + var androidAppConfig struct { + ManagedConfiguration json.RawMessage `json:"managedConfiguration"` + WorkProfileWidgets string `json:"workProfileWidgets"` + } + if config := configsByAppID[appID]; config != nil { + if err := json.Unmarshal(config, &androidAppConfig); err != nil { + // should never happen, as it is stored as json in the db and is pre-validated + return nil, ctxerr.Wrap(ctx, err, "unmarshal android app configuration") + } + } + appPolicies = append(appPolicies, &androidmanagement.ApplicationPolicy{ + PackageName: appID, + InstallType: installType, + ManagedConfiguration: googleapi.RawMessage(androidAppConfig.ManagedConfiguration), + WorkProfileWidgets: androidAppConfig.WorkProfileWidgets, + }) + } + return appPolicies, nil +} + func QueueRunAndroidSetupExperience(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, hostUUID string, hostEnrollTeamID *uint, enterpriseName string) error { @@ -299,13 +390,20 @@ func QueueRunAndroidSetupExperience(ctx context.Context, ds fleet.Datastore, log return nil } -func (v *SoftwareWorker) bulkMakeAndroidAppsAvailableForHost(ctx context.Context, hostUUID, policyID string, applicationIDs []string, enterpriseName string) error { - // Update Android MDM policy to include the apps in self service - err := v.AndroidModule.SetAppsForAndroidPolicy(ctx, enterpriseName, applicationIDs, map[string]string{hostUUID: policyID}, "AVAILABLE") - if err != nil { - return ctxerr.Wrap(ctx, err, "make android apps available") +func QueueMakeAndroidAppAvailableJob(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, applicationID string, appTeamID uint, enterpriseName string) error { + args := &softwareWorkerArgs{ + Task: makeAndroidAppAvailableTask, + ApplicationID: applicationID, + AppTeamID: appTeamID, + 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 }