From d8d25e6297fa05be264a020c0c5c3510fa7183ba Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 16 Oct 2025 11:06:16 -0400 Subject: [PATCH] Clear profiles on Android host unenroll (#34343) **Related issue:** Resolves #34335 No changes file as this is an unreleased bug in 4.75.0 and covered by initial feature changes file # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [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 --- server/datastore/mysql/android.go | 43 ++++++++++++---- server/datastore/mysql/android_test.go | 69 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/server/datastore/mysql/android.go b/server/datastore/mysql/android.go index e4d0e4e2b8..649443e2a5 100644 --- a/server/datastore/mysql/android.go +++ b/server/datastore/mysql/android.go @@ -370,22 +370,47 @@ UPDATE host_mdm WHERE host_id IN ( SELECT id FROM hosts WHERE platform = 'android' )`) - return ctxerr.Wrap(ctx, err, "set host_mdm to unenrolled for 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") + } + return nil } -// SetAndroidHostUnenrolled sets a single android host to unenrolled in host_mdm. -// If the host is not enrolled, it does nothing and returns false. +// 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) { - result, err := ds.writer(ctx).ExecContext(ctx, ` + 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 uuid string + err = sqlx.GetContext(ctx, tx, &uuid, `SELECT uuid FROM hosts WHERE id = ?`, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host uuid") + } + err = ds.deleteMDMOSCustomSettingsForHost(ctx, tx, uuid, "android") + return ctxerr.Wrap(ctx, err, "delete Android custom OS settings for unenrolled host") + } + return nil + }) if err != nil { - return false, ctxerr.Wrap(ctx, err, "set host_mdm to unenrolled for android host") - } - rows, err := result.RowsAffected() - if err != nil { - return false, ctxerr.Wrap(ctx, err, "get rows affected for set host_mdm unenrolled for android host") + return false, err } return rows > 0, nil } diff --git a/server/datastore/mysql/android_test.go b/server/datastore/mysql/android_test.go index adc7163774..d35d47eb3a 100644 --- a/server/datastore/mysql/android_test.go +++ b/server/datastore/mysql/android_test.go @@ -48,6 +48,7 @@ func TestAndroid(t *testing.T) { {"NewAndroidHostWithIdP", testNewAndroidHostWithIdP}, {"AndroidBYODDetection", testAndroidBYODDetection}, {"SetAndroidHostUnenrolled", testSetAndroidHostUnenrolled}, + {"BulkSetAndroidHostsUnenrolled", testBulkSetAndroidHostsUnenrolled}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -2053,6 +2054,9 @@ func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) { require.NotEmpty(t, serverURL) require.Equal(t, 0, mdmIDIsNull) + upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending) + upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending) + // Perform single-host unenroll didUnenroll, err := ds.SetAndroidHostUnenrolled(testCtx(), res.Host.ID) require.NoError(t, err) @@ -2063,6 +2067,8 @@ func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) { require.NoError(t, err) require.False(t, didUnenroll) + profileCountForHost := 0 + // Validate host_mdm row updated ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(testCtx(), q, &enrolled, `SELECT enrolled FROM host_mdm WHERE host_id = ?`, res.Host.ID) @@ -2073,7 +2079,70 @@ func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) { ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(testCtx(), q, &mdmIDIsNull, `SELECT CASE WHEN mdm_id IS NULL THEN 1 ELSE 0 END FROM host_mdm WHERE host_id = ?`, res.Host.ID) }) + // validate profile records deleted + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(testCtx(), q, &profileCountForHost, `SELECT COUNT(*) FROM host_mdm_android_profiles WHERE host_uuid=?`, res.Host.UUID) + }) assert.Equal(t, 0, enrolled) assert.Equal(t, "", serverURL) assert.Equal(t, 1, mdmIDIsNull) + assert.Equal(t, 0, profileCountForHost) +} + +func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) { + // Set a non-empty server URL so initial enrolled row has data to clear + appCfg, err := ds.AppConfig(testCtx()) + require.NoError(t, err) + appCfg.ServerSettings.ServerURL = "https://mdm.example.com" + require.NoError(t, ds.SaveAppConfig(testCtx(), appCfg)) + + // Create 5 android hosts + for i := 0; i < 5; i++ { + esid := "enterprise-" + uuid.NewString() + h := createAndroidHost(esid) + res, err := ds.NewAndroidHost(testCtx(), h) + require.NoError(t, err) + + upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending) + upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending) + } + + // Create a macOS host (to verify we don't unenroll non-Android hosts) + macHost, err := ds.NewHost(testCtx(), &fleet.Host{ + Hostname: "test-host1-name", + OsqueryHostID: ptr.String("1337"), + NodeKey: ptr.String("1337"), + UUID: "test-uuid-1", + Platform: "darwin", + HardwareSerial: uuid.NewString(), + }) + require.NoError(t, err) + nanoEnroll(t, ds, macHost, false) + err = ds.MDMAppleUpsertHost(testCtx(), macHost, false) + require.NoError(t, err) + + // Initial sanity check + enrolledCount := 0 + androidHostProfileCount := 0 + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(testCtx(), q, &enrolledCount, `SELECT COUNT(*) FROM host_mdm WHERE enrolled = 1`) + }) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(testCtx(), q, &androidHostProfileCount, `SELECT COUNT(*) FROM host_mdm_android_profiles`) + }) + assert.Equal(t, 10, androidHostProfileCount) + require.Equal(t, 6, enrolledCount) // 5 android + 1 macOS + + err = ds.BulkSetAndroidHostsUnenrolled(testCtx()) + require.NoError(t, err) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(testCtx(), q, &enrolledCount, `SELECT COUNT(*) FROM host_mdm WHERE enrolled = 1`) + }) + require.Equal(t, 1, enrolledCount) + + // validate profile records deleted + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(testCtx(), q, &androidHostProfileCount, `SELECT COUNT(*) FROM host_mdm_android_profiles`) + }) + assert.Equal(t, 0, androidHostProfileCount) }