Clear Android cert records on unenroll. (#42920)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42600 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed an issue where Android device certificate template records were
not properly cleared during unenrollment, which previously resulted in
stale certificate statuses after re-enrollment.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky 2026-04-02 14:59:09 -05:00 committed by GitHub
parent 83651ce49f
commit 2118dcb0d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 81 additions and 15 deletions

View file

@ -0,0 +1 @@
- Fixed a bug where Android host certificate template records were not cleared when a device unenrolled, causing stale certificate statuses after re-enrollment.

View file

@ -393,6 +393,14 @@ UPDATE host_mdm
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
}
@ -413,13 +421,21 @@ UPDATE host_mdm
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)
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, uuid, "android")
return ctxerr.Wrap(ctx, err, "delete Android custom OS settings for unenrolled host")
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
})

View file

@ -2268,6 +2268,18 @@ func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) {
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending)
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending)
// Insert a certificate template record for this host to verify it gets deleted on unenroll.
err = ds.BulkInsertHostCertificateTemplates(testCtx(), []fleet.HostCertificateTemplate{
{
HostUUID: res.Host.UUID,
CertificateTemplateID: 1,
Status: fleet.CertificateTemplateVerified,
OperationType: fleet.MDMOperationTypeInstall,
Name: "test-cert",
},
})
require.NoError(t, err)
// Perform single-host unenroll
didUnenroll, err := ds.SetAndroidHostUnenrolled(testCtx(), res.Host.ID)
require.NoError(t, err)
@ -2290,7 +2302,7 @@ 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
// 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)
})
@ -2298,6 +2310,11 @@ func testSetAndroidHostUnenrolled(t *testing.T, ds *Datastore) {
assert.Equal(t, "", serverURL)
assert.Equal(t, 1, mdmIDIsNull)
assert.Equal(t, 0, profileCountForHost)
// Validate certificate template records deleted
certRecords, err := ds.GetHostCertificateTemplates(testCtx(), res.Host.UUID)
require.NoError(t, err)
assert.Empty(t, certRecords)
}
func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) {
@ -2310,6 +2327,7 @@ func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) {
require.NoError(t, ds.SaveAppConfig(testCtx(), appCfg))
// Create 5 android hosts
var androidHostUUIDs []string
for i := 0; i < 5; i++ {
esid := "enterprise-" + uuid.NewString()
h := createAndroidHost(esid)
@ -2318,6 +2336,19 @@ func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) {
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-1", &fleet.MDMDeliveryPending)
upsertAndroidHostProfileStatus(t, ds, res.Host.UUID, "profile-2", &fleet.MDMDeliveryPending)
// Insert a certificate template record for each host.
err = ds.BulkInsertHostCertificateTemplates(testCtx(), []fleet.HostCertificateTemplate{
{
HostUUID: res.Host.UUID,
CertificateTemplateID: 1,
Status: fleet.CertificateTemplateVerified,
OperationType: fleet.MDMOperationTypeInstall,
Name: "test-cert",
},
})
require.NoError(t, err)
androidHostUUIDs = append(androidHostUUIDs, res.Host.UUID)
}
// Create a macOS host (to verify we don't unenroll non-Android hosts)
@ -2345,6 +2376,12 @@ func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) {
})
assert.Equal(t, 10, androidHostProfileCount)
require.Equal(t, 6, enrolledCount) // 5 android + 1 macOS
// Verify each android host has a certificate template record.
for _, hostUUID := range androidHostUUIDs {
records, err := ds.GetHostCertificateTemplates(testCtx(), hostUUID)
require.NoError(t, err)
require.Len(t, records, 1)
}
err = ds.BulkSetAndroidHostsUnenrolled(testCtx())
require.NoError(t, err)
@ -2353,11 +2390,18 @@ func testBulkSetAndroidHostsUnenrolled(t *testing.T, ds *Datastore) {
})
require.Equal(t, 1, enrolledCount)
// validate profile records deleted
// 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)
// Validate certificate template records deleted for all android hosts
for _, hostUUID := range androidHostUUIDs {
records, err := ds.GetHostCertificateTemplates(testCtx(), hostUUID)
require.NoError(t, err)
assert.Empty(t, records)
}
}
// setupTestApp creates a test Android app in vpp_apps table

View file

@ -649,6 +649,17 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateUnenrollReenroll() {
s.verifyCertificateStatusWithSubject(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID,
fleet.CertificateTemplatePending, "", "CN="+host.HardwareSerial)
// Step: Simulate the certificate being successfully installed on the device (status = verified).
// This is critical for testing that verified records are cleared on unenroll (issue #42600).
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"UPDATE host_certificate_templates SET status = ?, uuid = UUID_TO_BIN(UUID(), true) WHERE host_uuid = ? AND certificate_template_id = ?",
fleet.CertificateTemplateVerified, host.UUID, certTemplateID)
return err
})
s.verifyCertificateStatusWithSubject(t, host, orbitNodeKey, certTemplateID, certTemplateName, caID,
fleet.CertificateTemplateVerified, "", "CN="+host.HardwareSerial)
// Step: Unenroll the host (simulates pubsub DELETED message)
unenrolled, err := s.ds.SetAndroidHostUnenrolled(ctx, host.ID)
require.NoError(t, err)
@ -672,17 +683,11 @@ func (s *integrationMDMTestSuite) TestCertificateTemplateUnenrollReenroll() {
require.NotZero(t, createResp.ID)
certTemplateID2 := createResp.ID
// Step: Verify that the unenrolled host did NOT get a host_certificate_templates record for the second template.
// The host API only returns profiles that have host_certificate_templates records, so the second
// template should not appear in the profiles list.
// Step: Verify that unenrolling cleared all certificate template records for this host.
// Neither the previously verified first template nor the newly created second template should appear.
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.Profiles)
require.Len(t, *getHostResp.Host.MDM.Profiles, 1, "Only first template should appear (second was created while host was unenrolled)")
profile := (*getHostResp.Host.MDM.Profiles)[0]
require.Equal(t, certTemplateName, profile.Name, "First certificate template should be present")
require.NotNil(t, profile.Status)
require.Equal(t, string(fleet.CertificateTemplatePending), *profile.Status)
require.Nil(t, getHostResp.Host.MDM.Profiles, "All certificate template records should be cleared on unenroll")
// Step: Re-enroll the host (simulates pubsub status report triggering UpdateAndroidHost with fromEnroll=true)
err = s.ds.UpdateAndroidHost(ctx, createdAndroidHost, true, false)