Fixed stale pending MDM profiles reappearing#46111
Conversation
|
@coderabbitai full review |
|
/agentic_review |
✅ Actions performedFull review triggered. |
Code Review by Qodo
1. iOS MDM URL not restored
|
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #42427 by preventing stale/persisted pending MDM profile operations from resurfacing after Apple/Windows MDM is globally disabled and later re-enabled. It does this by bulk-disabling MDM enrollment state for the affected platform(s) during global disable flows, and by tightening the Windows profile reconciler’s desired-state queries to only target hosts that osquery has re-confirmed as currently MDM-enrolled.
Changes:
- Replace “delete pending host profile rows” global cleanup with a stronger
BulkDisableMDMForPlatformoperation that also flips enrollment gating state (Windows:host_mdm.enrolled; Apple:nano_enrollments.enabled+host_mdm.enrolled). - Gate Windows profile reconciliation queries on
host_mdm.enrolled = 1to prevent recreating pending rows for previously-enrolled hosts after a disable/re-enable cycle. - Add/adjust integration + datastore tests to reflect the new reconciler gating and bulk-disable behavior, plus add a changes entry.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| server/service/microsoft_mdm_integration_test.go | Updates Windows reconciliation integration test setup to set host_mdm.enrolled=1 (required by new gating). |
| server/service/mdm.go | On APNS cert deletion, bulk-disables Apple MDM state so reconciler doesn’t recreate rows on re-enable. |
| server/service/mdm_test.go | Updates service test mock expectations for the renamed/changed datastore method. |
| server/service/appconfig.go | On Windows MDM global disable, calls bulk-disable operation to prevent stale pending rows resurfacing. |
| server/mock/datastore_mock.go | Renames mock hook from cleanup to BulkDisableMDMForPlatform. |
| server/fleet/datastore.go | Updates datastore interface to expose BulkDisableMDMForPlatform. |
| server/datastore/mysql/microsoft_mdm.go | Adds host_mdm.enrolled = 1 gating to Windows desired-state and removal queries. |
| server/datastore/mysql/microsoft_mdm_test.go | Adds coverage ensuring global disable blocks reconciler until osquery flips host_mdm.enrolled back to 1. |
| server/datastore/mysql/mdm.go | Implements BulkDisableMDMForPlatform (Apple + Windows bulk disable + deletes host profile rows). |
| server/datastore/mysql/mdm_test.go | Updates existing tests to validate new bulk-disable semantics (including enrollment gating state). |
| changes/42427-stale-mdm-profiles-reconciler-fix | User-facing changelog entry for the bug fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // BulkDisableMDMForPlatform marks all hosts of the given platform as | ||
| // unenrolled from MDM and deletes their pending profile rows. Used when MDM | ||
| // is toggled off globally so the profile reconciler does not recreate | ||
| // pending rows after MDM is turned back on. |
| // unenrolled from MDM and deletes their pending profile rows. This is the | ||
| // global-disable companion to per-host unenrollment (Apple CheckOut, | ||
| // Windows AlertUserUnenrollmentRequest): both paths must mark the host | ||
| // as unenrolled so the profile reconciler does not recreate pending rows. | ||
| // | ||
| // For Apple, nano_enrollments.enabled is the gate consulted by | ||
| // ReconcileAppleProfiles (see listMDMAppleProfilesToInstallTransaction). | ||
| // For Windows, host_mdm.enrolled is the gate added to |
| // Mark Windows hosts as unenrolled and clean up pending profile rows. | ||
| // Without this, mdm_windows_enrollments rows would remain and the | ||
| // Windows profile reconciler would recreate pending rows the next | ||
| // time Windows MDM is re-enabled. | ||
| if err := svc.ds.BulkDisableMDMForPlatform(ctx, "windows"); err != nil { |
| // Mark Apple hosts as unenrolled and clean up pending profile rows. | ||
| // Without this, nano_enrollments rows would remain with enabled = 1 | ||
| // and the Apple profile reconciler would recreate pending rows the next |
WalkthroughThis PR fixes an issue where pending MDM profile rows persist in the database after Apple or Windows MDM is toggled off globally, then back on. The fix introduces a new Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
server/datastore/mysql/mdm_test.go (1)
10140-10179:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winStrengthen post-disable assertions so the test fails if rows are deleted instead of flipped.
Current checks only assert
enrolled = 1/enabled = 1counts are zero. This still passes if records are removed, which weakens validation of the “mark unenrolled / soft-disable” contract.Suggested assertion hardening
- var winEnrolledCount int + var winHostMDM struct { + Total int `db:"total"` + Unenrolled int `db:"unenrolled"` + } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &winEnrolledCount, - `SELECT COUNT(*) FROM host_mdm hmdm + return sqlx.GetContext(ctx, q, &winHostMDM, + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN hmdm.enrolled = 0 THEN 1 ELSE 0 END) AS unenrolled + FROM host_mdm hmdm JOIN hosts h ON h.id = hmdm.host_id - WHERE h.platform = 'windows' AND hmdm.enrolled = 1`) + WHERE h.uuid IN (?, ?) AND h.platform = 'windows'`, + host3.UUID, host4.UUID) }) - require.Equal(t, 0, winEnrolledCount, "Windows hosts must be marked unenrolled in host_mdm after global disable") + require.Equal(t, 2, winHostMDM.Total) + require.Equal(t, 2, winHostMDM.Unenrolled) - var appleEnabledCount int + var appleEnrollments struct { + Total int `db:"total"` + Disabled int `db:"disabled"` + } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &appleEnabledCount, - `SELECT COUNT(*) FROM nano_enrollments WHERE enabled = 1`) + return sqlx.GetContext(ctx, q, &appleEnrollments, + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END) AS disabled + FROM nano_enrollments + WHERE id IN (?, ?)`, + host1.UUID, host2.UUID) }) - require.Equal(t, 0, appleEnabledCount, "nano_enrollments rows must be disabled after global Apple MDM disable") + require.Equal(t, 2, appleEnrollments.Total) + require.Equal(t, 2, appleEnrollments.Disabled) - var appleHostMDMEnrolledCount int + var appleHostMDM struct { + Total int `db:"total"` + Unenrolled int `db:"unenrolled"` + } ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.GetContext(ctx, q, &appleHostMDMEnrolledCount, - `SELECT COUNT(*) FROM host_mdm hmdm + return sqlx.GetContext(ctx, q, &appleHostMDM, + `SELECT + COUNT(*) AS total, + SUM(CASE WHEN hmdm.enrolled = 0 THEN 1 ELSE 0 END) AS unenrolled + FROM host_mdm hmdm JOIN hosts h ON h.id = hmdm.host_id - WHERE h.platform IN ('darwin', 'ios', 'ipados') AND hmdm.enrolled = 1`) + WHERE h.uuid IN (?, ?) AND h.platform IN ('darwin', 'ios', 'ipados')`, + host1.UUID, host2.UUID) }) - require.Equal(t, 0, appleHostMDMEnrolledCount, "Apple hosts must be marked unenrolled in host_mdm after global disable") + require.Equal(t, 2, appleHostMDM.Total) + require.Equal(t, 2, appleHostMDM.Unenrolled)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@server/datastore/mysql/mdm_test.go` around lines 10140 - 10179, The current assertions only check that enrolled/enabled counts are zero, which would pass if rows were deleted; update the test to also assert that the total number of affected rows still exists and that those rows have enrolled/enabled = 0. Specifically, after calling ds.BulkDisableMDMForPlatform(ctx, "darwin") and the Windows disable block, use ExecAdhocSQL + sqlx.GetContext to fetch both COUNT(*) WHERE ... (e.g., total_host_mdm_count for platforms 'windows' and for 'darwin','ios','ipados') and COUNT(*) WHERE enrolled = 0 for the same platform filters, then require total_host_mdm_count > 0 and that total_host_mdm_count == enrolled_zero_count; similarly for nano_enrollments fetch total_nano_count and enabled_zero_count and require total_nano_count > 0 and total_nano_count == enabled_zero_count to ensure rows were flipped rather than deleted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@server/datastore/mysql/mdm_test.go`:
- Around line 10140-10179: The current assertions only check that
enrolled/enabled counts are zero, which would pass if rows were deleted; update
the test to also assert that the total number of affected rows still exists and
that those rows have enrolled/enabled = 0. Specifically, after calling
ds.BulkDisableMDMForPlatform(ctx, "darwin") and the Windows disable block, use
ExecAdhocSQL + sqlx.GetContext to fetch both COUNT(*) WHERE ... (e.g.,
total_host_mdm_count for platforms 'windows' and for 'darwin','ios','ipados')
and COUNT(*) WHERE enrolled = 0 for the same platform filters, then require
total_host_mdm_count > 0 and that total_host_mdm_count == enrolled_zero_count;
similarly for nano_enrollments fetch total_nano_count and enabled_zero_count and
require total_nano_count > 0 and total_nano_count == enabled_zero_count to
ensure rows were flipped rather than deleted.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 5f6a0949-18a6-4ad4-bab6-3647d5da0aa8
📒 Files selected for processing (11)
changes/42427-stale-mdm-profiles-reconciler-fixserver/datastore/mysql/mdm.goserver/datastore/mysql/mdm_test.goserver/datastore/mysql/microsoft_mdm.goserver/datastore/mysql/microsoft_mdm_test.goserver/fleet/datastore.goserver/mock/datastore_mock.goserver/service/appconfig.goserver/service/mdm.goserver/service/mdm_test.goserver/service/microsoft_mdm_integration_test.go
| if _, err := tx.ExecContext(ctx, ` | ||
| UPDATE host_mdm SET enrolled = 0, server_url = '', mdm_id = NULL | ||
| WHERE host_id IN (SELECT id FROM hosts WHERE platform IN ('darwin', 'ios', 'ipados'))`); err != nil { | ||
| return ctxerr.Wrap(ctx, err, "marking Apple hosts unenrolled in host_mdm") |
There was a problem hiding this comment.
1. Ios mdm url not restored 🐞 Bug ≡ Correctness
BulkDisableMDMForPlatform clears host_mdm.server_url and mdm_id for Apple hosts, but the Apple MDM upsert path only updates host_mdm.enrolled on duplicates, so iOS/iPadOS hosts can remain enrolled with empty server_url/mdm_id after MDM is re-enabled. This breaks MDM server URL/solution reporting (and any filtering/logic relying on mdm_id) for platforms without osquery to repopulate host_mdm.
Agent Prompt
### Issue description
`BulkDisableMDMForPlatform` clears `host_mdm.server_url` and `host_mdm.mdm_id` for Apple platforms. For iOS/iPadOS (no osquery), the Apple MDM ingestion path restores `enrolled` but does **not** restore `server_url`/`mdm_id` because the upsert only updates `enrolled` on duplicate keys.
### Issue Context
After global Apple MDM disable/re-enable (e.g. APNS cert delete + re-upload), iOS/iPadOS hosts may become enrolled again but continue to present empty `mdm.server_url`/unknown MDM solution because `host_mdm` fields stay blank.
### Fix Focus Areas
- server/datastore/mysql/mdm.go[853-886]
- server/datastore/mysql/apple_mdm.go[1879-1922]
### Suggested fix
Update `upsertMDMAppleHostMDMInfoDB` to also update `server_url`, `mdm_id`, and other relevant columns (`installed_from_dep`, `is_server`, `is_personal_enrollment`) in the `ON DUPLICATE KEY UPDATE` clause (or at least when currently NULL/empty), so a re-enable cycle repopulates the cleared fields for iOS/iPadOS.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
CI Feedback 🧐A test triggered by this PR failed. Here is an AI-generated analysis of the failure:
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #46111 +/- ##
==========================================
- Coverage 66.82% 66.82% -0.01%
==========================================
Files 2754 2754
Lines 220172 220183 +11
Branches 10914 10914
==========================================
Hits 147133 147133
- Misses 59745 59751 +6
- Partials 13294 13299 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Related issue: Resolves #42427
Checklist for submitter
If some of the following don't apply, delete the relevant line.
Changes file added for user-visible changes in
changes/,orbit/changes/oree/fleetd-chrome/changes.See Changes files for more information.
Input data is properly validated,
SELECT *is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.Timeouts are implemented and retries are limited to avoid infinite loops
If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes
Testing
Added/updated automated tests
Where appropriate, automated tests simulate multiple hosts and test for host isolation (updates to one hosts's records do not affect another)
QA'd all new/changed functionality manually
For unreleased bug fixes in a release candidate, one of:
Database migrations
COLLATE utf8mb4_unicode_ci).New Fleet configuration settings
If you didn't check the box above, follow this checklist for GitOps-enabled settings:
fleetctl generate-gitopsfleetd/orbit/Fleet Desktop
runtime.GOOSis used as needed to isolate changesSummary by CodeRabbit
Bug Fixes
Tests