Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/datastore/mysql/mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 15 additions & 8 deletions server/datastore/mysql/microsoft_mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
41 changes: 41 additions & 0 deletions server/datastore/mysql/postgres_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
5 changes: 4 additions & 1 deletion server/platform/postgres/rebind_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(<expr>, '0') = '1' → COALESCE(<expr>, '0') IN ('1', 'true')
reJSONBoolCoalesce = regexp.MustCompile(`COALESCE\(([^)]+->>'[^']+'),\s*'0'\)\s*=\s*'1'`)
Expand Down Expand Up @@ -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
}

Expand Down
15 changes: 15 additions & 0 deletions server/platform/postgres/rebind_driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading