package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/android" common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) func (ds *Datastore) NewAndroidHost(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) { if !host.IsValid() { return nil, ctxerr.New(ctx, "valid Android host is required") } ds.setTimesToNonZero(host) appCfg, err := ds.AppConfig(ctx) if err != nil { return nil, ctxerr.Wrap(ctx, err, "new Android host get app config") } err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { // If the Fleet Android agent already orbit-enrolled this device, a hosts row exists // keyed by uuid = enterpriseSpecificId. Reuse it instead of inserting a duplicate. // (platform = '' covers agents that didn't send platform on orbit enroll.) params := map[string]any{ "node_key": host.NodeKey, "hostname": host.Hostname, "computer_name": host.ComputerName, "platform": host.Platform, "os_version": host.OSVersion, "build": host.Build, "memory": host.Memory, "team_id": host.TeamID, "hardware_serial": host.HardwareSerial, "cpu_type": host.CPUType, "hardware_model": host.HardwareModel, "hardware_vendor": host.HardwareVendor, "detail_updated_at": host.DetailUpdatedAt, "label_updated_at": host.LabelUpdatedAt, "uuid": host.UUID, } // Only look up an existing host when we have a non-empty UUID. An empty UUID // would match every hosts row with uuid='' and falsely dedupe unrelated hosts. // When multiple rows share this uuid (e.g. one orbit-enrolled with // node_key= and one Android with node_key=android/), prefer the // row whose node_key already matches host.NodeKey. Updating that row's node_key // to itself is a no-op and avoids colliding with the UNIQUE idx_host_unique_nodekey // constraint that would fire if we picked the other duplicate and tried to flip // its node_key over to an already-taken value. var ( existingID uint foundHost bool ) if host.UUID != "" { err := sqlx.GetContext(ctx, tx, &existingID, `SELECT id FROM hosts WHERE uuid = ? AND platform IN ('android', '') ORDER BY (node_key = ?) DESC, id LIMIT 1`, host.UUID, host.NodeKey, ) if err != nil && !errors.Is(err, sql.ErrNoRows) { return ctxerr.Wrap(ctx, err, "check for existing orbit-enrolled Android host") } foundHost = err == nil } if !foundHost { // No orbit-enrolled host for this uuid. Insert as usual. // We use node_key as a unique identifier for the host table row. It matches: android/{enterpriseSpecificID}. insertStmt := ` INSERT INTO hosts ( node_key, hostname, computer_name, platform, os_version, build, memory, team_id, hardware_serial, cpu_type, hardware_model, hardware_vendor, detail_updated_at, label_updated_at, uuid ) VALUES ( :node_key, :hostname, :computer_name, :platform, :os_version, :build, :memory, :team_id, :hardware_serial, :cpu_type, :hardware_model, :hardware_vendor, :detail_updated_at, :label_updated_at, :uuid ) ON DUPLICATE KEY UPDATE hostname = VALUES(hostname), computer_name = VALUES(computer_name), platform = VALUES(platform), os_version = VALUES(os_version), build = VALUES(build), memory = VALUES(memory), team_id = VALUES(team_id), hardware_serial = VALUES(hardware_serial), cpu_type = VALUES(cpu_type), hardware_model = VALUES(hardware_model), hardware_vendor = VALUES(hardware_vendor), detail_updated_at = VALUES(detail_updated_at), label_updated_at = VALUES(label_updated_at), uuid = VALUES(uuid) ` result, err := sqlx.NamedExecContext(ctx, tx, insertStmt, params) if err != nil { return ctxerr.Wrap(ctx, err, "new Android host") } id, _ := result.LastInsertId() if id == 0 { // This was an UPDATE, not an INSERT, so we need to get the host ID var hostID uint err := sqlx.GetContext(ctx, tx, &hostID, `SELECT id FROM hosts WHERE node_key = ?`, host.NodeKey) if err != nil { return ctxerr.Wrap(ctx, err, "get host ID after update") } host.Host.ID = hostID } else { host.Host.ID = uint(id) // nolint:gosec } } else { // Orbit-enrolled Android host already exists; update it in place so both // enrollment paths converge on a single hosts row. params["id"] = existingID updateStmt := ` UPDATE hosts SET node_key = :node_key, hostname = :hostname, computer_name = :computer_name, platform = :platform, os_version = :os_version, build = :build, memory = :memory, team_id = :team_id, hardware_serial = :hardware_serial, cpu_type = :cpu_type, hardware_model = :hardware_model, hardware_vendor = :hardware_vendor, detail_updated_at = :detail_updated_at, label_updated_at = :label_updated_at, uuid = :uuid WHERE id = :id` if _, err := sqlx.NamedExecContext(ctx, tx, updateStmt, params); err != nil { return ctxerr.Wrap(ctx, err, "update existing orbit-enrolled Android host") } host.Host.ID = existingID } host.Device.HostID = host.Host.ID err = upsertHostDisplayNames(ctx, tx, *host.Host) if err != nil { return ctxerr.Wrap(ctx, err, "new Android host display name") } err = ds.insertAndroidHostLabelMembershipTx(ctx, tx, host.Host.ID) if err != nil { return ctxerr.Wrap(ctx, err, "new Android host label membership") } // create entry in host_mdm as enrolled (manually), because currently all // android hosts are necessarily MDM-enrolled when created. if err := upsertAndroidHostMDMInfoDB(ctx, tx, appCfg.ServerSettings.ServerURL, companyOwned, true, host.Host.ID); err != nil { return ctxerr.Wrap(ctx, err, "new Android host MDM info") } host.Device, err = ds.CreateDeviceTx(ctx, tx, host.Device) if err != nil { return ctxerr.Wrap(ctx, err, "creating new Android device") } // insert storage data into host_disks table for API consumption // Check != 0 to allow -1 sentinel value for "not supported" to be stored if host.Host.GigsTotalDiskSpace != 0 || host.Host.GigsDiskSpaceAvailable != 0 { err = ds.SetOrUpdateHostDisksSpace(ctx, host.Host.ID, host.Host.GigsDiskSpaceAvailable, host.Host.PercentDiskSpaceAvailable, host.Host.GigsTotalDiskSpace, nil) if err != nil { return ctxerr.Wrap(ctx, err, "setting Android host disk space") } } return nil }) return host, err } // setTimesToNonZero to avoid issues with MySQL. func (ds *Datastore) setTimesToNonZero(host *fleet.AndroidHost) { if host.DetailUpdatedAt.IsZero() { host.DetailUpdatedAt = common_mysql.GetDefaultNonZeroTime() } if host.LabelUpdatedAt.IsZero() { host.LabelUpdatedAt = common_mysql.GetDefaultNonZeroTime() } if host.PolicyUpdatedAt.IsZero() { host.PolicyUpdatedAt = common_mysql.GetDefaultNonZeroTime() } } func (ds *Datastore) UpdateAndroidHost(ctx context.Context, host *fleet.AndroidHost, fromEnroll, companyOwned bool) error { if !host.IsValid() { return ctxerr.New(ctx, "valid Android host is required") } ds.setTimesToNonZero(host) appCfg, err := ds.AppConfig(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "update Android host get app config") } err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { stmt := ` UPDATE hosts SET team_id = :team_id, detail_updated_at = :detail_updated_at, label_updated_at = :label_updated_at, hostname = :hostname, computer_name = :computer_name, platform = :platform, os_version = :os_version, build = :build, memory = :memory, hardware_serial = :hardware_serial, cpu_type = :cpu_type, hardware_model = :hardware_model, hardware_vendor = :hardware_vendor, uuid = :uuid WHERE id = :id ` _, err := sqlx.NamedExecContext(ctx, tx, stmt, map[string]interface{}{ "id": host.Host.ID, "team_id": host.TeamID, "detail_updated_at": host.DetailUpdatedAt, "label_updated_at": host.LabelUpdatedAt, "hostname": host.Hostname, "computer_name": host.ComputerName, "platform": host.Platform, "os_version": host.OSVersion, "build": host.Build, "memory": host.Memory, "hardware_serial": host.HardwareSerial, "cpu_type": host.CPUType, "hardware_model": host.HardwareModel, "hardware_vendor": host.HardwareVendor, "uuid": host.UUID, }) if err != nil { return ctxerr.Wrap(ctx, err, "update Android host") } if fromEnroll { // update host_mdm to set enrolled back to true if err := upsertAndroidHostMDMInfoDB(ctx, tx, appCfg.ServerSettings.ServerURL, companyOwned, true, host.Host.ID); err != nil { return ctxerr.Wrap(ctx, err, "update Android host MDM info") } // Certificate template records for re-enrolling hosts are created by the caller // (Service.updateHost) via CreatePendingCertificateTemplatesForNewHost after this // transaction commits. Doing it here would result in a duplicate call. } err = ds.UpdateDeviceTx(ctx, tx, host.Device) if err != nil { return ctxerr.Wrap(ctx, err, "update Android device") } // update storage data in host_disks table for API consumption // Check != 0 to allow -1 sentinel value for "not supported" to be stored if host.Host.GigsTotalDiskSpace != 0 || host.Host.GigsDiskSpaceAvailable != 0 { err = ds.SetOrUpdateHostDisksSpace(ctx, host.Host.ID, host.Host.GigsDiskSpaceAvailable, host.Host.PercentDiskSpaceAvailable, host.Host.GigsTotalDiskSpace, nil) if err != nil { return ctxerr.Wrap(ctx, err, "updating Android host disk space") } } return nil }) return err } func (ds *Datastore) AndroidHostLite(ctx context.Context, enterpriseSpecificID string) (*fleet.AndroidHost, error) { type liteHost struct { TeamID *uint `db:"team_id"` UUID string `db:"uuid"` *android.Device } stmt := `SELECT h.team_id, h.uuid, ad.id, ad.host_id, ad.device_id, ad.enterprise_specific_id, ad.last_policy_sync_time, ad.applied_policy_id, ad.applied_policy_version FROM android_devices ad JOIN hosts h ON ad.host_id = h.id WHERE ad.enterprise_specific_id = ?` var host liteHost err := sqlx.GetContext(ctx, ds.reader(ctx), &host, stmt, enterpriseSpecificID) switch { case errors.Is(err, sql.ErrNoRows): return nil, common_mysql.NotFound("Android device").WithName(enterpriseSpecificID) case err != nil: return nil, ctxerr.Wrap(ctx, err, "getting device by enterprise specific ID") } result := &fleet.AndroidHost{ Host: &fleet.Host{ ID: host.Device.HostID, TeamID: host.TeamID, UUID: host.UUID, }, Device: host.Device, } result.SetNodeKey(enterpriseSpecificID) return result, nil } func (ds *Datastore) AndroidHostLiteByHostUUID(ctx context.Context, hostUUID string) (*fleet.AndroidHost, error) { type liteHost struct { TeamID *uint `db:"team_id"` *android.Device } stmt := `SELECT h.team_id, ad.id, ad.host_id, ad.device_id, ad.enterprise_specific_id, ad.last_policy_sync_time, ad.applied_policy_id, ad.applied_policy_version FROM android_devices ad JOIN hosts h ON ad.host_id = h.id WHERE h.uuid = ?` var host liteHost switch err := sqlx.GetContext(ctx, ds.reader(ctx), &host, stmt, hostUUID); { case errors.Is(err, sql.ErrNoRows): return nil, common_mysql.NotFound("Android device").WithName(hostUUID) case err != nil: return nil, ctxerr.Wrap(ctx, err, "getting android device by host UUID") } result := &fleet.AndroidHost{ Host: &fleet.Host{ ID: host.Device.HostID, UUID: hostUUID, TeamID: host.TeamID, }, Device: host.Device, } if host.Device.EnterpriseSpecificID != nil { result.SetNodeKey(*host.Device.EnterpriseSpecificID) } return result, nil } func (ds *Datastore) insertAndroidHostLabelMembershipTx(ctx context.Context, tx sqlx.ExtContext, hostID uint) error { // Insert the host in the builtin label memberships, adding them to the "All // Hosts" and "Android" labels. var labels []struct { ID uint `db:"id"` Name string `db:"name"` } err := sqlx.SelectContext(ctx, tx, &labels, `SELECT id, name FROM labels WHERE label_type = 1 AND (name = ? OR name = ?)`, fleet.BuiltinLabelNameAllHosts, fleet.BuiltinLabelNameAndroid) switch { case err != nil: return ctxerr.Wrap(ctx, err, "get builtin labels") case len(labels) != 2: // Builtin labels can get deleted so it is important that we check that // they still exist before we continue. // Note that this is the same behavior as for the iOS/iPadOS host labels. ds.logger.ErrorContext(ctx, fmt.Sprintf("expected 2 builtin labels but got %d", len(labels))) return nil } // We cannot assume IDs on labels, thus we look by name. var allHostsLabelID, androidLabelID uint for _, label := range labels { switch label.Name { case fleet.BuiltinLabelNameAllHosts: allHostsLabelID = label.ID case fleet.BuiltinLabelNameAndroid: androidLabelID = label.ID } } _, err = tx.ExecContext(ctx, ` INSERT INTO label_membership (host_id, label_id) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE host_id = host_id`, hostID, allHostsLabelID, hostID, androidLabelID) if err != nil { return ctxerr.Wrap(ctx, err, "set label membership") } return nil } // BulkSetAndroidHostsUnenrolled sets all android hosts to unenrolled (for when // Android MDM is turned off for all Fleet). func (ds *Datastore) BulkSetAndroidHostsUnenrolled(ctx context.Context) error { _, err := ds.writer(ctx).ExecContext(ctx, ` UPDATE host_mdm SET server_url = '', mdm_id = NULL, enrolled = 0 WHERE host_id IN ( SELECT id FROM hosts WHERE platform = 'android' )`) if err != nil { return ctxerr.Wrap(ctx, err, "set host_mdm to unenrolled for android hosts in bulk") } // Delete all Android custom OS settings for unenrolled hosts. // We do this in one query using a JOIN to avoid doing it one host at a time. _, err = ds.writer(ctx).ExecContext(ctx, `DELETE FROM host_mdm_android_profiles`) if err != nil { return ctxerr.Wrap(ctx, err, "delete Android custom OS settings for unenrolled hosts in bulk") } // Delete all certificate template records for Android hosts so they get re-created on re-enrollment. _, err = ds.writer(ctx).ExecContext(ctx, ` DELETE hct FROM host_certificate_templates hct INNER JOIN hosts h ON h.uuid = hct.host_uuid WHERE h.platform = 'android'`) if err != nil { return ctxerr.Wrap(ctx, err, "delete certificate templates for unenrolled android hosts in bulk") } return nil } // SetAndroidHostUnenrolled sets a single android host to unenrolled in host_mdm and OS settings records // associated with it. If the host is not enrolled, it does nothing and returns false. func (ds *Datastore) SetAndroidHostUnenrolled(ctx context.Context, hostID uint) (bool, error) { var rows int64 err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { result, err := tx.ExecContext(ctx, ` UPDATE host_mdm SET server_url = '', mdm_id = NULL, enrolled = 0 WHERE host_id = ? AND enrolled = 1`, hostID) if err != nil { return ctxerr.Wrap(ctx, err, "set host_mdm to unenrolled for android host") } rows, err = result.RowsAffected() if err != nil { return ctxerr.Wrap(ctx, err, "get rows affected for set host_mdm unenrolled for android host") } if rows > 0 { var hostUUID string err = sqlx.GetContext(ctx, tx, &hostUUID, `SELECT uuid FROM hosts WHERE id = ?`, hostID) if err != nil { return ctxerr.Wrap(ctx, err, "get host uuid") } err = ds.deleteMDMOSCustomSettingsForHost(ctx, tx, hostUUID, "android") if err != nil { return ctxerr.Wrap(ctx, err, "delete Android custom OS settings for unenrolled host") } // Delete certificate template records so they get re-created fresh on re-enrollment. // The device no longer has these certificates after unenrolling. _, err = tx.ExecContext(ctx, `DELETE FROM host_certificate_templates WHERE host_uuid = ?`, hostUUID) if err != nil { return ctxerr.Wrap(ctx, err, "delete certificate templates for unenrolled android host") } } return nil }) if err != nil { return false, err } return rows > 0, nil } func upsertAndroidHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, serverURL string, companyOwned, enrolled bool, hostID uint) error { result, err := tx.ExecContext(ctx, ` INSERT INTO mobile_device_management_solutions (name, server_url) VALUES (?, ?) ON DUPLICATE KEY UPDATE server_url = VALUES(server_url)`, fleet.WellKnownMDMFleet, serverURL) if err != nil { return ctxerr.Wrap(ctx, err, "upsert mdm solution") } var mdmID int64 if insertOnDuplicateDidInsertOrUpdate(result) { mdmID, _ = result.LastInsertId() } else { stmt := `SELECT id FROM mobile_device_management_solutions WHERE name = ? AND server_url = ?` if err := sqlx.GetContext(ctx, tx, &mdmID, stmt, fleet.WellKnownMDMFleet, serverURL); err != nil { return ctxerr.Wrap(ctx, err, "query mdm solution id") } } args := []any{} parts := []string{} args = append(args, enrolled, serverURL, companyOwned, mdmID, false, !companyOwned, hostID) parts = append(parts, "(?, ?, ?, ?, ?, ?, ?)") _, err = tx.ExecContext(ctx, fmt.Sprintf(` INSERT INTO host_mdm (enrolled, server_url, installed_from_dep, mdm_id, is_server, is_personal_enrollment, host_id) VALUES %s ON DUPLICATE KEY UPDATE enrolled = VALUES(enrolled), server_url = VALUES(server_url), mdm_id = VALUES(mdm_id), is_personal_enrollment = VALUES(is_personal_enrollment)`, strings.Join(parts, ",")), args...) return ctxerr.Wrap(ctx, err, "upsert host mdm info") } func (ds *Datastore) NewMDMAndroidConfigProfile(ctx context.Context, cp fleet.MDMAndroidConfigProfile) (*fleet.MDMAndroidConfigProfile, error) { profileUUID := fleet.MDMAndroidProfileUUIDPrefix + uuid.New().String() insertProfileStmt := ` INSERT INTO mdm_android_configuration_profiles (profile_uuid, team_id, name, raw_json, uploaded_at) (SELECT ?, ?, ?, ?, CURRENT_TIMESTAMP() FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM mdm_apple_configuration_profiles WHERE name = ? AND team_id = ? ) AND NOT EXISTS ( SELECT 1 FROM mdm_apple_declarations WHERE name = ? AND team_id = ? ) AND NOT EXISTS ( SELECT 1 FROM mdm_windows_configuration_profiles WHERE name = ? AND team_id = ? ) )` var teamID uint if cp.TeamID != nil { teamID = *cp.TeamID } err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { res, err := tx.ExecContext(ctx, insertProfileStmt, profileUUID, teamID, cp.Name, cp.RawJSON, cp.Name, teamID, cp.Name, teamID, cp.Name, teamID) if err != nil { switch { case IsDuplicate(err): return &existsError{ ResourceType: "MDMAndroidConfigProfile.Name", Identifier: cp.Name, TeamID: cp.TeamID, } default: return ctxerr.Wrap(ctx, err, "creating new android mdm config profile") } } aff, _ := res.RowsAffected() if aff == 0 { return &existsError{ ResourceType: "MDMAndroidConfigProfile.Name", Identifier: cp.Name, TeamID: cp.TeamID, } } labels := make([]fleet.ConfigurationProfileLabel, 0, len(cp.LabelsIncludeAll)+len(cp.LabelsIncludeAny)+len(cp.LabelsExcludeAny)) for i := range cp.LabelsIncludeAll { cp.LabelsIncludeAll[i].ProfileUUID = profileUUID cp.LabelsIncludeAll[i].RequireAll = true cp.LabelsIncludeAll[i].Exclude = false labels = append(labels, cp.LabelsIncludeAll[i]) } for i := range cp.LabelsIncludeAny { cp.LabelsIncludeAny[i].ProfileUUID = profileUUID cp.LabelsIncludeAny[i].RequireAll = false cp.LabelsIncludeAny[i].Exclude = false labels = append(labels, cp.LabelsIncludeAny[i]) } for i := range cp.LabelsExcludeAny { cp.LabelsExcludeAny[i].ProfileUUID = profileUUID cp.LabelsExcludeAny[i].RequireAll = false cp.LabelsExcludeAny[i].Exclude = true labels = append(labels, cp.LabelsExcludeAny[i]) } var profsWithoutLabel []string if len(labels) == 0 { profsWithoutLabel = append(profsWithoutLabel, profileUUID) } if _, err := batchSetProfileLabelAssociationsDB(ctx, tx, labels, profsWithoutLabel, "android"); err != nil { return ctxerr.Wrap(ctx, err, "inserting android profile label associations") } return nil }) if err != nil { return nil, err } return &fleet.MDMAndroidConfigProfile{ ProfileUUID: profileUUID, Name: cp.Name, RawJSON: cp.RawJSON, TeamID: cp.TeamID, }, nil } func (ds *Datastore) GetMDMAndroidConfigProfile(ctx context.Context, profileUUID string) (*fleet.MDMAndroidConfigProfile, error) { stmt := `SELECT profile_uuid, team_id, name, raw_json, auto_increment, created_at, uploaded_at FROM mdm_android_configuration_profiles WHERE profile_uuid = ?` var profile fleet.MDMAndroidConfigProfile err := sqlx.GetContext(ctx, ds.reader(ctx), &profile, stmt, profileUUID) if err != nil { if err == sql.ErrNoRows { return nil, notFound("MDMAndroidConfigProfile").WithName(profileUUID) } return nil, ctxerr.Wrap(ctx, err, "getting android mdm config profile") } labels, err := ds.listProfileLabelsForProfiles(ctx, nil, nil, []string{profile.ProfileUUID}, nil) if err != nil { return nil, err } for _, lbl := range labels { switch { case lbl.Exclude && lbl.RequireAll: // this should never happen so log it for debugging ds.logger.WarnContext(ctx, "unsupported profile label: cannot be both exclude and require all. Label will be ignored.", "profile_uuid", lbl.ProfileUUID, "label_name", lbl.LabelName, ) case lbl.Exclude && !lbl.RequireAll: profile.LabelsExcludeAny = append(profile.LabelsExcludeAny, lbl) case !lbl.Exclude && !lbl.RequireAll: profile.LabelsIncludeAny = append(profile.LabelsIncludeAny, lbl) default: // default include all profile.LabelsIncludeAll = append(profile.LabelsIncludeAll, lbl) } } return &profile, nil } func (ds *Datastore) DeleteMDMAndroidConfigProfile(ctx context.Context, profileUUID string) error { return ds.withTx(ctx, func(tx sqlx.ExtContext) error { stmt := `DELETE FROM mdm_android_configuration_profiles WHERE profile_uuid = ?` res, err := tx.ExecContext(ctx, stmt, profileUUID) if err != nil { return ctxerr.Wrap(ctx, err, "deleting android mdm config profile") } deleted, _ := res.RowsAffected() if deleted != 1 { return ctxerr.Wrap(ctx, notFound("MDMAndroidConfigProfile").WithName(profileUUID)) } if err := cancelAndroidHostInstallsForDeletedMDMProfiles(ctx, tx, []string{profileUUID}); err != nil { return err } return nil }) } func (ds *Datastore) GetMDMAndroidProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) { stmt := ` SELECT COUNT(id) AS count, %s AS status FROM hosts h INNER JOIN host_mdm hmdm ON h.id=hmdm.host_id %s WHERE platform = 'android' AND hmdm.enrolled = 1 AND %s GROUP BY status HAVING status IS NOT NULL` teamFilter := "team_id IS NULL" if teamID != nil && *teamID > 0 { teamFilter = fmt.Sprintf("team_id = %d", *teamID) } stmt = fmt.Sprintf(stmt, sqlCaseMDMAndroidStatus(), sqlJoinMDMAndroidProfilesStatus(), teamFilter) var dest []struct { Count uint `db:"count"` Status string `db:"status"` } if err := sqlx.SelectContext(ctx, ds.reader(ctx), &dest, stmt); err != nil { return nil, err } byStatus := make(map[string]uint) for _, s := range dest { if _, ok := byStatus[s.Status]; ok { return nil, fmt.Errorf("duplicate status %s from android profiles summary", s.Status) } byStatus[s.Status] = s.Count } var res fleet.MDMProfilesSummary for s, c := range byStatus { switch fleet.MDMDeliveryStatus(s) { case fleet.MDMDeliveryFailed: res.Failed = c case fleet.MDMDeliveryPending: res.Pending = c case fleet.MDMDeliveryVerifying: res.Verifying = c case fleet.MDMDeliveryVerified: res.Verified = c default: return nil, fmt.Errorf("unknown status %s", s) } } return &res, nil } // sqlJoinMDMAndroidProfilesStatus returns a SQL snippet that can be used to join a table derived from // host_mdm_android_profiles and host_certificate_templates (grouped by host_uuid and status) and the hosts table. // For each host_uuid, it derives a boolean value for each status category. The value will be 1 if the host has any // profile or certificate template in the given status category. The snippet assumes the hosts table to be aliased as 'h'. func sqlJoinMDMAndroidProfilesStatus() string { // NOTE: To make this snippet reusable, we're not using sqlx.Named here because it would // complicate usage in other queries (e.g., list hosts). var ( failed = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryFailed)) pending = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryPending)) verifying = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerifying)) verified = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerified)) install = fmt.Sprintf("'%s'", string(fleet.MDMOperationTypeInstall)) // Certificate template statuses certPending = fmt.Sprintf("'%s'", string(fleet.CertificateTemplatePending)) certDelivering = fmt.Sprintf("'%s'", string(fleet.CertificateTemplateDelivering)) certDelivered = fmt.Sprintf("'%s'", string(fleet.CertificateTemplateDelivered)) certFailed = fmt.Sprintf("'%s'", string(fleet.CertificateTemplateFailed)) certVerified = fmt.Sprintf("'%s'", string(fleet.CertificateTemplateVerified)) ) return ` LEFT JOIN ( -- profile and certificate template statuses grouped by host uuid -- boolean value will be 1 if host has any profile or certificate template with the given status SELECT host_uuid, MAX(prof_pending) AS android_prof_pending, MAX(prof_failed) AS android_prof_failed, MAX(prof_verifying) AS android_prof_verifying, MAX(prof_verified) AS android_prof_verified FROM ( -- Android profiles SELECT host_uuid, IF(status IS NULL OR status = ` + pending + `, 1, 0) AS prof_pending, IF(status = ` + failed + `, 1, 0) AS prof_failed, IF(status = ` + verifying + ` AND operation_type = ` + install + `, 1, 0) AS prof_verifying, IF(status = ` + verified + ` AND operation_type = ` + install + `, 1, 0) AS prof_verified FROM host_mdm_android_profiles UNION ALL -- Certificate templates (delivering and delivered count as pending) SELECT host_uuid, IF(status IS NULL OR status IN (` + certPending + `, ` + certDelivering + `, ` + certDelivered + `), 1, 0) AS prof_pending, IF(status = ` + certFailed + `, 1, 0) AS prof_failed, 0 AS prof_verifying, IF(status = ` + certVerified + ` AND operation_type = ` + install + `, 1, 0) AS prof_verified FROM host_certificate_templates ) combined GROUP BY host_uuid) hmgp ON h.uuid = hmgp.host_uuid ` } // sqlCaseMDMAndroidStatus returns a SQL snippet that can be used to determine the status of an Android host // based on the status of its profiles. It should be used in conjunction with sqlJoinMDMAndroidProfilesStatus // It assumes the hosts table to be aliased as 'h' func sqlCaseMDMAndroidStatus() string { // NOTE: To make this snippet reusable, we're not using sqlx.Named here because it would // complicate usage in other queries (e.g., list hosts). var ( failed = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryFailed)) pending = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryPending)) verifying = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerifying)) verified = fmt.Sprintf("'%s'", string(fleet.MDMDeliveryVerified)) ) return ` CASE WHEN (android_prof_failed) THEN ` + failed + ` WHEN (android_prof_pending) THEN ` + pending + ` WHEN (android_prof_verifying) THEN ` + verifying + ` WHEN (android_prof_verified) THEN ` + verified + ` END ` } func (ds *Datastore) NewAndroidPolicyRequest(ctx context.Context, req *android.MDMAndroidPolicyRequest) error { const stmt = ` INSERT INTO android_policy_requests (request_uuid, request_name, policy_id, payload, status_code, error_details, applied_policy_version, policy_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ` if req.RequestUUID == "" { req.RequestUUID = uuid.NewString() } _, err := ds.writer(ctx).ExecContext(ctx, stmt, req.RequestUUID, req.RequestName, req.PolicyID, req.Payload, req.StatusCode, req.ErrorDetails, req.AppliedPolicyVersion, req.PolicyVersion, ) return ctxerr.Wrap(ctx, err, "inserting android policy request") } func (ds *Datastore) GetAndroidPolicyRequestByUUID(ctx context.Context, requestUUID string) (*android.MDMAndroidPolicyRequest, error) { const stmt = ` SELECT request_uuid, request_name, policy_id, payload, status_code, error_details, applied_policy_version, policy_version FROM android_policy_requests WHERE request_uuid = ? ` req := android.MDMAndroidPolicyRequest{} err := sqlx.GetContext(ctx, ds.reader(ctx), &req, stmt, requestUUID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, common_mysql.NotFound("AndroidPolicyRequest").WithName(requestUUID) } return nil, ctxerr.Wrap(ctx, err, "getting android policy request") } return &req, nil } const androidApplicableProfilesQuery = ` -- non label-based profiles SELECT macp.profile_uuid, macp.name, h.uuid as host_uuid, h.id as host_id, 0 as count_profile_labels, 0 as count_non_broken_labels, 0 as count_host_labels, 0 as count_host_updated_after_labels FROM mdm_android_configuration_profiles macp JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) JOIN android_devices ad ON ad.host_id = h.id WHERE h.platform = 'android' AND NOT EXISTS ( SELECT 1 FROM mdm_configuration_profile_labels mcpl WHERE mcpl.android_profile_uuid = macp.profile_uuid ) AND ( %s ) UNION -- label-based profiles where the host is a member of all the labels (include-all). -- by design, "include" labels cannot match if they are broken (the host cannot be -- a member of a deleted label). SELECT macp.profile_uuid, macp.name, h.uuid as host_uuid, h.id as host_id, COUNT(*) as count_profile_labels, COUNT(mcpl.label_id) as count_non_broken_labels, COUNT(lm.label_id) as count_host_labels, 0 as count_host_updated_after_labels FROM mdm_android_configuration_profiles macp JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) JOIN android_devices ad ON ad.host_id = h.id JOIN mdm_configuration_profile_labels mcpl ON mcpl.android_profile_uuid = macp.profile_uuid AND mcpl.exclude = 0 AND mcpl.require_all = 1 LEFT OUTER JOIN label_membership lm ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'android' AND ( %s ) GROUP BY macp.profile_uuid, macp.name, h.uuid, h.id HAVING count_profile_labels > 0 AND count_host_labels = count_profile_labels UNION -- label-based entities where the host is NOT a member of any of the labels (exclude-any). -- explicitly ignore profiles with broken excluded labels so that they are never applied, -- and ignore profiles that depend on labels created _after_ the label_updated_at timestamp -- of the host (because we don't have results for that label yet, the host may or may not be -- a member). SELECT macp.profile_uuid, macp.name, h.uuid as host_uuid, h.id as host_id, COUNT(*) as count_profile_labels, COUNT(mcpl.label_id) as count_non_broken_labels, COUNT(lm.label_id) as count_host_labels, -- this helps avoid the case where the host is not a member of a label -- just because it hasn't reported results for that label yet. But we -- only need consider this for dynamic labels - manual(type=1) can be -- considered at any time SUM( CASE WHEN lbl.label_membership_type <> 1 AND lbl.created_at IS NOT NULL AND h.label_updated_at >= lbl.created_at THEN 1 WHEN lbl.label_membership_type = 1 AND lbl.created_at IS NOT NULL THEN 1 ELSE 0 END) as count_host_updated_after_labels FROM mdm_android_configuration_profiles macp JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) JOIN android_devices ad ON ad.host_id = h.id JOIN mdm_configuration_profile_labels mcpl ON mcpl.android_profile_uuid = macp.profile_uuid AND mcpl.exclude = 1 AND mcpl.require_all = 0 LEFT OUTER JOIN labels lbl ON lbl.id = mcpl.label_id LEFT OUTER JOIN label_membership lm ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'android' AND ( %s ) GROUP BY macp.profile_uuid, macp.name, h.uuid, h.id HAVING -- considers only the profiles with labels, without any broken label, with results reported after all labels were -- created and with the host not in any label count_profile_labels > 0 AND count_profile_labels = count_non_broken_labels AND count_profile_labels = count_host_updated_after_labels AND count_host_labels = 0 UNION -- label-based profiles where the host is a member of any of the labels (include-any). -- by design, "include" labels cannot match if they are broken (the host cannot be -- a member of a deleted label). SELECT macp.profile_uuid, macp.name, h.uuid as host_uuid, h.id as host_id, COUNT(*) as count_profile_labels, COUNT(mcpl.label_id) as count_non_broken_labels, COUNT(lm.label_id) as count_host_labels, 0 as count_host_updated_after_labels FROM mdm_android_configuration_profiles macp JOIN hosts h ON h.team_id = macp.team_id OR (h.team_id IS NULL AND macp.team_id = 0) JOIN android_devices ad ON ad.host_id = h.id JOIN mdm_configuration_profile_labels mcpl ON mcpl.android_profile_uuid = macp.profile_uuid AND mcpl.exclude = 0 AND mcpl.require_all = 0 LEFT OUTER JOIN label_membership lm ON lm.label_id = mcpl.label_id AND lm.host_id = h.id WHERE h.platform = 'android' AND ( %s ) GROUP BY macp.profile_uuid, macp.name, h.uuid, h.id HAVING count_profile_labels > 0 AND count_host_labels >= 1 ` // ListMDMAndroidProfilesToSend is the android platform equivalent to // ListMDMAppleProfilesToInstall/Remove and // ListMDMWindowsProfilesToInstall/Remove. It plays a similar role but is quite // different in implementation since Android profiles are fundamentally // different from those platforms - the "configuration profiles" are just // fragments of the full (and unique per host) "Android policy" to apply to a // host, so as soon as there is a change to the set of profiles to apply to a // host, the full list of applicable profiles are needed to generate the // resulting policy. // // That is, profiles are not applied individually, but as a merged set. For // that reason, there is no "to install" and "to remove", as removing a profile // is just not including it in the merged set of profiles to apply. // // So with that in mind, what this method does is return the full set of // applicable profiles for each host that has a change in that set of // applicable profiles, so that it needs to be sent again, along with the list // of of previously-applied profiles that need to be removed (which is just // "not merging them" in the Android policy, but until this policy is fully // applied on the host we need to mark those profiles as pending removal). // // See https://github.com/fleetdm/fleet/issues/32032#issuecomment-3229548389 // for more details on the rationale of that approach. func (ds *Datastore) ListMDMAndroidProfilesToSend(ctx context.Context) ([]*fleet.MDMAndroidProfilePayload, []*fleet.MDMAndroidProfilePayload, error) { var toApplyProfiles, toRemoveProfiles []*fleet.MDMAndroidProfilePayload err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { hostsWithChangesStmt := fmt.Sprintf(` WITH ds AS ( %s ) SELECT DISTINCT ds.host_uuid FROM ds INNER JOIN android_devices ad ON ad.host_id = ds.host_id INNER JOIN host_mdm hmdm ON ad.host_id=hmdm.host_id LEFT OUTER JOIN host_mdm_android_profiles hmap ON hmap.host_uuid = ds.host_uuid AND hmap.profile_uuid = ds.profile_uuid WHERE -- host is enrolled hmdm.enrolled = 1 AND ( -- at least one profile is missing from host_mdm_android_profiles hmap.host_uuid IS NULL OR -- profile was never sent or was updated after sent -- TODO(ap): need to make sure we set it to NULL when profile is updated ( hmap.included_in_policy_version IS NULL AND COALESCE(hmap.status, '') <> ? ) OR hmap.status IS NULL OR -- profile was sent in older policy version than currently applied (hmap.included_in_policy_version IS NOT NULL AND ad.applied_policy_id = ds.host_uuid AND hmap.included_in_policy_version < COALESCE(ad.applied_policy_version, 0)) ) UNION SELECT DISTINCT hmap.host_uuid FROM host_mdm_android_profiles hmap INNER JOIN hosts h ON h.uuid = hmap.host_uuid INNER JOIN android_devices ad ON ad.host_id = h.id INNER JOIN host_mdm hmdm ON hmdm.host_id = h.id LEFT OUTER JOIN ds ON hmap.host_uuid = ds.host_uuid AND hmap.profile_uuid = ds.profile_uuid WHERE -- at least one profile was removed from the set of applicable profiles hmdm.enrolled = 1 AND ds.host_uuid IS NULL AND -- and it is not in pending remove status (in which case it was processed) ( hmap.operation_type != ? OR COALESCE(hmap.status, '') <> ? ) `, fmt.Sprintf(androidApplicableProfilesQuery, "TRUE", "TRUE", "TRUE", "TRUE")) // NOTE: we explicitly don't "ignore" profiles to remove based on broken labels, // because of how Android profiles are applied vs other platforms (ignoring // a broken profile would effectively remove it anyway, and including it so // we don't remove it could cause errors applying the rest of the policy if // the setting is invalid, which is worse and contrary to the "broken profiles // are ignored" general behavior). // // So unlike for Apple/Windows, for Android we effectively remove broken // profiles from the host. // // see https://github.com/fleetdm/fleet/issues/25557#issuecomment-3246496873 var hostUUIDs []string if err := sqlx.SelectContext(ctx, tx, &hostUUIDs, hostsWithChangesStmt, fleet.MDMDeliveryFailed, fleet.MDMOperationTypeRemove, fleet.MDMDeliveryPending); err != nil { return ctxerr.Wrap(ctx, err, "list android hosts with profile changes") } if len(hostUUIDs) == 0 { return nil } // retrieve all the applicable profiles for those hosts listToInstallProfilesStmt := fmt.Sprintf(` SELECT ds.profile_uuid, ds.name as profile_name, ds.host_uuid, COALESCE(hmap.request_fail_count, 0) as request_fail_count, COALESCE(apr.error_details, '') as last_error_details FROM ( %s ) ds LEFT OUTER JOIN host_mdm_android_profiles hmap ON hmap.host_uuid = ds.host_uuid AND hmap.profile_uuid = ds.profile_uuid LEFT OUTER JOIN android_policy_requests apr ON apr.request_uuid = hmap.policy_request_uuid `, fmt.Sprintf(androidApplicableProfilesQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) query, args, err := sqlx.In(listToInstallProfilesStmt, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs) if err != nil { return ctxerr.Wrap(ctx, err, "building list android host applicable profiles query") } if err := sqlx.SelectContext(ctx, tx, &toApplyProfiles, query, args...); err != nil { return ctxerr.Wrap(ctx, err, "list android host applicable profiles") } listToRemoveProfilesStmt := fmt.Sprintf(` SELECT hmap.profile_uuid, hmap.profile_name, hmap.host_uuid, hmap.request_fail_count, COALESCE(apr.error_details, '') as last_error_details FROM ( %s ) ds RIGHT OUTER JOIN host_mdm_android_profiles hmap ON hmap.host_uuid = ds.host_uuid AND hmap.profile_uuid = ds.profile_uuid LEFT OUTER JOIN android_policy_requests apr ON apr.request_uuid = hmap.policy_request_uuid WHERE hmap.host_uuid IN (?) AND ds.host_uuid IS NULL `, fmt.Sprintf(androidApplicableProfilesQuery, "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)", "h.uuid IN (?)")) query, args, err = sqlx.In(listToRemoveProfilesStmt, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs, hostUUIDs) if err != nil { return ctxerr.Wrap(ctx, err, "building list android host to remove profiles query") } if err := sqlx.SelectContext(ctx, tx, &toRemoveProfiles, query, args...); err != nil { return ctxerr.Wrap(ctx, err, "list android host to remove profiles") } return nil }) return toApplyProfiles, toRemoveProfiles, err } func (ds *Datastore) GetMDMAndroidProfilesContents(ctx context.Context, uuids []string) (map[string]json.RawMessage, error) { if len(uuids) == 0 { return nil, nil } stmt := ` SELECT profile_uuid, raw_json FROM mdm_android_configuration_profiles WHERE profile_uuid IN (?) ` query, args, err := sqlx.In(stmt, uuids) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building get android profiles contents query") } rows, err := ds.reader(ctx).QueryxContext(ctx, query, args...) if err != nil { return nil, ctxerr.Wrap(ctx, err, "querying android profiles contents") } defer rows.Close() results := make(map[string]json.RawMessage, len(uuids)) for rows.Next() { var ( uid string rawJSON json.RawMessage ) if err := rows.Scan(&uid, &rawJSON); err != nil { return nil, ctxerr.Wrap(ctx, err, "scanning android profile content") } results[uid] = rawJSON } if err := rows.Err(); err != nil { return nil, ctxerr.Wrap(ctx, err, "iterating android profiles contents") } return results, nil } func (ds *Datastore) BulkUpsertMDMAndroidHostProfiles(ctx context.Context, payload []*fleet.MDMAndroidProfilePayload) error { if len(payload) == 0 { return nil } executeUpsertBatch := func(valuePart string, args []any) error { stmt := fmt.Sprintf(` INSERT INTO host_mdm_android_profiles ( host_uuid, status, operation_type, detail, profile_uuid, profile_name, policy_request_uuid, device_request_uuid, request_fail_count, included_in_policy_version, can_reverify ) VALUES %s ON DUPLICATE KEY UPDATE status = VALUES(status), operation_type = VALUES(operation_type), detail = VALUES(detail), profile_name = VALUES(profile_name), policy_request_uuid = VALUES(policy_request_uuid), device_request_uuid = VALUES(device_request_uuid), request_fail_count = VALUES(request_fail_count), included_in_policy_version = VALUES(included_in_policy_version), can_reverify = VALUES(can_reverify) `, strings.TrimSuffix(valuePart, ","), ) // Taken from BulkUpsertMDMAppleHostProfiles: We need to run with retry // due to deadlocks. The INSERT/ON DUPLICATE KEY UPDATE pattern is prone // to deadlocks when multiple threads are modifying nearby rows. That's // because this statement uses gap locks. When two transactions acquire // the same gap lock, they may deadlock. Two simultaneous transactions // may happen when cron job runs and the user is updating via the UI at // the same time. err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { _, err := tx.ExecContext(ctx, stmt, args...) return err }) return err } generateValueArgs := func(p *fleet.MDMAndroidProfilePayload) (string, []any) { valuePart := "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)," args := []any{ p.HostUUID, p.Status, p.OperationType, p.Detail, p.ProfileUUID, p.ProfileName, p.PolicyRequestUUID, p.DeviceRequestUUID, p.RequestFailCount, p.IncludedInPolicyVersion, p.CanReverify, } return valuePart, args } const defaultBatchSize = 1000 // number of parameters is this times number of placeholders batchSize := defaultBatchSize if ds.testUpsertMDMDesiredProfilesBatchSize > 0 { batchSize = ds.testUpsertMDMDesiredProfilesBatchSize } if err := batchProcessDB(payload, batchSize, generateValueArgs, executeUpsertBatch); err != nil { return err } return nil } func (ds *Datastore) GetHostMDMAndroidProfiles(ctx context.Context, hostUUID string) ([]fleet.HostMDMAndroidProfile, error) { // TODO(AP): confirm whether we should be hiding any profile names for Android like we do // for other platforms stmt := fmt.Sprintf(` SELECT profile_uuid, profile_name AS name, -- internally, a NULL status implies that the cron needs to pick up -- this profile, for the user that difference doesn't exist, the -- profile is effectively pending. This is consistent with all our -- aggregation functions. COALESCE(status, '%s') AS status, COALESCE(operation_type, '') AS operation_type, COALESCE(detail, '') AS detail FROM host_mdm_android_profiles WHERE host_uuid = ? AND NOT (operation_type = '%s' AND COALESCE(status, '%s') IN('%s', '%s'))`, fleet.MDMDeliveryPending, fleet.MDMOperationTypeRemove, fleet.MDMDeliveryPending, fleet.MDMDeliveryVerifying, fleet.MDMDeliveryVerified, ) args := []interface{}{hostUUID} var profiles []fleet.HostMDMAndroidProfile if err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, stmt, args...); err != nil { return nil, err } return profiles, nil } func (ds *Datastore) ListHostMDMAndroidProfilesPendingOrFailedInstallWithVersion(ctx context.Context, hostUUID string, policyVersion int64) ([]*fleet.MDMAndroidProfilePayload, error) { stmt := ` SELECT profile_uuid, host_uuid, status, operation_type, detail, profile_name, policy_request_uuid, device_request_uuid, request_fail_count, included_in_policy_version FROM host_mdm_android_profiles WHERE host_uuid = ? AND included_in_policy_version <= ? AND operation_type = ? AND (status = 'pending' OR (status = 'failed' AND can_reverify = true)) ` var profiles []*fleet.MDMAndroidProfilePayload err := sqlx.SelectContext(ctx, ds.reader(ctx), &profiles, stmt, hostUUID, policyVersion, fleet.MDMOperationTypeInstall) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing host MDM Android profiles pending or failed install") } return profiles, nil } func (ds *Datastore) BulkDeleteMDMAndroidHostProfiles(ctx context.Context, hostUUID string, policyVersionId int64) error { stmt := ` DELETE FROM host_mdm_android_profiles WHERE host_uuid = ? AND included_in_policy_version <= ? AND operation_type = ? AND status IN (?) ` stmt, args, err := sqlx.In(stmt, hostUUID, policyVersionId, fleet.MDMOperationTypeRemove, []fleet.MDMDeliveryStatus{fleet.MDMDeliveryPending, fleet.MDMDeliveryFailed}) if err != nil { return ctxerr.Wrap(ctx, err, "building query to delete host MDM Android profiles") } _, err = ds.writer(ctx).ExecContext(ctx, stmt, args...) if err != nil { return ctxerr.Wrap(ctx, err, "deleting host MDM Android profiles") } return nil } func (ds *Datastore) deleteAllAndroidProfiles(ctx context.Context, tx sqlx.ExtContext, tmID *uint) (int, error) { var teamID uint if tmID != nil { teamID = *tmID } res, err := tx.ExecContext(ctx, `DELETE FROM mdm_android_configuration_profiles WHERE team_id = ?`, teamID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "deleting all android profiles for team") } rows, err := res.RowsAffected() if err != nil { return 0, ctxerr.Wrap(ctx, err, "getting rows affected when deleting all android profiles for team") } return int(rows), nil } func (ds *Datastore) batchSetMDMAndroidProfiles( ctx context.Context, tx sqlx.ExtContext, tmID *uint, profiles []*fleet.MDMAndroidConfigProfile, ) (updatedDB bool, err error) { if len(profiles) == 0 { rowsAffected, err := ds.deleteAllAndroidProfiles(ctx, tx, tmID) if err != nil { return false, err } return rowsAffected > 0, nil } // Select and delete profiles that are not incoming so we can cancel the install. const loadToBeDeletedProfilesNotInList = ` SELECT profile_uuid FROM mdm_android_configuration_profiles WHERE team_id = ? AND name NOT IN (?) ` // use a profile team id of 0 if no-team var profileTeamID uint if tmID != nil { profileTeamID = *tmID } // Create list of names from profiles incomingNames := make([]string, len(profiles)) incomingUUIDS := make([]string, len(profiles)) for i, p := range profiles { incomingNames[i] = p.Name incomingUUIDS[i] = p.ProfileUUID } var ( stmt string args []interface{} deletedProfileUUIDs []string ) stmt, args, err = sqlx.In(loadToBeDeletedProfilesNotInList, profileTeamID, incomingNames) if err != nil { return false, ctxerr.Wrap(ctx, err, "build query to load profiles to be deleted") } if err := sqlx.SelectContext(ctx, tx, &deletedProfileUUIDs, stmt, args...); err != nil { return false, ctxerr.Wrap(ctx, err, "load profiles to be deleted") } // Delete profiles that are not incoming const deleteProfilesNotInList = ` DELETE FROM mdm_android_configuration_profiles WHERE profile_uuid IN (?) ` if len(deletedProfileUUIDs) > 0 { var result sql.Result stmt, args, err = sqlx.In(deleteProfilesNotInList, deletedProfileUUIDs) if err != nil { return false, ctxerr.Wrap(ctx, err, "build query to delete profiles") } if result, err = tx.ExecContext(ctx, stmt, args...); err != nil { return false, ctxerr.Wrap(ctx, err, "delete profiles") } if result != nil { rows, _ := result.RowsAffected() updatedDB = rows > 0 } if err := cancelAndroidHostInstallsForDeletedMDMProfiles(ctx, tx, deletedProfileUUIDs); err != nil { return false, ctxerr.Wrap(ctx, err, "cancel android host installs for deleted profiles") } } // Insert or update incoming profiles const insertNewOrEditedProfile = ` INSERT INTO mdm_android_configuration_profiles ( profile_uuid, team_id, name, raw_json, uploaded_at ) VALUES (CONCAT('` + fleet.MDMAndroidProfileUUIDPrefix + `', CONVERT(uuid() USING utf8mb4)), ?, ?, ?, CURRENT_TIMESTAMP(6)) ON DUPLICATE KEY UPDATE raw_json = VALUES(raw_json), name = VALUES(name), uploaded_at = IF(raw_json = VALUES(raw_json) AND name = VALUES(name), uploaded_at, CURRENT_TIMESTAMP(6)) ` for _, p := range profiles { var res sql.Result if res, err = tx.ExecContext(ctx, insertNewOrEditedProfile, profileTeamID, p.Name, p.RawJSON); err != nil { return false, ctxerr.Wrap(ctx, err, "insert or update profile") } if insertOnDuplicateDidInsertOrUpdate(res) { updatedDB = true } } const updateIncludedInPolicyVersionStmt = ` UPDATE host_mdm_android_profiles SET included_in_policy_version = NULL, detail = NULL, policy_request_uuid = NULL, device_request_uuid = NULL, status = NULL, request_fail_count = 0 WHERE profile_uuid IN (SELECT profile_uuid FROM mdm_android_configuration_profiles WHERE name IN (?)) ` stmt, args, err = sqlx.In(updateIncludedInPolicyVersionStmt, incomingNames) if err != nil { return false, ctxerr.Wrap(ctx, err, "build query to update included in policy version") } if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { return false, ctxerr.Wrap(ctx, err, "update included in policy version") } var mappedIncomingProfiles []*BatchSetAssociationIncomingProfile for _, p := range profiles { mappedIncomingProfiles = append(mappedIncomingProfiles, &BatchSetAssociationIncomingProfile{ Name: p.Name, ProfileUUID: p.ProfileUUID, LabelsIncludeAll: p.LabelsIncludeAll, LabelsIncludeAny: p.LabelsIncludeAny, LabelsExcludeAny: p.LabelsExcludeAny, }) } didUpdateLabels, err := ds.batchSetLabelAndVariableAssociations(ctx, tx, "android", tmID, mappedIncomingProfiles, nil) if err != nil { return false, ctxerr.Wrap(ctx, err, "setting labels and variable associations") } return updatedDB || didUpdateLabels, nil } func cancelAndroidHostInstallsForDeletedMDMProfiles(ctx context.Context, tx sqlx.ExtContext, profileUUIDs []string) error { // For Android profiles, we can safely delete the rows where STATUS is null and operation is install. // For any other profiles, we update the operating to remove and set the STATUS to null // to let it be picked up by the reconciler. if len(profileUUIDs) == 0 { return nil } const delStmt = ` DELETE FROM host_mdm_android_profiles WHERE profile_uuid IN (?) AND status IS NULL AND operation_type = ?` stmt, args, err := sqlx.In(delStmt, profileUUIDs, fleet.MDMOperationTypeInstall) if err != nil { return ctxerr.Wrap(ctx, err, "build query to cancel android host installs for deleted profiles") } if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { return ctxerr.Wrap(ctx, err, "cancel android host installs for deleted profiles") } const updateStmt = ` UPDATE host_mdm_android_profiles SET status = NULL, operation_type = ? WHERE profile_uuid IN (?) AND status IS NOT NULL AND operation_type = ?` stmt, args, err = sqlx.In(updateStmt, fleet.MDMOperationTypeRemove, profileUUIDs, fleet.MDMOperationTypeInstall) if err != nil { return ctxerr.Wrap(ctx, err, "build query to update android host installs for deleted profiles") } if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { return ctxerr.Wrap(ctx, err, "update android host installs for deleted profiles") } return nil } // For android we set the status to NIL func (ds *Datastore) bulkSetPendingMDMAndroidHostProfilesDB( ctx context.Context, hostUUIDs []string, ) (updatedDB bool, err error) { if len(hostUUIDs) == 0 { return false, nil } profilesToInstall, profilesToRemove, err := ds.ListMDMAndroidProfilesToSend(ctx) if err != nil { return false, ctxerr.Wrap(ctx, err, "list android profiles to send") } if len(profilesToInstall) == 0 && len(profilesToRemove) == 0 { return false, nil } var profilesToUpsert []*fleet.MDMAndroidProfilePayload for setIndex, profiles := range [][]*fleet.MDMAndroidProfilePayload{profilesToInstall, profilesToRemove} { operationType := fleet.MDMOperationTypeInstall if setIndex == 1 { operationType = fleet.MDMOperationTypeRemove } for _, p := range profiles { profilesToUpsert = append(profilesToUpsert, &fleet.MDMAndroidProfilePayload{ ProfileUUID: p.ProfileUUID, ProfileName: p.ProfileName, HostUUID: p.HostUUID, OperationType: operationType, Status: nil, Detail: "", PolicyRequestUUID: nil, DeviceRequestUUID: nil, RequestFailCount: 0, IncludedInPolicyVersion: nil, }) } } err = ds.BulkUpsertMDMAndroidHostProfiles(ctx, profilesToUpsert) if err != nil { return false, ctxerr.Wrap(ctx, err, "bulk upsert android host profiles") } return true, nil } // ListAndroidEnrolledDevicesForReconcile returns Android devices currently marked as enrolled in Fleet. func (ds *Datastore) ListAndroidEnrolledDevicesForReconcile(ctx context.Context) ([]*android.Device, error) { var devices []*android.Device stmt := `SELECT ad.id, ad.host_id, ad.device_id, ad.enterprise_specific_id, ad.last_policy_sync_time, ad.applied_policy_id, ad.applied_policy_version FROM android_devices ad JOIN host_mdm hm ON hm.host_id = ad.host_id AND hm.enrolled = 1 JOIN hosts h ON h.id = ad.host_id AND h.platform = 'android'` if err := sqlx.SelectContext(ctx, ds.reader(ctx), &devices, stmt); err != nil { return nil, ctxerr.Wrap(ctx, err, "list enrolled android devices for reconcile") } return devices, nil } func isAndroidHostConnectedToFleetMDM(ctx context.Context, q sqlx.QueryerContext, h *fleet.Host) (bool, error) { var isEnrolled bool err := sqlx.GetContext(ctx, q, &isEnrolled, ` SELECT 1 FROM host_mdm WHERE host_id = ? AND enrolled = 1 `, h.ID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, ctxerr.Wrap(ctx, err, "check android host mdm enrolled") } return isEnrolled, nil } func (ds *Datastore) InsertAndroidSetupExperienceSoftwareInstall(ctx context.Context, payload *fleet.HostAndroidVPPSoftwareInstall) error { const stmt = ` INSERT INTO host_vpp_software_installs ( host_id, adam_id, command_uuid, self_service, associated_event_id, platform ) VALUES (?, ?, ?, ?, ?, ?)` _, err := ds.writer(ctx).ExecContext(ctx, stmt, payload.HostID, payload.AdamID, payload.CommandUUID, false, payload.AssociatedEventID, fleet.AndroidPlatform, ) return ctxerr.Wrap(ctx, err, "inserting android setup experience software install") } func (ds *Datastore) ListHostMDMAndroidVPPAppsPendingInstallWithVersion(ctx context.Context, hostUUID string, policyVersion int64) ([]*fleet.HostAndroidVPPSoftwareInstall, error) { const stmt = ` SELECT hvsi.host_id, hvsi.adam_id, hvsi.command_uuid, hvsi.associated_event_id FROM host_vpp_software_installs hvsi INNER JOIN hosts h ON hvsi.host_id = h.id WHERE h.uuid = ? AND h.platform = ? AND CAST(hvsi.associated_event_id AS SIGNED INT) <= ? AND hvsi.verification_at IS NULL AND hvsi.verification_failed_at IS NULL ` var pendingInstalls []*fleet.HostAndroidVPPSoftwareInstall err := sqlx.SelectContext(ctx, ds.reader(ctx), &pendingInstalls, stmt, hostUUID, fleet.AndroidPlatform, policyVersion) if err != nil { return nil, ctxerr.Wrap(ctx, err, "listing host mdm android vpp apps pending install") } return pendingInstalls, nil } func (ds *Datastore) BulkSetVPPInstallsAsVerified(ctx context.Context, hostID uint, commandUUIDs []string) error { return ds.bulkSetVPPInstallsAsFinalState(ctx, hostID, commandUUIDs, "verification_at") } func (ds *Datastore) BulkSetVPPInstallsAsFailed(ctx context.Context, hostID uint, commandUUIDs []string) error { return ds.bulkSetVPPInstallsAsFinalState(ctx, hostID, commandUUIDs, "verification_failed_at") } func (ds *Datastore) bulkSetVPPInstallsAsFinalState(ctx context.Context, hostID uint, commandUUIDs []string, column string) error { if len(commandUUIDs) == 0 { return nil } // For Android, we don't care about the verification command uuid (at least currently), // we just set a random one. stmt := fmt.Sprintf(` UPDATE host_vpp_software_installs SET %s = CURRENT_TIMESTAMP(6), verification_command_uuid = ? WHERE command_uuid IN (?) `, column) return ds.withTx(ctx, func(tx sqlx.ExtContext) error { verificationUUID := uuid.NewString() stmt, args, err := sqlx.In(stmt, verificationUUID, commandUUIDs) if err != nil { return ctxerr.Wrap(ctx, err, "build set vpp installs as final state query") } if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { return ctxerr.Wrap(ctx, err, "set vpp installs as final state") } // TODO(mna): for now we don't use the upcoming queue for Android app installs, // but when we implement the standard Android app installs we probably will. Leaving // this here as a reminder. // if _, err := ds.activateNextUpcomingActivity(ctx, tx, hostID, ...); err != nil { // return ctxerr.Wrap(ctx, err, "activate next activity from VPP app install verify") // } return nil }) } // HasAndroidAppConfigurationChanged checks if the new configuration for an Android app // identified by application_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, teamID 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, teamID) 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 by app ID and team func (ds *Datastore) GetAndroidAppConfiguration(ctx context.Context, applicationID string, teamID uint) (*json.RawMessage, error) { stmt := `SELECT configuration FROM android_app_configurations WHERE application_id = ? AND global_or_team_id = ?` var config json.RawMessage err := sqlx.GetContext(ctx, ds.reader(ctx), &config, stmt, applicationID, teamID) 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) GetAndroidAppConfigurationByAppTeamID(ctx context.Context, vppAppTeamID uint) (*json.RawMessage, error) { stmt := ` SELECT aac.configuration 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 json.RawMessage 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, teamID 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, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "building bulk get android app configurations query") } var configs []*struct { ApplicationID string `db:"application_id"` Configuration json.RawMessage `db:"configuration"` } 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 } func (ds *Datastore) SetAndroidAppInstallPendingApplyConfig(ctx context.Context, hostUUID, applicationID string, policyVersion int64) error { const stmt = ` UPDATE host_vpp_software_installs JOIN hosts h ON h.id = host_vpp_software_installs.host_id SET verification_at = NULL, verification_command_uuid = NULL, associated_event_id = ? WHERE h.uuid = ? AND host_vpp_software_installs.adam_id = ? AND host_vpp_software_installs.platform = ? AND -- not removed or canceled host_vpp_software_installs.removed = 0 AND host_vpp_software_installs.canceled = 0 AND -- only if successfull or pending install host_vpp_software_installs.verification_failed_at IS NULL ` _, err := ds.writer(ctx).ExecContext(ctx, stmt, fmt.Sprint(policyVersion), hostUUID, applicationID, fleet.AndroidPlatform) if err != nil { return ctxerr.Wrap(ctx, err, "set android app install pending apply config") } return nil } // DeleteAndroidAppConfiguration removes an Android app configuration. func (ds *Datastore) DeleteAndroidAppConfiguration(ctx context.Context, appID string, teamID uint) error { stmt := ` DELETE FROM android_app_configurations WHERE application_id = ? AND global_or_team_id = ? ` result, err := ds.writer(ctx).ExecContext(ctx, stmt, appID, teamID) if err != nil { return ctxerr.Wrap(ctx, err, "delete android app configuration") } rows, err := result.RowsAffected() if err != nil { return ctxerr.Wrap(ctx, err, "delete android app configuration rows affected") } if rows == 0 { return ctxerr.Wrap(ctx, notFound("AndroidAppConfiguration")) } return nil } // updateAndroidAppConfigurationTx inserts or updates an app configuration using a transaction func (ds *Datastore) updateAndroidAppConfigurationTx(ctx context.Context, tx sqlx.ExtContext, teamID uint, appID string, config json.RawMessage) error { err := fleet.ValidateAndroidAppConfiguration(config) if err != nil { return ctxerr.Wrap(ctx, err, "validating android app configuration") } stmt := ` INSERT INTO android_app_configurations (application_id, team_id, global_or_team_id, configuration) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE configuration = VALUES(configuration) ` _, err = tx.ExecContext(ctx, stmt, appID, ptr.UintOrNilIfZero(teamID), teamID, config) if err != nil { return ctxerr.Wrap(ctx, err, "updateAndroidAppConfiguration") } 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 }