diff --git a/articles/using-maintenance-windows.md b/articles/using-maintenance-windows.md index cf44c23537..c3f5788e79 100644 --- a/articles/using-maintenance-windows.md +++ b/articles/using-maintenance-windows.md @@ -22,9 +22,12 @@ You can customize these flows with a webhook (e.g. Tines) to run scripts, use th ### End user experience -* If a host has multiple users listed in host vitals, such as an IdP user and one or more Google Chrome profiles, Fleet schedules the calendar event for the first user in alphabetical order. -> **Example:** if the CEO's Executive Assistant (EA) is logged into Google Chrome with two profiles—their own (assistant@example.com) and the CEO's (ceo@example.com)—Fleet schedules the calendar event for the first profile in alphabetical order. In this case, the CEO would receive the calendar event for the EA's maintenance window. -* If a user is associated with multiple failing hosts, Fleet schedules only one calendar event at a time. After the first host is fixed, Fleet schedules the next event. +* If a user owns multiple failing hosts, only one host is scheduled at a time. Once it's fixed, Fleet schedules the next. +* If a host has multiple users, Fleet chooses one user to receive the event based on email priority: + * First priority: **IdP Username** email address (from MDM IdP accounts or manually set IdP email) + * Second priority: Google Chrome profile email address + * If multiple Google Chrome profile emails exist, Fleet selects the first one alphabetically + * Third priority: other email sources * Users can reschedule the event on their calendar—Fleet will run remediation at the new time. * If a user moves the event to before the current time, Fleet shifts it to the next day. * If a user deletes the event, Fleet automatically reschedules it for the next day. diff --git a/changes/37249-use-username-idp-email-for-maintenance-windows b/changes/37249-use-username-idp-email-for-maintenance-windows new file mode 100644 index 0000000000..55359118b2 --- /dev/null +++ b/changes/37249-use-username-idp-email-for-maintenance-windows @@ -0,0 +1 @@ +* Updated determination process used for selecting which user email address to use when scheduling a maintenance event for a host failing policies. diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go index a4eafdd88d..d2dc360037 100644 --- a/server/cron/calendar_cron.go +++ b/server/cron/calendar_cron.go @@ -131,7 +131,9 @@ func cronCalendarEventsForTeam( // NOTEs: // - We ignore hosts that are passing all policies and do not have an associated email. // - We get only one host per email that's failing policies (the one with lower host id). - // - On every host, we get only the first email that matches the domain (sorted lexicographically). + // - On every host, we prioritize email selection: IdP Username (mdm_idp_accounts or idp sources) first, + // then Google Chrome profiles, then other sources. If multiple Google Chrome profile emails exist, + // we select the first one alphabetically. // - GetTeamHostsPolicyMemberships returns the hosts that are passing all policies and have a calendar event. // diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 5029892676..9f54e40190 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -2341,18 +2341,38 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( GROUP BY host_id ) pm ON h.id = pm.host_id LEFT JOIN ( - SELECT host_id, MIN(email) AS email - FROM host_emails - JOIN hosts ON host_emails.host_id=hosts.id - WHERE email LIKE CONCAT('%@', ?) AND team_id = ? - GROUP BY host_id + SELECT host_id, email + FROM ( + SELECT + he.host_id, + he.email, + ROW_NUMBER() OVER ( + PARTITION BY he.host_id + ORDER BY + CASE + WHEN he.source IN (?, ?) THEN 1 -- IdP sources (mdm_idp_accounts, idp) have priority 1 + WHEN he.source = ? THEN 2 -- Google Chrome profiles have priority 2 + ELSE 3 -- Other sources have lower priority + END, + he.email -- alphabetical tiebreaker within same priority + ) AS rn + FROM host_emails he + JOIN hosts h_email ON he.host_id = h_email.id + WHERE he.email LIKE CONCAT('%@', ?) AND h_email.team_id = ? + ) ranked_emails + WHERE rn = 1 ) sh ON h.id = sh.host_id LEFT JOIN host_display_names hdn ON h.id = hdn.host_id LEFT JOIN host_calendar_events hce ON h.id = hce.host_id WHERE h.team_id = ? AND ((pm.passing IS NOT NULL AND NOT pm.passing) OR (COALESCE(pm.passing, 1) AND hce.host_id IS NOT NULL)) ` - query, args, err := sqlx.In(query, policyIDs, domain, teamID, teamID) + query, args, err := sqlx.In(query, + policyIDs, + fleet.DeviceMappingMDMIdpAccounts, fleet.DeviceMappingIDP, // IdP sources + fleet.DeviceMappingGoogleChromeProfiles, // Chrome profiles + domain, teamID, // domain and team_id for WHERE clause + teamID) // h.team_id in main WHERE if err != nil { return nil, ctxerr.Wrapf(ctx, err, "build select get team hosts policy memberships query") } diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 276f36c928..b8027fa68b 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -67,6 +67,7 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNameSort", testPoliciesNameSort}, {"TestGetCalendarPolicies", testGetCalendarPolicies}, {"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships}, + {"GetTeamHostsPolicyMembershipsEmailPriority", testGetTeamHostsPolicyMembershipsEmailPriority}, {"TestPoliciesNewGlobalPolicyWithInstaller", testNewGlobalPolicyWithInstaller}, {"TestPoliciesTeamPoliciesWithInstaller", testTeamPoliciesWithInstaller}, {"TestPoliciesTeamPoliciesWithVPP", testTeamPoliciesWithVPP}, @@ -4346,6 +4347,132 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) } +func testGetTeamHostsPolicyMembershipsEmailPriority(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test-team"}) + require.NoError(t, err) + + calendarPolicy, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ + Name: "Calendar Policy", + Query: "SELECT 1;", + CalendarEventsEnabled: true, + }) + require.NoError(t, err) + + newHost := func(name string) *fleet.Host { + h, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String(name), + NodeKey: ptr.String(name), + HardwareSerial: name + "-serial", + ComputerName: name, + TeamID: &team.ID, + }) + require.NoError(t, err) + // Make the host fail the calendar policy so it always appears in results. + err = ds.RecordPolicyQueryExecutions(ctx, h, map[uint]*bool{calendarPolicy.ID: ptr.Bool(false)}, time.Now(), false) + require.NoError(t, err) + return h + } + + setEmails := func(hostID uint, mappings ...*fleet.HostDeviceMapping) { + // Group by source so each source gets its own ReplaceHostDeviceMapping call, + // matching production usage (each source replaces its own slice). + bySource := make(map[string][]*fleet.HostDeviceMapping) + for _, m := range mappings { + bySource[m.Source] = append(bySource[m.Source], m) + } + for src, ms := range bySource { + err := ds.ReplaceHostDeviceMapping(ctx, hostID, ms, src) + require.NoError(t, err) + } + } + + getResults := func(domain string) []fleet.HostPolicyMembershipData { + results, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, []uint{calendarPolicy.ID}, nil) + require.NoError(t, err) + sort.Slice(results, func(i, j int) bool { return results[i].HostID < results[j].HostID }) + return results + } + + emailFor := func(results []fleet.HostPolicyMembershipData, h *fleet.Host) string { + for _, r := range results { + if r.HostID == h.ID { + return r.Email + } + } + t.Fatalf("host %d not found in results", h.ID) + return "" + } + + testCases := []struct { + name string + mappings []*fleet.HostDeviceMapping + expected string + }{ + { + name: "idp-vs-chrome", + mappings: []*fleet.HostDeviceMapping{ + {Email: "chrome@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "idp@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, + }, + expected: "idp@example.com", + }, + { + name: "idpsrc-vs-chrome", + mappings: []*fleet.HostDeviceMapping{ + {Email: "chrome@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "idpsrc@example.com", Source: fleet.DeviceMappingIDP}, + }, + expected: "idpsrc@example.com", + }, + { + name: "custom-vs-chrome", + mappings: []*fleet.HostDeviceMapping{ + {Email: "chrome@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "custom@example.com", Source: "custom"}, + }, + expected: "chrome@example.com", + }, + { + name: "multi-idp", + mappings: []*fleet.HostDeviceMapping{ + {Email: "zebra@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, + {Email: "apple@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, + }, + expected: "apple@example.com", + }, + { + name: "multi-chrome", + mappings: []*fleet.HostDeviceMapping{ + {Email: "zebra@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, + {Email: "apple@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, + }, + expected: "apple@example.com", + }, + { + name: "idp-wrong-domain", + mappings: []*fleet.HostDeviceMapping{ + {Email: "idp@other.com", Source: fleet.DeviceMappingMDMIdpAccounts}, + {Email: "chrome@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, + }, + expected: "chrome@example.com", + }, + } + + for _, tC := range testCases { + host := newHost(tC.name) + + for _, m := range tC.mappings { + m.HostID = host.ID + } + setEmails(host.ID, tC.mappings...) + + results := getResults("example.com") + require.Equal(t, tC.expected, emailFor(results, host), tC.name) + } +} + func testNewGlobalPolicyWithInstaller(t *testing.T, ds *Datastore) { user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) _, err := ds.NewGlobalPolicy(context.Background(), &user1.ID, fleet.PolicyPayload{