diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index fa4a9e54525..95a406543ae 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -1999,7 +1999,7 @@ WHERE AND ncaa.renew_command_uuid IS NULL AND ne.enabled = true GROUP BY - host_uuid, ncaa.sha256, ncaa.cert_not_valid_after + host_uuid, ncaa.sha256, ncaa.cert_not_valid_after, ne.enrolled_from_migration, ne.type ORDER BY cert_not_valid_after ASC LIMIT ?`, expiryDays, limit) diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go index a730d617996..aee567dc435 100644 --- a/server/datastore/mysql/microsoft_mdm.go +++ b/server/datastore/mysql/microsoft_mdm.go @@ -446,12 +446,17 @@ func (ds *Datastore) MDMWindowsDeleteEnrolledDeviceOnReenrollment(ctx context.Co delActionsStmt = "DELETE FROM host_mdm_actions WHERE host_id = (SELECT id FROM hosts WHERE uuid = ? LIMIT 1)" delProfilesStmt = "DELETE FROM host_mdm_windows_profiles WHERE host_uuid = ?" // setup_experience_status_results.host_uuid is keyed by fleet.HostUUIDForSetupExperience; for Windows that's the - // host's OsqueryHostID, NOT the Fleet host UUID stored on the MDM enrollment. Resolve via JOIN so we delete by - // whichever identifier matches (works for both shapes). - delSetupExpStmt = `DELETE ser FROM setup_experience_status_results ser - JOIN hosts h ON ser.host_uuid = h.osquery_host_id OR ser.host_uuid = h.uuid - WHERE h.uuid = ?` - delUpcomingStmt = `DELETE ua FROM upcoming_activities ua JOIN hosts h ON h.id = ua.host_id WHERE h.uuid = ?` + // host's OsqueryHostID, NOT the Fleet host UUID stored on the MDM enrollment. Resolve via the hosts table so we + // delete by whichever identifier matches (works for both shapes). + // Cross-dialect: avoid MySQL-only "DELETE alias FROM ... JOIN" syntax. + delSetupExpStmt = `DELETE FROM setup_experience_status_results + WHERE EXISTS ( + SELECT 1 FROM hosts h + WHERE (setup_experience_status_results.host_uuid = h.osquery_host_id + OR setup_experience_status_results.host_uuid = h.uuid) + AND h.uuid = ? + )` + delUpcomingStmt = `DELETE FROM upcoming_activities WHERE host_id IN (SELECT id FROM hosts WHERE uuid = ?)` ) return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { @@ -3777,10 +3782,12 @@ LIMIT ?` // needs: a host that was offline when the profile was deleted keeps its host-profile row, which keeps the content alive until the // removal finally delivers. The NOT EXISTS is an index probe via host_mdm_windows_profiles(profile_uuid). func (ds *Datastore) CleanupWindowsMDMPendingDeleteProfiles(ctx context.Context) error { + // Cross-dialect: avoid MySQL-only "DELETE alias FROM table alias" syntax. const stmt = ` -DELETE pd FROM mdm_windows_configuration_profiles_pending_delete pd +DELETE FROM mdm_windows_configuration_profiles_pending_delete WHERE NOT EXISTS ( - SELECT 1 FROM host_mdm_windows_profiles hmwp WHERE hmwp.profile_uuid = pd.profile_uuid + SELECT 1 FROM host_mdm_windows_profiles hmwp + WHERE hmwp.profile_uuid = mdm_windows_configuration_profiles_pending_delete.profile_uuid )` if _, err := ds.writer(ctx).ExecContext(ctx, stmt); err != nil { return ctxerr.Wrap(ctx, err, "cleanup windows mdm pending-delete profiles") diff --git a/server/datastore/mysql/postgres_smoke_test.go b/server/datastore/mysql/postgres_smoke_test.go index ce7ce79572d..a9abad0a7f3 100644 --- a/server/datastore/mysql/postgres_smoke_test.go +++ b/server/datastore/mysql/postgres_smoke_test.go @@ -555,3 +555,44 @@ func TestPostgresHostSoftwareUpdate(t *testing.T) { assert.Empty(t, host.Software, "host inventory should be empty") }) } + +// TestPostgresMDMCleanupQueries is regression coverage for MDM statements that +// previously used MySQL-only SQL and broke at runtime on PostgreSQL (the +// cleanups_then_aggregation cron and Windows re-enrollment). Each subtest +// executes the real statement against PG; a dialect regression fails here +// instead of shipping silently (these paths are not otherwise in the +// TestPostgres* suite). +func TestPostgresMDMCleanupQueries(t *testing.T) { + ds := CreatePostgresDS(t) + ctx := context.Background() + + t.Run("GetHostCertAssociationsToExpire", func(t *testing.T) { + // renew_scep_certificates cron: the expiry filter multiplies a bound + // param by INTERVAL '1 day' and must not be cast to timestamptz. + _, err := ds.GetHostCertAssociationsToExpire(ctx, 30, 100) + require.NoError(t, err, "GetHostCertAssociationsToExpire") + }) + + t.Run("CleanupWindowsMDMPendingDeleteProfiles", func(t *testing.T) { + require.NoError(t, ds.CleanupWindowsMDMPendingDeleteProfiles(ctx), + "CleanupWindowsMDMPendingDeleteProfiles") + }) + + t.Run("MDMWindowsDeleteEnrolledDeviceOnReenrollment", func(t *testing.T) { + device := &fleet.MDMWindowsEnrolledDevice{ + MDMDeviceID: "pg-mdm-device-1", + MDMHardwareID: "pg-mdm-hwid-1-0123456789012345678901234567890123456789", + MDMDeviceState: "2", + MDMDeviceType: "CIMClient_Windows", + MDMDeviceName: "PG-TEST-DESKTOP", + MDMEnrollType: "ProgrammaticEnrollment", + MDMEnrollUserID: "", + MDMEnrollProtoVersion: "5.0", + MDMEnrollClientVersion: "10.0.19045.2965", + MDMNotInOOBE: false, + } + require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, device), "insert enrolled device") + require.NoError(t, ds.MDMWindowsDeleteEnrolledDeviceOnReenrollment(ctx, device.MDMHardwareID), + "MDMWindowsDeleteEnrolledDeviceOnReenrollment") + }) +} diff --git a/server/platform/postgres/rebind_driver.go b/server/platform/postgres/rebind_driver.go index 3517695b287..2b917556e2c 100644 --- a/server/platform/postgres/rebind_driver.go +++ b/server/platform/postgres/rebind_driver.go @@ -68,7 +68,9 @@ var ( // MySQL: INSERT INTO table () VALUES () — empty column/value lists for auto-increment-only inserts reEmptyValues = regexp.MustCompile(`(?i)(INSERT\s+INTO\s+\S+\s+)\(\s*\)\s*VALUES\s*\(\s*\)`) // PG can't infer $N type in interval arithmetic; cast to timestamptz - reParamBeforeInterval = regexp.MustCompile(`(\$\d+)\s+([-+*]\s*INTERVAL\b)`) + reParamBeforeInterval = regexp.MustCompile(`(\$\d+)\s+([-+]\s*INTERVAL\b)`) + // $N * INTERVAL scales the interval, so the param is a number, not a timestamp + reParamTimesInterval = regexp.MustCompile(`(\$\d+)\s*(\*\s*INTERVAL\b)`) // JSON boolean comparison: MySQL ->> on JSON true returns '1', PG returns 'true'. // Match: COALESCE(, '0') = '1' → COALESCE(, '0') IN ('1', 'true') reJSONBoolCoalesce = regexp.MustCompile(`COALESCE\(([^)]+->>'[^']+'),\s*'0'\)\s*=\s*'1'`) @@ -694,6 +696,7 @@ func rebindQuery(query string) string { // PG can't infer the type of $N when used in interval arithmetic ($N - INTERVAL, $N + INTERVAL). // Cast to timestamptz so the operator resolves correctly. result = reParamBeforeInterval.ReplaceAllString(result, "${1}::timestamptz ${2}") + result = reParamTimesInterval.ReplaceAllString(result, "${1}::float8 ${2}") return result } diff --git a/server/platform/postgres/rebind_driver_test.go b/server/platform/postgres/rebind_driver_test.go index c0c00803eb2..ef0572db3fc 100644 --- a/server/platform/postgres/rebind_driver_test.go +++ b/server/platform/postgres/rebind_driver_test.go @@ -301,6 +301,21 @@ func TestRewriteIntervalPlaceholder(t *testing.T) { in: "a >= NOW() - INTERVAL ? SECOND AND b <= NOW() + INTERVAL ? MINUTE", want: "a >= NOW() - ($1::float8 * INTERVAL '1 second') AND b <= NOW() + ($2::float8 * INTERVAL '1 minute')", }, + { + name: "placeholder plus INTERVAL gets timestamptz cast", + in: "expires_at <= ? + INTERVAL '1 hour'", + want: "expires_at <= $1::timestamptz + INTERVAL '1 hour'", + }, + { + name: "placeholder minus INTERVAL gets timestamptz cast", + in: "seen_at >= ? - INTERVAL '30 minutes'", + want: "seen_at >= $1::timestamptz - INTERVAL '30 minutes'", + }, + { + name: "placeholder times INTERVAL gets float8 cast, not timestamptz", + in: "cert_not_valid_after <= CURRENT_DATE + (? * INTERVAL '1 day')", + want: "cert_not_valid_after <= CURRENT_DATE + ($1::float8 * INTERVAL '1 day')", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {