Don't delete Android agent when transferring teams. (#37517)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #37440 Note, from manual testing, when moving host to a new team, it does NOT get the certs for the new team. We could fix this as part of this fix or in a separate PR. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [x] Confirmed that the fix is not expected to adversely impact load test results <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Fleet Agent integration for Android MDM devices * Fleet Agent policies are now automatically constructed and applied to managed Android devices * Fleet Agent configuration includes server URL, enrollment secrets, and certificate templates <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -28,6 +28,7 @@ type Service interface {
|
||||
// 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
|
||||
BuildFleetAgentApplicationPolicy(ctx context.Context, hostUUID string) (*androidmanagement.ApplicationPolicy, error)
|
||||
// BuildAndSendFleetAgentConfig builds the complete AgentManagedConfiguration for the given hosts
|
||||
// (including certificate templates) and sends it to the Android Management API.
|
||||
// This is the centralized function that should be used by all callers to avoid race conditions.
|
||||
|
||||
@@ -881,55 +881,142 @@ func (svc *Service) AddAppsToAndroidPolicy(ctx context.Context, enterpriseName s
|
||||
return hostToPolicyRequest, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// AddFleetAgentToAndroidPolicy adds the Fleet Agent to the Android policy for the given enterprise.
|
||||
// getFleetAgentPackageInfo returns the Fleet agent package name and SHA256 fingerprint.
|
||||
// Returns empty strings if the package is not configured, or an error if the package is configured but SHA256 is missing.
|
||||
func getFleetAgentPackageInfo(ctx context.Context) (packageName, sha256Fingerprint string, err error) {
|
||||
packageName = os.Getenv("FLEET_DEV_ANDROID_AGENT_PACKAGE")
|
||||
if packageName == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
sha256Fingerprint = os.Getenv("FLEET_DEV_ANDROID_AGENT_SIGNING_SHA256")
|
||||
if sha256Fingerprint == "" {
|
||||
return "", "", ctxerr.New(ctx, "FLEET_DEV_ANDROID_AGENT_SIGNING_SHA256 must be set when FLEET_DEV_ANDROID_AGENT_PACKAGE is set")
|
||||
}
|
||||
|
||||
return packageName, sha256Fingerprint, nil
|
||||
}
|
||||
|
||||
// buildFleetAgentAppPolicy builds an ApplicationPolicy for the Fleet agent from the given managed configuration.
|
||||
func buildFleetAgentAppPolicy(packageName, sha256Fingerprint string, managedConfig android.AgentManagedConfiguration) (*androidmanagement.ApplicationPolicy, error) {
|
||||
managedConfigJSON, err := json.Marshal(managedConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &androidmanagement.ApplicationPolicy{
|
||||
PackageName: packageName,
|
||||
InstallType: "FORCE_INSTALLED",
|
||||
DefaultPermissionPolicy: "GRANT",
|
||||
DelegatedScopes: []string{"CERT_INSTALL"},
|
||||
ManagedConfiguration: managedConfigJSON,
|
||||
SigningKeyCerts: []*androidmanagement.ApplicationSigningKeyCert{
|
||||
{
|
||||
SigningKeyCertFingerprintSha256: sha256Fingerprint,
|
||||
},
|
||||
},
|
||||
Roles: []*androidmanagement.Role{
|
||||
{
|
||||
RoleType: "COMPANION_APP",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddFleetAgentToAndroidPolicy adds the Fleet agent to the Android policy for the given enterprise.
|
||||
// hostConfigs maps host UUIDs to managed configurations for the Fleet Agent.
|
||||
// The UUID is BOTH the hostUUID and the policyID. We assume that the host UUID is the same as the policy ID.
|
||||
func (svc *Service) AddFleetAgentToAndroidPolicy(ctx context.Context, enterpriseName string,
|
||||
hostConfigs map[string]android.AgentManagedConfiguration,
|
||||
) error {
|
||||
packageName, sha256Fingerprint, err := getFleetAgentPackageInfo(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if packageName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if packageName := os.Getenv("FLEET_DEV_ANDROID_AGENT_PACKAGE"); packageName != "" {
|
||||
sha256Fingerprint := os.Getenv("FLEET_DEV_ANDROID_AGENT_SIGNING_SHA256")
|
||||
if sha256Fingerprint == "" {
|
||||
return ctxerr.New(ctx, "FLEET_DEV_ANDROID_AGENT_SIGNING_SHA256 must be set when FLEET_DEV_ANDROID_AGENT_PACKAGE is set")
|
||||
for uuid, managedConfig := range hostConfigs {
|
||||
policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, uuid)
|
||||
|
||||
fleetAgentApp, err := buildFleetAgentAppPolicy(packageName, sha256Fingerprint, managedConfig)
|
||||
if err != nil {
|
||||
errs = append(errs, ctxerr.Wrapf(ctx, err, "build fleet agent app policy for host %s", uuid))
|
||||
continue
|
||||
}
|
||||
for uuid, managedConfig := range hostConfigs {
|
||||
policyName := fmt.Sprintf("%s/policies/%s", enterpriseName, uuid)
|
||||
|
||||
// Marshal managed configuration to JSON
|
||||
managedConfigJSON, err := json.Marshal(managedConfig)
|
||||
if err != nil {
|
||||
errs = append(errs, ctxerr.Wrapf(ctx, err, "marshal managed configuration for host %s", uuid))
|
||||
continue
|
||||
}
|
||||
|
||||
fleetAgentApp := &androidmanagement.ApplicationPolicy{
|
||||
PackageName: packageName,
|
||||
InstallType: "FORCE_INSTALLED",
|
||||
DefaultPermissionPolicy: "GRANT",
|
||||
DelegatedScopes: []string{"CERT_INSTALL"},
|
||||
ManagedConfiguration: managedConfigJSON,
|
||||
SigningKeyCerts: []*androidmanagement.ApplicationSigningKeyCert{
|
||||
{
|
||||
SigningKeyCertFingerprintSha256: sha256Fingerprint,
|
||||
},
|
||||
},
|
||||
Roles: []*androidmanagement.Role{
|
||||
{
|
||||
RoleType: "COMPANION_APP",
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = svc.androidAPIClient.EnterprisesPoliciesModifyPolicyApplications(ctx, policyName,
|
||||
[]*androidmanagement.ApplicationPolicy{fleetAgentApp})
|
||||
if err != nil {
|
||||
errs = append(errs, ctxerr.Wrapf(ctx, err, "google api: modify fleet agent application for host %s", uuid))
|
||||
}
|
||||
_, err = svc.androidAPIClient.EnterprisesPoliciesModifyPolicyApplications(ctx, policyName,
|
||||
[]*androidmanagement.ApplicationPolicy{fleetAgentApp})
|
||||
if err != nil {
|
||||
errs = append(errs, ctxerr.Wrapf(ctx, err, "google api: modify fleet agent application for host %s", uuid))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// BuildFleetAgentApplicationPolicy builds the ApplicationPolicy for the Fleet agent for the given host.
|
||||
func (svc *Service) BuildFleetAgentApplicationPolicy(ctx context.Context, hostUUID string) (*androidmanagement.ApplicationPolicy, error) {
|
||||
packageName, sha256Fingerprint, err := getFleetAgentPackageInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if packageName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build the managed configuration for this host
|
||||
managedConfig, err := svc.buildAgentManagedConfig(ctx, hostUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildFleetAgentAppPolicy(packageName, sha256Fingerprint, *managedConfig)
|
||||
}
|
||||
|
||||
// buildAgentManagedConfig builds the AgentManagedConfiguration for the given host.
|
||||
// This includes the server URL, enroll secret, and certificate template IDs.
|
||||
func (svc *Service) buildAgentManagedConfig(ctx context.Context, hostUUID string) (*android.AgentManagedConfiguration, error) {
|
||||
appConfig, err := svc.fleetDS.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get app config")
|
||||
}
|
||||
|
||||
androidHost, err := svc.ds.AndroidHostLiteByHostUUID(ctx, hostUUID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "get android host %s", hostUUID)
|
||||
}
|
||||
|
||||
enrollSecrets, err := svc.fleetDS.GetEnrollSecrets(ctx, androidHost.Host.TeamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "get enroll secrets for team %v", androidHost.Host.TeamID)
|
||||
}
|
||||
if len(enrollSecrets) == 0 {
|
||||
return nil, ctxerr.Errorf(ctx, "no enroll secrets found for team %v", androidHost.Host.TeamID)
|
||||
}
|
||||
|
||||
// Get certificate templates for the host (all templates, regardless of status)
|
||||
certTemplates, err := svc.fleetDS.ListCertificateTemplatesForHosts(ctx, []string{hostUUID})
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "get certificate templates for host %s", hostUUID)
|
||||
}
|
||||
|
||||
var certificateTemplateIDs []android.AgentCertificateTemplate
|
||||
for _, ct := range certTemplates {
|
||||
certificateTemplateIDs = append(certificateTemplateIDs, android.AgentCertificateTemplate{
|
||||
ID: ct.CertificateTemplateID,
|
||||
})
|
||||
}
|
||||
|
||||
return &android.AgentManagedConfiguration{
|
||||
ServerURL: appConfig.ServerSettings.ServerURL,
|
||||
HostUUID: hostUUID,
|
||||
EnrollSecret: enrollSecrets[0].Secret,
|
||||
CertificateTemplateIDs: certificateTemplateIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (svc *Service) EnableAppReportsOnDefaultPolicy(ctx context.Context) error {
|
||||
enterprise, err := svc.ds.GetEnterprise(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -491,6 +491,14 @@ func (v *SoftwareWorker) bulkSetAndroidAppsAvailableForHosts(ctx context.Context
|
||||
return ctxerr.Wrap(ctx, err, "building application policies with config")
|
||||
}
|
||||
|
||||
// Include the Fleet Agent in the app list so it's not removed when we replace the apps.
|
||||
fleetAgentPolicy, err := v.AndroidModule.BuildFleetAgentApplicationPolicy(ctx, uuid)
|
||||
if err != nil {
|
||||
level.Error(v.Log).Log("msg", "failed to build Fleet Agent policy, Fleet Agent may be removed", "host_uuid", uuid, "err", err)
|
||||
} else if fleetAgentPolicy != nil {
|
||||
appPolicies = append(appPolicies, fleetAgentPolicy)
|
||||
}
|
||||
|
||||
err = v.AndroidModule.SetAppsForAndroidPolicy(ctx, enterpriseName, appPolicies, map[string]string{uuid: uuid})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
"github.com/fleetdm/fleet/v4/server/mock"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
kitlog "github.com/go-kit/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/api/androidmanagement/v1"
|
||||
)
|
||||
|
||||
func TestSoftwareWorker(t *testing.T) {
|
||||
@@ -14,3 +23,83 @@ func TestSoftwareWorker(t *testing.T) {
|
||||
mysql.SetTestABMAssets(t, ds, "fleet")
|
||||
|
||||
}
|
||||
|
||||
// mockAndroidModule is a mock implementation of the android.Service interface for testing.
|
||||
type mockAndroidModule struct {
|
||||
android.Service
|
||||
|
||||
buildFleetAgentApplicationPolicyFunc func(ctx context.Context, hostUUID string) (*androidmanagement.ApplicationPolicy, error)
|
||||
setAppsForAndroidPolicyFunc func(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, hostUUIDs map[string]string) error
|
||||
}
|
||||
|
||||
func (m *mockAndroidModule) BuildFleetAgentApplicationPolicy(ctx context.Context, hostUUID string) (*androidmanagement.ApplicationPolicy, error) {
|
||||
if m.buildFleetAgentApplicationPolicyFunc != nil {
|
||||
return m.buildFleetAgentApplicationPolicyFunc(ctx, hostUUID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAndroidModule) SetAppsForAndroidPolicy(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, hostUUIDs map[string]string) error {
|
||||
if m.setAppsForAndroidPolicyFunc != nil {
|
||||
return m.setAppsForAndroidPolicyFunc(ctx, enterpriseName, appPolicies, hostUUIDs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestBulkSetAndroidAppsAvailableForHostsPreservesFleetAgent verifies that the Fleet Agent
|
||||
// is preserved when an Android host is transferred between teams. This prevents the agent
|
||||
// from being uninstalled (and losing state) during team transfers.
|
||||
func TestBulkSetAndroidAppsAvailableForHostsPreservesFleetAgent(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
hostUUID := "test-host-uuid"
|
||||
hostID := uint(1)
|
||||
teamID := uint(2)
|
||||
|
||||
ds := new(mock.Store)
|
||||
ds.AndroidHostLiteByHostUUIDFunc = func(ctx context.Context, uuid string) (*fleet.AndroidHost, error) {
|
||||
return &fleet.AndroidHost{
|
||||
Host: &fleet.Host{
|
||||
ID: hostID,
|
||||
UUID: hostUUID,
|
||||
TeamID: ptr.Uint(teamID),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
ds.GetAndroidAppsInScopeForHostFunc = func(ctx context.Context, hostID uint) ([]string, error) {
|
||||
return []string{"com.example.teamapp"}, nil
|
||||
}
|
||||
ds.BulkGetAndroidAppConfigurationsFunc = func(ctx context.Context, appIDs []string, globalOrTeamID uint) (map[string]json.RawMessage, error) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
|
||||
var capturedAppPolicies []*androidmanagement.ApplicationPolicy
|
||||
androidModule := &mockAndroidModule{
|
||||
buildFleetAgentApplicationPolicyFunc: func(ctx context.Context, hostUUID string) (*androidmanagement.ApplicationPolicy, error) {
|
||||
return &androidmanagement.ApplicationPolicy{
|
||||
PackageName: "com.fleetdm.agent",
|
||||
InstallType: "FORCE_INSTALLED",
|
||||
}, nil
|
||||
},
|
||||
setAppsForAndroidPolicyFunc: func(ctx context.Context, enterpriseName string, appPolicies []*androidmanagement.ApplicationPolicy, hostUUIDs map[string]string) error {
|
||||
capturedAppPolicies = appPolicies
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
worker := &SoftwareWorker{
|
||||
Datastore: ds,
|
||||
AndroidModule: androidModule,
|
||||
Log: kitlog.NewNopLogger(),
|
||||
}
|
||||
|
||||
err := worker.bulkSetAndroidAppsAvailableForHosts(ctx, map[string]uint{hostUUID: hostID}, "enterprises/test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both the team app and Fleet Agent are in the policy
|
||||
require.Len(t, capturedAppPolicies, 2, "expected team app + Fleet Agent")
|
||||
capturedPackageNames := make([]string, len(capturedAppPolicies))
|
||||
for i, policy := range capturedAppPolicies {
|
||||
capturedPackageNames[i] = policy.PackageName
|
||||
}
|
||||
require.ElementsMatch(t, []string{"com.example.teamapp", "com.fleetdm.agent"}, capturedPackageNames)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user