Android config: policy reconciliation/application (#36627)

This commit is contained in:
Martin Angers
2025-12-09 14:48:55 -05:00
committed by GitHub
parent 3d12840e98
commit 7582a084f6
13 changed files with 621 additions and 74 deletions
@@ -0,0 +1 @@
* Applied the Android app configuration to the devices.
+35 -13
View File
@@ -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)
+90
View File
@@ -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 := `
+104
View File
@@ -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)
})
}
}
@@ -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
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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(`{}`),
+6
View File
@@ -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
+4 -2
View File
@@ -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.
+21 -21
View File
@@ -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...)
}
+36
View File
@@ -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
@@ -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, &notFoundError{}
}
// 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()
+127 -29
View File
@@ -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
}