cherry-pick: fix some issues with teams and self-service android apps #37062 (#37362)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #36807 

cherry-pick for https://github.com/fleetdm/fleet/pull/37062
This commit is contained in:
Jahziel Villasana-Espinoza
2025-12-17 11:44:25 -05:00
committed by GitHub
parent 8e589f8ee6
commit 63fc8a3da5
11 changed files with 284 additions and 14 deletions
+3
View File
@@ -67,6 +67,9 @@ func TestHostsTransferByHosts(t *testing.T) {
ds.ListHostsLiteByIDsFunc = func(ctx context.Context, ids []uint) ([]*fleet.Host, error) {
return nil, nil
}
ds.ListMDMAndroidUUIDsToHostIDsFunc = func(ctx context.Context, hostIDs []uint) (map[string]uint, error) {
return map[string]uint{}, nil
}
assert.Equal(t, "", RunAppForTest(t, []string{"hosts", "transfer", "--team", "team1", "--hosts", "host1"}))
assert.True(t, ds.AddHostsToTeamFuncInvoked)
+37
View File
@@ -1903,3 +1903,40 @@ func (ds *Datastore) updateAndroidAppConfigurationTx(ctx context.Context, tx sql
}
return nil
}
func (ds *Datastore) ListMDMAndroidUUIDsToHostIDs(ctx context.Context, hostIDs []uint) (map[string]uint, error) {
if len(hostIDs) == 0 {
return nil, nil
}
stmt := `
SELECT
h.id AS id, h.uuid AS uuid
FROM
hosts h
JOIN android_devices ad ON ad.host_id = h.id
WHERE
h.id IN (?) AND
h.platform = 'android'
`
stmt, args, err := sqlx.In(stmt, hostIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "prepare statement arguments")
}
var rows []struct {
ID uint `db:"id"`
UUID string `db:"uuid"`
}
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "list mdm android uuids to host ids")
}
results := make(map[string]uint, len(rows))
for _, r := range rows {
results[r.UUID] = r.ID
}
return results, nil
}
@@ -3113,17 +3113,18 @@ func (ds *Datastore) getIncludedHostUUIDMapForSoftware(ctx context.Context, tx s
FROM
hosts h
JOIN android_devices ad ON ad.enterprise_specific_id = h.uuid
JOIN vpp_apps_teams vat ON vat.team_id <=> h.team_id AND vat.id = ?
WHERE
EXISTS (%s)
AND platform = 'android'
AND h.platform = 'android'
`, filter)
var queryResults []struct {
UUID string `db:"uuid"`
AppliedPolicyID *string `db:"applied_policy_id"`
}
if err := sqlx.SelectContext(ctx, tx, &queryResults, stmt, softwareID, softwareID, softwareID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing host uuids included in software scope")
if err := sqlx.SelectContext(ctx, tx, &queryResults, stmt, softwareID, softwareID, softwareID, softwareID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing hosts included in software scope")
}
res := make(map[string]string, len(queryResults))
+5 -2
View File
@@ -633,7 +633,7 @@ func (ds *Datastore) InsertVPPAppWithTeam(ctx context.Context, app *fleet.VPPApp
}
}
if vppToken != nil {
if vppToken != nil && app.Platform != fleet.AndroidPlatform {
vppTokenID = &vppToken.ID
}
@@ -2250,6 +2250,7 @@ FROM (
0 AS count_host_updated_after_labels,
vpp_apps_teams.adam_id AS installable_id
FROM vpp_apps_teams
JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id
LEFT JOIN vpp_app_team_labels ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id
WHERE vpp_app_team_labels.id IS NULL AND vpp_apps_teams.platform = 'android'
@@ -2264,6 +2265,7 @@ FROM (
FROM
vpp_app_team_labels vatl
LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id
JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id
LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id
AND lm.host_id = ?
WHERE vatl.exclude = 0 AND vpp_apps_teams.platform = 'android'
@@ -2300,6 +2302,7 @@ FROM (
FROM
vpp_app_team_labels vatl
LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id
JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id
LEFT OUTER JOIN labels lbl ON lbl.id = vatl.label_id
LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id
AND lm.host_id = ?
@@ -2311,7 +2314,7 @@ FROM (
AND count_host_labels = 0) t;
`
err = sqlx.SelectContext(ctx, ds.reader(ctx), &applicationIDs, stmt, hostID, hostID, hostID)
err = sqlx.SelectContext(ctx, ds.reader(ctx), &applicationIDs, stmt, hostID, hostID, hostID, hostID, hostID, hostID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get in android apps in scope for host")
}
+2
View File
@@ -2408,6 +2408,8 @@ type Datastore interface {
// DeleteAndroidAppConfiguration removes an Android app configuration.
DeleteAndroidAppConfiguration(ctx context.Context, adamID string, globalOrTeamID uint) error
ListMDMAndroidUUIDsToHostIDs(ctx context.Context, hostIDs []uint) (map[string]uint, error)
// /////////////////////////////////////////////////////////////////////////////
// SCIM
+2
View File
@@ -76,3 +76,5 @@ type MDMAndroidPolicyRequest struct {
AppliedPolicyVersion sql.Null[int64] `db:"applied_policy_version"`
PolicyVersion sql.Null[int64] `db:"policy_version"`
}
const AppStatusAvailable = "AVAILABLE"
+12
View File
@@ -1567,6 +1567,8 @@ type UpdateAndroidAppConfigurationFunc func(ctx context.Context, config *fleet.A
type DeleteAndroidAppConfigurationFunc func(ctx context.Context, adamID string, globalOrTeamID uint) error
type ListMDMAndroidUUIDsToHostIDsFunc func(ctx context.Context, hostIDs []uint) (map[string]uint, error)
type CreateScimUserFunc func(ctx context.Context, user *fleet.ScimUser) (uint, error)
type ScimUserByIDFunc func(ctx context.Context, id uint) (*fleet.ScimUser, error)
@@ -4014,6 +4016,9 @@ type DataStore struct {
DeleteAndroidAppConfigurationFunc DeleteAndroidAppConfigurationFunc
DeleteAndroidAppConfigurationFuncInvoked bool
ListMDMAndroidUUIDsToHostIDsFunc ListMDMAndroidUUIDsToHostIDsFunc
ListMDMAndroidUUIDsToHostIDsFuncInvoked bool
CreateScimUserFunc CreateScimUserFunc
CreateScimUserFuncInvoked bool
@@ -9616,6 +9621,13 @@ func (s *DataStore) DeleteAndroidAppConfiguration(ctx context.Context, adamID st
return s.DeleteAndroidAppConfigurationFunc(ctx, adamID, globalOrTeamID)
}
func (s *DataStore) ListMDMAndroidUUIDsToHostIDs(ctx context.Context, hostIDs []uint) (map[string]uint, error) {
s.mu.Lock()
s.ListMDMAndroidUUIDsToHostIDsFuncInvoked = true
s.mu.Unlock()
return s.ListMDMAndroidUUIDsToHostIDsFunc(ctx, hostIDs)
}
func (s *DataStore) CreateScimUser(ctx context.Context, user *fleet.ScimUser) (uint, error) {
s.mu.Lock()
s.CreateScimUserFuncInvoked = true
+17
View File
@@ -1109,6 +1109,23 @@ func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []
}
}
// If there are any Android hosts, update their available apps.
androidUUIDs, err := svc.ds.ListMDMAndroidUUIDsToHostIDs(ctx, hostIDs)
if err != nil {
return err
}
if len(androidUUIDs) > 0 {
enterprise, err := svc.ds.GetEnterprise(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "get android enterprise")
}
if err := worker.QueueBulkSetAndroidAppsAvailableForHosts(ctx, svc.ds, svc.logger, androidUUIDs, enterprise.Name()); err != nil {
return ctxerr.Wrap(ctx, err, "queue bulk set available android apps for hosts job")
}
}
return svc.createTransferredHostsActivity(ctx, teamID, hostIDs, nil)
}
+3
View File
@@ -847,6 +847,9 @@ func TestHostAuth(t *testing.T) {
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
ds.ListMDMAndroidUUIDsToHostIDsFunc = func(ctx context.Context, hostIDs []uint) (map[string]uint, error) {
return map[string]uint{}, nil
}
testCases := []struct {
name string
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"time"
@@ -91,8 +92,25 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
)
s.Assert().Contains(extractServerErrorText(r.Body), "Couldn't add software. The application ID isn't available in Play Store. Please find ID on the Play Store and try again.")
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) {
return &androidmanagement.Application{IconUrl: "https://example.com/1.jpg", Title: "Test App"}, nil
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
}
// Valid application ID format, but wrong platform specified: should fail
@@ -193,6 +211,7 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
// Should have hit the android API endpoint
s.Assert().True(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked = false
s.DoJSON(
"PATCH",
@@ -211,7 +230,108 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
return nil
})
// Test Android app configurations
// Add some apps to a different team. They shouldn't be sent to our existing host
var newTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &createTeamRequest{TeamPayload: fleet.TeamPayload{Name: ptr.String("Team 1")}}, http.StatusOK, &newTeamResp)
team := newTeamResp.Team
// Add Android app
androidAppNewTeam := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "com.my.cool.app",
Platform: fleet.AndroidPlatform,
},
},
Name: "My cool app",
BundleIdentifier: "com.my.cool.app",
IconURL: "https://example.com/images/3",
}
s.DoJSON(
"POST",
"/api/latest/fleet/software/app_store_apps",
&addAppStoreAppRequest{AppStoreID: androidAppNewTeam.AdamID, Platform: fleet.AndroidPlatform, TeamID: &team.ID},
http.StatusOK,
&addAppResp,
)
// New app should not show up in "No team" library
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(0))
s.Assert().Len(listSWTitles.SoftwareTitles, 1)
s.Assert().Equal(androidApp.AdamID, listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID) // just the app we had before
s.Assert().Empty(listSWTitles.SoftwareTitles[0].AppStoreApp.Version)
// New app SHOULD show up in our new team library
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(team.ID))
s.Assert().Len(listSWTitles.SoftwareTitles, 1)
s.Assert().Equal(androidAppNewTeam.AdamID, listSWTitles.SoftwareTitles[0].AppStoreApp.AppStoreID)
s.Assert().Empty(listSWTitles.SoftwareTitles[0].AppStoreApp.Version)
androidAppNewTeam2 := &fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "com.my.cool.app.two",
Platform: fleet.AndroidPlatform,
},
},
Name: "My cool app 2",
BundleIdentifier: "com.my.cool.app.two",
IconURL: "https://example.com/images/4",
}
amapiConfig.AppIDsToNames[androidAppNewTeam2.AdamID] = androidAppNewTeam2.Name
s.DoJSON(
"POST",
"/api/latest/fleet/software/app_store_apps",
&addAppStoreAppRequest{AppStoreID: androidAppNewTeam2.AdamID, Platform: fleet.AndroidPlatform, TeamID: &team.ID},
http.StatusOK,
&addAppResp,
)
s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listSWTitles, "team_id", fmt.Sprint(team.ID))
s.Assert().Len(listSWTitles.SoftwareTitles, 2)
s.Assert().True(slices.ContainsFunc(listSWTitles.SoftwareTitles, func(t fleet.SoftwareTitleListResult) bool {
return t.AppStoreApp.AppStoreID == androidAppNewTeam.AdamID || t.AppStoreApp.AppStoreID == androidAppNewTeam2.AdamID
}))
s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(),
fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "platform": "%s", "self_service": true}`,
team.Name, androidAppNewTeam2.Name, addAppResp.TitleID, androidAppNewTeam2.AdamID, fmt.Sprint(team.ID), androidAppNewTeam2.Platform), 0)
s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked = false
s.runWorkerUntilDone()
// We shouldn't have hit the AMAPI, since there are no hosts in the team
s.Assert().False(s.androidAPIClient.EnterprisesPoliciesModifyPolicyApplicationsFuncInvoked)
s.Assert().False(s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
amapiConfig.EnterprisesPoliciesPatchValidator = func(policyName string, policy *androidmanagement.Policy, opts androidmgmt.PoliciesPatchOpts) {
var appIDs []string
for _, a := range policy.Applications {
appIDs = append(appIDs, a.PackageName)
}
s.Assert().ElementsMatch(appIDs, []string{androidAppNewTeam.AdamID, androidAppNewTeam2.AdamID})
s.Assert().Contains(policyName, host1.UUID)
}
// Transfer a host to the team
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &team.ID,
HostIDs: []uint{host1.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
s.runWorkerUntilDone()
s.Assert().True(s.androidAPIClient.EnterprisesPoliciesPatchFuncInvoked)
// Transfer host back to "No team"
s.DoJSON("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: nil,
HostIDs: []uint{host1.ID},
}, http.StatusOK, &addHostsToTeamResponse{})
// =========================================
// Android app configurations
// =========================================
// Title with no configuration should omit it from response
var getAppResp map[string]any
@@ -236,6 +356,8 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
IconURL: "https://example.com/images/2",
}
amapiConfig.AppIDsToNames[androidAppWithConfig.AdamID] = androidAppWithConfig.Name
// Add Android app
var appWithConfigResp addAppStoreAppResponse
s.DoJSON(
@@ -253,7 +375,7 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
// Verify that activity includes configuration
s.lastActivityMatches(fleet.ActivityAddedAppStoreApp{}.ActivityName(),
fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "platform": "%s", "self_service": true,"configuration": %s}`,
"", "Test App", appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", androidAppWithConfig.Platform, androidAppWithConfig.Configuration), 0)
"", androidAppWithConfig.Name, appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", androidAppWithConfig.Platform, androidAppWithConfig.Configuration), 0)
// Should see it in host software library
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host1.ID), nil, http.StatusOK, &getHostSw, "available_for_install", "true")
@@ -294,7 +416,7 @@ func (s *integrationMDMTestSuite) TestAndroidAppsSelfService() {
// Verify that configuration changed and last activity is correct
s.lastActivityMatches(fleet.ActivityEditedAppStoreApp{}.ActivityName(),
fmt.Sprintf(`{"team_name": "%s", "software_title": "%s", "software_icon_url":"https://example.com/1.jpg", "software_title_id": %d, "app_store_id": "%s", "team_id": %s, "software_display_name":"", "platform": "%s", "self_service": true,"configuration": %s}`,
"", "Test App", appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", androidAppWithConfig.Platform, newConfig), 0)
"", androidAppWithConfig.Name, appWithConfigResp.TitleID, androidAppWithConfig.AdamID, "null", androidAppWithConfig.Platform, newConfig), 0)
}
func (s *integrationMDMTestSuite) TestAndroidSetupExperienceSoftware() {
+73 -5
View File
@@ -31,10 +31,11 @@ func (v *SoftwareWorker) Name() string {
}
const (
makeAndroidAppsAvailableForHostTask SoftwareWorkerTask = "make_android_apps_available_for_host"
makeAndroidAppAvailableTask SoftwareWorkerTask = "make_android_app_available"
runAndroidSetupExperienceTask SoftwareWorkerTask = "run_android_setup_experience"
bulkSetAndroidAppsAvailableForHostTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_host"
makeAndroidAppsAvailableForHostTask SoftwareWorkerTask = "make_android_apps_available_for_host"
makeAndroidAppAvailableTask SoftwareWorkerTask = "make_android_app_available"
runAndroidSetupExperienceTask SoftwareWorkerTask = "run_android_setup_experience"
bulkSetAndroidAppsAvailableForHostTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_host"
bulkSetAndroidAppsAvailableForHostsTask SoftwareWorkerTask = "bulk_set_android_apps_available_for_hosts"
)
type softwareWorkerArgs struct {
@@ -59,7 +60,8 @@ type softwareWorkerArgs struct {
// AppConfigChanged indicates if the android app configuration changed as part
// of the action that triggered this task.
AppConfigChanged bool `json:"app_config_changed,omitempty"`
AppConfigChanged bool `json:"app_config_changed,omitempty"`
UUIDsToIDs map[string]uint `json:"uuids_to_ids,omitempty"`
}
func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) error {
@@ -105,8 +107,16 @@ func (v *SoftwareWorker) Run(ctx context.Context, argsJSON json.RawMessage) erro
), "running %s task",
bulkSetAndroidAppsAvailableForHostTask)
case bulkSetAndroidAppsAvailableForHostsTask:
return ctxerr.Wrapf(ctx, v.bulkSetAndroidAppsAvailableForHosts(
ctx,
args.UUIDsToIDs,
args.EnterpriseName,
), "running %s task", bulkSetAndroidAppsAvailableForHostsTask)
default:
return ctxerr.Errorf(ctx, "unknown task: %v", args.Task)
}
}
@@ -456,3 +466,61 @@ func QueueBulkSetAndroidAppsAvailableForHost(
level.Debug(logger).Log("job_id", job.ID, "job_name", softwareWorkerJobName, "task", args.Task)
return nil
}
func (v *SoftwareWorker) bulkSetAndroidAppsAvailableForHosts(ctx context.Context, uuidsToIDs map[string]uint, enterpriseName string) error {
// for each host
// get the set of self-service apps that are in scope for it
for uuid, hostID := range uuidsToIDs {
androidHost, err := v.Datastore.AndroidHostLiteByHostUUID(ctx, uuid)
if err != nil {
return ctxerr.Wrapf(ctx, err, "get android host by host UUID %s", uuid)
}
appIDs, err := v.Datastore.GetAndroidAppsInScopeForHost(ctx, hostID)
if err != nil {
return ctxerr.WrapWithData(ctx, err, "get android apps in scope for host", map[string]any{"host_id": hostID})
}
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.SetAppsForAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{uuid: uuid})
if err != nil {
return ctxerr.WrapWithData(ctx, err, "set apps for android policy", map[string]any{"host_id": hostID})
}
}
return nil
}
func QueueBulkSetAndroidAppsAvailableForHosts(
ctx context.Context,
ds fleet.Datastore,
logger kitlog.Logger,
uuidsToIDs map[string]uint,
enterpriseName string) error {
args := &softwareWorkerArgs{
Task: bulkSetAndroidAppsAvailableForHostsTask,
UUIDsToIDs: uuidsToIDs,
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
}