Prioritize using IdP email address when available for maintenance window scheduling (#37250)

This pull request updates the logic for selecting which user receives
maintenance window calendar events on hosts with multiple users. The
changes clarify and enforce a priority system for choosing the recipient
email, ensuring that IdP-sourced emails are preferred, followed by
Google Chrome profile emails. This affects both user-facing
documentation and backend implementation.

**User-facing behavior and documentation:**

* The end-user documentation now explicitly describes the email
selection priority for calendar event recipients: IdP Username email is
chosen first, then Google Chrome profile email, and if multiple Chrome
emails exist, the first alphabetically is selected.

**Backend logic and data selection:**

* The comment in `calendar_cron.go` is updated to match the new email
selection logic, explaining the prioritization of email sources for
host-user assignment.

* The SQL query in `policies.go` is refactored to implement the new
priority system for selecting user emails per host:
  - IdP sources (`mdm_idp_accounts`, `idp`) are considered first,
  - then Google Chrome profiles,
  - then other sources.
- If multiple emails exist at the same priority, the first
alphabetically is chosen.

---------

Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com>
Co-authored-by: Noah Talerman <47070608+noahtalerman@users.noreply.github.com>
Co-authored-by: Juan Fernandez <juan-fdz-hawa@users.noreply.github.com>
Co-authored-by: Juan Fernandez <juan@fleetdm.com>
This commit is contained in:
Allen Houchins 2026-02-27 12:57:43 -06:00 committed by GitHub
parent c95283c490
commit 518475fc4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 163 additions and 10 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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.
//

View file

@ -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")
}

View file

@ -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{