diff --git a/server/mdm/android/service.go b/server/mdm/android/service.go index d34df426aa..8949901449 100644 --- a/server/mdm/android/service.go +++ b/server/mdm/android/service.go @@ -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. diff --git a/server/mdm/android/service/service.go b/server/mdm/android/service/service.go index 420c4078ae..ea2c0bbf19 100644 --- a/server/mdm/android/service/service.go +++ b/server/mdm/android/service/service.go @@ -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 { diff --git a/server/worker/software_worker.go b/server/worker/software_worker.go index 42e6afdb62..e0a4bb1cdb 100644 --- a/server/worker/software_worker.go +++ b/server/worker/software_worker.go @@ -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 { diff --git a/server/worker/software_worker_test.go b/server/worker/software_worker_test.go index f9e1ea5c31..cc9cdeebdd 100644 --- a/server/worker/software_worker_test.go +++ b/server/worker/software_worker_test.go @@ -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) +}