diff --git a/cmd/fleet/calendar_cron.go b/cmd/fleet/calendar_cron.go index e4b1927d55..953747c602 100644 --- a/cmd/fleet/calendar_cron.go +++ b/cmd/fleet/calendar_cron.go @@ -110,6 +110,10 @@ func cronCalendarEventsForTeam( } if len(policies) == 0 { + level.Debug(logger).Log( + "msg", "skipping, no calendar policies", + "team_id", team.ID, + ) return nil } @@ -120,6 +124,7 @@ func cronCalendarEventsForTeam( // - 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). + // - GetTeamHostsPolicyMemberships returns the hosts that are passing all policies and have a calendar event. // policyIDs := make([]uint, 0, len(policies)) @@ -625,7 +630,7 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k } for _, team := range teams { - if err := deleteTeamCalendarEvents(ctx, ds, userCalendar, *team); err != nil { + if err := cleanupTeamCalendarEvents(ctx, ds, userCalendar, *team); err != nil { level.Info(logger).Log("msg", "delete team calendar events", "team_id", team.ID, "err", err) } } @@ -666,17 +671,27 @@ func deleteAllCalendarEvents( return nil } -func deleteTeamCalendarEvents( +func cleanupTeamCalendarEvents( ctx context.Context, ds fleet.Datastore, userCalendar fleet.UserCalendar, team fleet.Team, ) error { - if team.Config.Integrations.GoogleCalendar != nil && - team.Config.Integrations.GoogleCalendar.Enable { - // Feature is enabled, nothing to cleanup. - return nil + teamFeatureEnabled := team.Config.Integrations.GoogleCalendar != nil && team.Config.Integrations.GoogleCalendar.Enable + + if teamFeatureEnabled { + policies, err := ds.GetCalendarPolicies(ctx, team.ID) + if err != nil { + return fmt.Errorf("get calendar policy ids: %w", err) + } + if len(policies) > 0 { + // Feature is enabled and there are calendar policies configured, so nothing to do. + return nil + } + // Feature is enabled but there are no calendar policies, + // so we want to cleanup all calendar events for the team. } + return deleteAllCalendarEvents(ctx, ds, userCalendar, &team.ID) } diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx index da22ea4801..e9f6ed57c9 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx @@ -7,6 +7,7 @@ import { AppContext } from "context/app"; import configAPI from "services/entities/config"; // @ts-ignore import { stringToClipboard } from "utilities/copy_text"; +import paths from "router/paths"; // @ts-ignore import InputField from "components/forms/fields/InputField"; @@ -17,6 +18,7 @@ import Spinner from "components/Spinner"; import DataError from "components/DataError"; import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage"; import Icon from "components/Icon"; +import Card from "components/Card"; const CREATING_SERVICE_ACCOUNT = "https://www.fleetdm.com/learn-more-about/creating-service-accounts"; @@ -236,6 +238,9 @@ const Calendars = (): JSX.Element => { For "Organization" and "Location", select your calendar's organization. +
  • + Click Create. +
  • @@ -277,69 +282,72 @@ const Calendars = (): JSX.Element => {
  • Click Create to create the key & download a JSON file.
  • -
  • - Configure your service account integration in Fleet using the - form below: -
    - - Paste the full contents of the JSON file downloaded{" "} -
    - when creating your service account API key. - - } - placeholder={API_KEY_JSON_PLACEHOLDER} - ignore1password - inputClassName={`${baseClass}__api-key-json`} - error={formErrors.apiKeyJson} - /> - - If the end user is signed into multiple Google accounts, - this will be used to identify their work calendar. - - } - placeholder="example.com" - helpText={ - <> - You can find your primary domain in Google Workspace{" "} - - - } - error={formErrors.domain} - /> - - + +

    +

    + 5. Configure your service account integration in Fleet using the + form below: +

      +
    • + Paste the full contents of the JSON file downloaded when + creating your service account API key. +
    • +
    • + Set your primary domain. (If the end user is signed into + multiple Google accounts, this will be used to identify their + work calendar.) +
    • +
    • + Save your changes. + +
      + + + You can find your primary domain in Google Workspace{" "} + + + } + error={formErrors.domain} + /> + + +

    - 5. Authorize the service account via domain-wide delegation. + 6. Authorize the service account via domain-wide delegation.

    • In Google Workspace, go to{" "} @@ -381,7 +389,7 @@ const Calendars = (): JSX.Element => {

    - 6. Enable the Google Calendar API. + 7. Enable the Google Calendar API.

    • In the Google Cloud console API library, go to the Google @@ -402,8 +410,12 @@ const Calendars = (): JSX.Element => {

    - You're ready to automatically schedule calendar events for end - users. + Now head over to{" "} + {" "} + to finish setup.

    diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss index 01db771e00..ffd30992ef 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss @@ -16,8 +16,8 @@ margin: $pad-small 0; } - form { - margin-top: $pad-large; + .card { + margin-top: $pad-small; } &__configuration { diff --git a/schema/osquery_fleet_schema.json b/schema/osquery_fleet_schema.json index c6f9096d43..adfa7eef1f 100644 --- a/schema/osquery_fleet_schema.json +++ b/schema/osquery_fleet_schema.json @@ -3970,7 +3970,7 @@ { "name": "path", "description": "Path to extension folder. Defaults to '' on ChromeOS", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -4067,7 +4067,7 @@ { "name": "state", "description": "1 if this extension is enabled", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -15143,19 +15143,19 @@ "name": "trace_id", "description": "The ID of a trace event", "required": false, - "type": "string" + "type": "text" }, { "name": "event_type", "description": "The type of event, this can be logEvent, signpostEvent or stateEvent.", "required": false, - "type": "string" + "type": "text" }, { "name": "format_string", "description": "The format string used to convert variable content into a string for output.", "required": false, - "type": "string" + "type": "text" }, { "name": "activity_identifier", @@ -15185,19 +15185,19 @@ "name": "sender_image_uuid", "description": "The UUID of the library, framework, kernel extension, or mach-o image, that originated the event.", "required": false, - "type": "string" + "type": "text" }, { "name": "sender_image_path", "description": "The full path of the library, framework, kernel extension, or mach-o image, that originated the event.", "required": false, - "type": "string" + "type": "text" }, { "name": "boot_uuid", "description": "The boot UUID of the event.", "required": false, - "type": "string" + "type": "text" }, { "name": "process_id", @@ -15209,7 +15209,7 @@ "name": "process_image_path", "description": "The full path of the process that originated the event.", "required": false, - "type": "string" + "type": "text" }, { "name": "timestamp", @@ -15221,7 +15221,7 @@ "name": "event_message", "description": "The message of the log entry.", "required": false, - "type": "string" + "type": "text" }, { "name": "sender_program_counter", @@ -25617,7 +25617,7 @@ { "name": "hostname", "description": "Network hostname including domain. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25635,7 +25635,7 @@ { "name": "cpu_type", "description": "CPU type", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25658,7 +25658,7 @@ { "name": "cpu_brand", "description": "CPU brand string, contains vendor and model", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25718,7 +25718,7 @@ { "name": "physical_memory", "description": "Total physical memory in bytes", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25727,7 +25727,7 @@ { "name": "hardware_vendor", "description": "Hardware vendor. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25736,7 +25736,7 @@ { "name": "hardware_model", "description": "Hardware model. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25759,7 +25759,7 @@ { "name": "hardware_serial", "description": "The device's serial number. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25824,7 +25824,7 @@ { "name": "computer_name", "description": "Friendly computer name (optional). For ChromeOS, if the extension wasn't force-installed by an enterprise policy this will default to 'ChromeOS' only", - "type": "STRING", + "type": "TEXT", "notes": "", "hidden": false, "required": false, @@ -25857,7 +25857,7 @@ "columns": [ { "name": "idle_state", - "type": "string", + "type": "text", "description": "Returns \"locked\", \"idle\", or \"active\".", "required": false } @@ -27254,7 +27254,7 @@ { "name": "email", "required": false, - "type": "string", + "type": "text", "description": "Email", "platforms": [ "chrome" diff --git a/schema/tables/chrome_extensions.yml b/schema/tables/chrome_extensions.yml index 932c087acf..a993273c25 100644 --- a/schema/tables/chrome_extensions.yml +++ b/schema/tables/chrome_extensions.yml @@ -57,7 +57,7 @@ columns: - windows - linux - name: path - type: string + type: text description: Path to extension folder. Defaults to '' on ChromeOS - name: optional_permissions platforms: @@ -85,7 +85,7 @@ columns: - windows - linux - name: state - type: string + type: text - name: install_time platforms: - darwin diff --git a/schema/tables/macadmins_unified_log.yml b/schema/tables/macadmins_unified_log.yml index f93a7ec614..bd8c6a85af 100644 --- a/schema/tables/macadmins_unified_log.yml +++ b/schema/tables/macadmins_unified_log.yml @@ -16,15 +16,15 @@ columns: - name: trace_id description: The ID of a trace event required: false - type: string + type: text - name: event_type description: The type of event, this can be logEvent, signpostEvent or stateEvent. required: false - type: string + type: text - name: format_string description: The format string used to convert variable content into a string for output. required: false - type: string + type: text - name: activity_identifier description: The identifier of the log activity. required: false @@ -44,15 +44,15 @@ columns: - name: sender_image_uuid description: The UUID of the library, framework, kernel extension, or mach-o image, that originated the event. required: false - type: string + type: text - name: sender_image_path description: The full path of the library, framework, kernel extension, or mach-o image, that originated the event. required: false - type: string + type: text - name: boot_uuid description: The boot UUID of the event. required: false - type: string + type: text - name: process_id description: Process ID of the process that generated this log item, which can be joined to multiple other tables including a *PID*. required: false @@ -60,7 +60,7 @@ columns: - name: process_image_path description: The full path of the process that originated the event. required: false - type: string + type: text - name: timestamp description: Timestamp in [UNIX time format](https://en.wikipedia.org/wiki/Unix_time). required: false @@ -68,7 +68,7 @@ columns: - name: event_message description: The message of the log entry. required: false - type: string + type: text - name: sender_program_counter description: The program counter of the library, framework, kernel extension, or mach-o image, that originated the event. required: false diff --git a/schema/tables/system_info.yml b/schema/tables/system_info.yml index 4300141df5..38b0b5b2af 100644 --- a/schema/tables/system_info.yml +++ b/schema/tables/system_info.yml @@ -56,26 +56,26 @@ columns: - windows - linux - name: hostname - type: string + type: text description: Network hostname including domain. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: computer_name - type: string + type: text description: Friendly computer name (optional). For ChromeOS, if the extension wasn't force-installed by an enterprise policy this will default to 'ChromeOS' only - name: hardware_serial - type: string + type: text description: The device's serial number. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: hardware_vendor - type: string + type: text description: Hardware vendor. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: hardware_model - type: string + type: text description: Hardware model. For ChromeOS, this is only available if the extension was force-installed by an enterprise policy - name: cpu_brand - type: string + type: text - name: cpu_type - type: string + type: text - name: physical_memory - type: string + type: text examples: >- See the CPU architecture of a machine as well as who made it and what its diff --git a/schema/tables/system_state.yml b/schema/tables/system_state.yml index 323fd7a023..f47031849e 100644 --- a/schema/tables/system_state.yml +++ b/schema/tables/system_state.yml @@ -12,7 +12,7 @@ examples: >- ``` columns: - name: idle_state - type: string + type: text description: Returns "locked", "idle", or "active". required: false evented: false diff --git a/schema/tables/users.yml b/schema/tables/users.yml index 574d1d2bf9..ed9469fa7e 100644 --- a/schema/tables/users.yml +++ b/schema/tables/users.yml @@ -46,7 +46,7 @@ columns: - name: uuid - name: email required: false - type: string + type: text description: Email platforms: - chrome diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go index 45d8d88331..ebd071d81a 100644 --- a/server/datastore/mysql/calendar_events.go +++ b/server/datastore/mysql/calendar_events.go @@ -210,7 +210,6 @@ func (ds *Datastore) ListCalendarEvents(ctx context.Context, teamID *uint) ([]*f var args []interface{} if teamID != nil { - // TODO(lucas): Should we add a team_id column to calendar_events? calendarEventsQuery += ` JOIN host_calendar_events hce ON ce.id=hce.calendar_event_id JOIN hosts h ON h.id=hce.host_id WHERE h.team_id = ?` args = append(args, *teamID) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 71530961de..098014f699 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -1181,28 +1181,30 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( query := ` SELECT COALESCE(sh.email, '') AS email, - pm.passing AS passing, + COALESCE(pm.passing, 1) AS passing, h.id AS host_id, - hdn.display_name AS host_display_name, + COALESCE(hdn.display_name, '') AS host_display_name, h.hardware_serial AS host_hardware_serial - FROM ( + FROM hosts h + LEFT JOIN ( SELECT host_id, BIT_AND(COALESCE(passes, 0)) AS passing FROM policy_membership WHERE policy_id IN (?) GROUP BY host_id - ) pm + ) 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 - ) sh ON sh.host_id = pm.host_id - JOIN hosts h ON h.id = pm.host_id - LEFT JOIN host_display_names hdn ON hdn.host_id = pm.host_id; + ) 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) + query, args, err := sqlx.In(query, policyIDs, domain, teamID, teamID) 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 15ebeee171..374dbf7281 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -2865,6 +2865,23 @@ func testGetCalendarPolicies(t *testing.T, ds *Datastore) { func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { ctx := context.Background() + // + // Test setup: + // + // team1: + // team1Policy1 (calendar), team1Policy2 + // host1, host5, host6 + // + // team2: + // team2Policy1 (calendar), team2Policy2 (calendar) + // host2, host3 + // + // global: + // Global Policy 1 + // host4 + // + // + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) require.NoError(t, err) team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) @@ -2894,11 +2911,16 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { CalendarEventsEnabled: true, }) require.NoError(t, err) + _, err = ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "Global Policy 1", + Query: "SELECT * FROM foobar;", + }) + require.NoError(t, err) // Empty teams. hostsTeam1, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) require.NoError(t, err) - require.Len(t, hostsTeam1, 0) + require.Empty(t, hostsTeam1) host1, err := ds.NewHost(ctx, &fleet.Host{ OsqueryHostID: ptr.String("host1"), @@ -2939,11 +2961,35 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { TeamID: &team1.ID, }) require.NoError(t, err) + host6, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("host6"), + NodeKey: ptr.String("host6"), + HardwareSerial: "serial6", + ComputerName: "display_name6", + TeamID: &team1.ID, + }) + require.NoError(t, err) - // No policy results yet. + // Some domain that doesn't exist on any of the hosts + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "not-exists.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) + require.NoError(t, err) + require.Empty(t, hostsTeam1) + + // No policy results yet (and no calendar events). hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) require.NoError(t, err) - require.Len(t, hostsTeam1, 0) + require.Empty(t, hostsTeam1) + + // + // Email setup + // + // host1 has foo@example.com, zoo@example.com + // host2 has foo@example.com, foo@other.com + // host3 has zoo@example.com + // host4 has foo@example.com + // host5 has foo@other.com + // host6 has bar@example.com + // err = ds.ReplaceHostDeviceMapping(ctx, host1.ID, []*fleet.HostDeviceMapping{ {HostID: host1.ID, Email: "foo@example.com", Source: "google_chrome_profiles"}, @@ -2973,6 +3019,20 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { {HostID: host5.ID, Email: "foo@other.com", Source: "google_chrome_profiles"}, }, "google_chrome_profiles") require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host6.ID, []*fleet.HostDeviceMapping{ + {HostID: host6.ID, Email: "bar@example.com", Source: "google_chrome_profiles"}, + }, "google_chrome_profiles") + require.NoError(t, err) + + // + // Results setup + // + // host1 (team1) is passing team1Policy1 (calendar) and failing team1Policy2. + // host2 (team2) is failing team2Policy1 (calendar) and passing team2Policy2 (calendar). + // host3 (team2) is passing all policies. + // host5 (team1) is failing all policies. + // host6 (team1) has not returned results. + // err = ds.RecordPolicyQueryExecutions(ctx, host1, map[uint]*bool{ team1Policy1.ID: ptr.Bool(true), @@ -3005,9 +3065,34 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, team2Policies, 2) + // Only returns the failing host, because the passing hosts do not have a calendar event. hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) require.NoError(t, err) - require.Len(t, hostsTeam1, 2) + sort.Slice(hostsTeam1, func(i, j int) bool { + return hostsTeam1[i].HostID < hostsTeam1[j].HostID + }) + require.Len(t, hostsTeam1, 1) + require.Equal(t, host5.ID, hostsTeam1[0].HostID) + require.Empty(t, hostsTeam1[0].Email) + require.False(t, hostsTeam1[0].Passing) + require.Equal(t, "serial5", hostsTeam1[0].HostHardwareSerial) + require.Equal(t, "display_name5", hostsTeam1[0].HostDisplayName) + + // + // Create a calendar event on host1 and host6. + // + now := time.Now() + _, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host1.ID, fleet.CalendarWebhookStatusPending) + require.NoError(t, err) + _, err = ds.CreateOrUpdateCalendarEvent(ctx, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host6.ID, fleet.CalendarWebhookStatusPending) + require.NoError(t, err) + + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) + require.NoError(t, err) + sort.Slice(hostsTeam1, func(i, j int) bool { + return hostsTeam1[i].HostID < hostsTeam1[j].HostID + }) + require.Len(t, hostsTeam1, 3) require.Equal(t, host1.ID, hostsTeam1[0].HostID) require.Equal(t, "foo@example.com", hostsTeam1[0].Email) require.True(t, hostsTeam1[0].Passing) @@ -3018,6 +3103,15 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.False(t, hostsTeam1[1].Passing) require.Equal(t, "serial5", hostsTeam1[1].HostHardwareSerial) require.Equal(t, "display_name5", hostsTeam1[1].HostDisplayName) + require.Equal(t, host6.ID, hostsTeam1[2].HostID) + require.Equal(t, "bar@example.com", hostsTeam1[2].Email) + require.True(t, hostsTeam1[2].Passing) + require.Equal(t, "serial6", hostsTeam1[2].HostHardwareSerial) + require.Equal(t, "display_name6", hostsTeam1[2].HostDisplayName) + + // + // Move host 4 to team1 and have it fail all team1 policies. + // err = ds.AddHostsToTeam(ctx, &team1.ID, []uint{host4.ID}) require.NoError(t, err) @@ -3029,7 +3123,10 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) require.NoError(t, err) - require.Len(t, hostsTeam1, 3) + require.Len(t, hostsTeam1, 4) + sort.Slice(hostsTeam1, func(i, j int) bool { + return hostsTeam1[i].HostID < hostsTeam1[j].HostID + }) require.Equal(t, host1.ID, hostsTeam1[0].HostID) require.Equal(t, "foo@example.com", hostsTeam1[0].Email) require.True(t, hostsTeam1[0].Passing) @@ -3045,10 +3142,44 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.False(t, hostsTeam1[2].Passing) require.Equal(t, "serial5", hostsTeam1[2].HostHardwareSerial) require.Equal(t, "display_name5", hostsTeam1[2].HostDisplayName) + require.Equal(t, host6.ID, hostsTeam1[3].HostID) + require.Equal(t, "bar@example.com", hostsTeam1[3].Email) + require.True(t, hostsTeam1[3].Passing) + require.Equal(t, "serial6", hostsTeam1[3].HostHardwareSerial) + require.Equal(t, "display_name6", hostsTeam1[3].HostDisplayName) + + // + // host3 doesn't have a calendar event so it's not returned by GetTeamHostsPolicyMemberships. + // hostsTeam2, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) require.NoError(t, err) + require.Len(t, hostsTeam2, 1) + require.Equal(t, host2.ID, hostsTeam2[0].HostID) + require.Equal(t, "foo@example.com", hostsTeam2[0].Email) + require.False(t, hostsTeam2[0].Passing) + require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) + require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) + + // + // Create a calendar event on host2 and host3. + // + now = time.Now() + _, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host2.ID, fleet.CalendarWebhookStatusPending) + require.NoError(t, err) + calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, "zoo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), host3.ID, fleet.CalendarWebhookStatusPending) + require.NoError(t, err) + + // + // Now it should return host3 because it's passing and has a calendar event. + // + + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + require.NoError(t, err) require.Len(t, hostsTeam2, 2) + sort.Slice(hostsTeam2, func(i, j int) bool { + return hostsTeam2[i].HostID < hostsTeam1[j].HostID + }) require.Equal(t, host2.ID, hostsTeam2[0].HostID) require.Equal(t, "foo@example.com", hostsTeam2[0].Email) require.False(t, hostsTeam2[0].Passing) @@ -3059,4 +3190,71 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.True(t, hostsTeam2[1].Passing) require.Equal(t, "serial3", hostsTeam2[1].HostHardwareSerial) require.Equal(t, "display_name3", hostsTeam2[1].HostDisplayName) + + // + // Make host2 pass all policies. + // + + err = ds.RecordPolicyQueryExecutions(ctx, host2, map[uint]*bool{ + team2Policy1.ID: ptr.Bool(true), + team2Policy2.ID: ptr.Bool(true), + }, time.Now(), false) + require.NoError(t, err) + + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + require.NoError(t, err) + require.Len(t, hostsTeam2, 2) + sort.Slice(hostsTeam2, func(i, j int) bool { + return hostsTeam2[i].HostID < hostsTeam1[j].HostID + }) + require.Equal(t, host2.ID, hostsTeam2[0].HostID) + require.Equal(t, "foo@example.com", hostsTeam2[0].Email) + require.True(t, hostsTeam2[0].Passing) + require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) + require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) + require.Equal(t, host3.ID, hostsTeam2[1].HostID) + require.Equal(t, "zoo@example.com", hostsTeam2[1].Email) + require.True(t, hostsTeam2[1].Passing) + require.Equal(t, "serial3", hostsTeam2[1].HostHardwareSerial) + require.Equal(t, "display_name3", hostsTeam2[1].HostDisplayName) + + // + // Delete host3 calendar event + // + + err = ds.DeleteCalendarEvent(ctx, calendarEventHost3.ID) + require.NoError(t, err) + + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + require.NoError(t, err) + require.Len(t, hostsTeam2, 1) + require.Equal(t, host2.ID, hostsTeam2[0].HostID) + require.Equal(t, "foo@example.com", hostsTeam2[0].Email) + require.True(t, hostsTeam2[0].Passing) + require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) + require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) + + // + // Edit team2Policy1 platform (which removes all its policy_membership entries). + // + + team2Policy1.Platform = "darwin" + err = ds.SavePolicy(ctx, team1Policy1, false) + require.NoError(t, err) + team1Policy1.Platform = "darwin" + err = ds.SavePolicy(ctx, team2Policy1, false) + require.NoError(t, err) + + // + // We should still get host2 as passing because it has an associated calendar event. + // + + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + require.NoError(t, err) + require.Len(t, hostsTeam2, 1) + require.Equal(t, host2.ID, hostsTeam2[0].HostID) + require.Equal(t, "foo@example.com", hostsTeam2[0].Email) + require.True(t, hostsTeam2[0].Passing) + require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) + require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) }