Clear profiles on Android host unenroll (#34343)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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
This commit is contained in:
Jordan Montgomery 2025-10-16 11:06:16 -04:00 committed by GitHub
parent 21d1421566
commit d8d25e6297
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 103 additions and 9 deletions

View file

@ -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
}

View file

@ -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)
}