diff --git a/changes/17230-fleet-in-your-calendar b/changes/17230-fleet-in-your-calendar new file mode 100644 index 0000000000..299239a074 --- /dev/null +++ b/changes/17230-fleet-in-your-calendar @@ -0,0 +1,5 @@ +Added integration with Google Calendar. +- Fleet admins can enable Google Calendar integration by using a Google service account with domain-wide delegation. +- Calendar integration is enabled at the team level for specific team policies. +- If the policy is failing, a calendar event will be put on the host user's calendar for the 3rd Tuesday of the month. +- During the event, Fleet will fire a webhook. IT admins should use this webhook to trigger a script or MDM command that will remediate the issue. diff --git a/cmd/fleet/calendar_cron.go b/cmd/fleet/calendar_cron.go new file mode 100644 index 0000000000..e4b1927d55 --- /dev/null +++ b/cmd/fleet/calendar_cron.go @@ -0,0 +1,699 @@ +package main + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "time" + + "github.com/fleetdm/fleet/v4/ee/server/calendar" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service/schedule" + "github.com/go-kit/log" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" +) + +func newCalendarSchedule( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + logger kitlog.Logger, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronCalendar) + defaultInterval = 5 * time.Minute + ) + logger = kitlog.With(logger, "cron", name) + s := schedule.New( + ctx, name, instanceID, defaultInterval, ds, ds, + schedule.WithAltLockID("calendar"), + schedule.WithLogger(logger), + schedule.WithJob( + "calendar_events_cleanup", + func(ctx context.Context) error { + return cronCalendarEventsCleanup(ctx, ds, logger) + }, + ), + schedule.WithJob( + "calendar_events", + func(ctx context.Context) error { + return cronCalendarEvents(ctx, ds, logger) + }, + ), + ) + + return s, nil +} + +func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { + appConfig, err := ds.AppConfig(ctx) + if err != nil { + return fmt.Errorf("load app config: %w", err) + } + + if len(appConfig.Integrations.GoogleCalendar) == 0 { + return nil + } + googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0] + domain := googleCalendarIntegrationConfig.Domain + + teams, err := ds.ListTeams(ctx, fleet.TeamFilter{ + User: &fleet.User{ + GlobalRole: ptr.String(fleet.RoleAdmin), + }, + }, fleet.ListOptions{}) + if err != nil { + return fmt.Errorf("list teams: %w", err) + } + + for _, team := range teams { + if err := cronCalendarEventsForTeam( + ctx, ds, googleCalendarIntegrationConfig, *team, appConfig.OrgInfo.OrgName, domain, logger, + ); err != nil { + level.Info(logger).Log("msg", "events calendar cron", "team_id", team.ID, "err", err) + } + } + + return nil +} + +func createUserCalendarFromConfig(ctx context.Context, config *fleet.GoogleCalendarIntegration, logger kitlog.Logger) fleet.UserCalendar { + googleCalendarConfig := calendar.GoogleCalendarConfig{ + Context: ctx, + IntegrationConfig: config, + Logger: log.With(logger, "component", "google_calendar"), + } + return calendar.NewGoogleCalendar(&googleCalendarConfig) +} + +func cronCalendarEventsForTeam( + ctx context.Context, + ds fleet.Datastore, + calendarConfig *fleet.GoogleCalendarIntegration, + team fleet.Team, + orgName string, + domain string, + logger kitlog.Logger, +) error { + if team.Config.Integrations.GoogleCalendar == nil || + !team.Config.Integrations.GoogleCalendar.Enable { + return nil + } + + policies, err := ds.GetCalendarPolicies(ctx, team.ID) + if err != nil { + return fmt.Errorf("get calendar policy ids: %w", err) + } + + if len(policies) == 0 { + return nil + } + + logger = kitlog.With(logger, "team_id", team.ID) + + // + // 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). + // + + policyIDs := make([]uint, 0, len(policies)) + for _, policy := range policies { + policyIDs = append(policyIDs, policy.ID) + } + hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs) + if err != nil { + return fmt.Errorf("get team hosts failing policies: %w", err) + } + + var ( + passingHosts []fleet.HostPolicyMembershipData + failingHosts []fleet.HostPolicyMembershipData + failingHostsWithoutAssociatedEmail []fleet.HostPolicyMembershipData + ) + for _, host := range hosts { + if host.Passing { // host is passing all configured policies + if host.Email != "" { + passingHosts = append(passingHosts, host) + } + } else { // host is failing some of the configured policies + if host.Email == "" { + failingHostsWithoutAssociatedEmail = append(failingHostsWithoutAssociatedEmail, host) + } else { + failingHosts = append(failingHosts, host) + } + } + } + level.Debug(logger).Log( + "msg", "summary", + "team_id", team.ID, + "passing_hosts", len(passingHosts), + "failing_hosts", len(failingHosts), + "failing_hosts_without_associated_email", len(failingHostsWithoutAssociatedEmail), + ) + + // Remove calendar events from hosts that are passing the calendar policies. + // + // We execute this first to remove any calendar events for a user that is now passing + // policies on one of its hosts, and possibly create a new calendar event if they have + // another failing host on the same team. + start := time.Now() + removeCalendarEventsFromPassingHosts(ctx, ds, calendarConfig, passingHosts, logger) + level.Debug(logger).Log( + "msg", "passing_hosts", "took", time.Since(start), + ) + + // Process hosts that are failing calendar policies. + start = time.Now() + processCalendarFailingHosts(ctx, ds, calendarConfig, orgName, failingHosts, logger) + level.Debug(logger).Log( + "msg", "failing_hosts", "took", time.Since(start), + ) + + // At last we want to log the hosts that are failing and don't have an associated email. + logHostsWithoutAssociatedEmail( + domain, + failingHostsWithoutAssociatedEmail, + logger, + ) + + return nil +} + +func processCalendarFailingHosts( + ctx context.Context, + ds fleet.Datastore, + calendarConfig *fleet.GoogleCalendarIntegration, + orgName string, + hosts []fleet.HostPolicyMembershipData, + logger kitlog.Logger, +) { + hosts = filterHostsWithSameEmail(hosts) + + const consumers = 20 + hostsCh := make(chan fleet.HostPolicyMembershipData) + var wg sync.WaitGroup + + for i := 0; i < consumers; i++ { + wg.Add(+1) + go func() { + defer wg.Done() + + for host := range hostsCh { + logger := log.With(logger, "host_id", host.HostID) + + hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEventByEmail(ctx, host.Email) + + expiredEvent := false + if err == nil { + if hostCalendarEvent.HostID != host.HostID { + // This calendar event belongs to another host with this associated email, + // thus we skip this entry. + continue // continue with next host + } + if hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusPending { + // This can happen if the host went offline (and never returned results) + // after setting the webhook as pending. + continue // continue with next host + } + now := time.Now() + webhookAlreadyFired := hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusSent + if webhookAlreadyFired && sameDate(now, calendarEvent.StartTime) { + // If the webhook already fired today and the policies are still failing + // we give a grace period of one day for the host before we schedule a new event. + continue // continue with next host + } + if calendarEvent.EndTime.Before(now) { + expiredEvent = true + } + } + + userCalendar := createUserCalendarFromConfig(ctx, calendarConfig, logger) + if err := userCalendar.Configure(host.Email); err != nil { + level.Error(logger).Log("msg", "configure user calendar", "err", err) + continue // continue with next host + } + + switch { + case err == nil && !expiredEvent: + if err := processFailingHostExistingCalendarEvent( + ctx, ds, userCalendar, orgName, hostCalendarEvent, calendarEvent, host, + ); err != nil { + level.Info(logger).Log("msg", "process failing host existing calendar event", "err", err) + continue // continue with next host + } + case fleet.IsNotFound(err) || expiredEvent: + if err := processFailingHostCreateCalendarEvent( + ctx, ds, userCalendar, orgName, host, + ); err != nil { + level.Info(logger).Log("msg", "process failing host create calendar event", "err", err) + continue // continue with next host + } + default: + level.Error(logger).Log("msg", "get calendar event from db", "err", err) + continue // continue with next host + } + } + }() + } + + for _, host := range hosts { + hostsCh <- host + } + close(hostsCh) + + wg.Wait() +} + +func filterHostsWithSameEmail(hosts []fleet.HostPolicyMembershipData) []fleet.HostPolicyMembershipData { + minHostPerEmail := make(map[string]fleet.HostPolicyMembershipData) + for _, host := range hosts { + minHost, ok := minHostPerEmail[host.Email] + if !ok { + minHostPerEmail[host.Email] = host + continue + } + if host.HostID < minHost.HostID { + minHostPerEmail[host.Email] = host + } + } + filtered := make([]fleet.HostPolicyMembershipData, 0, len(minHostPerEmail)) + for _, host := range minHostPerEmail { + filtered = append(filtered, host) + } + return filtered +} + +func processFailingHostExistingCalendarEvent( + ctx context.Context, + ds fleet.Datastore, + calendar fleet.UserCalendar, + orgName string, + hostCalendarEvent *fleet.HostCalendarEvent, + calendarEvent *fleet.CalendarEvent, + host fleet.HostPolicyMembershipData, +) error { + updatedEvent := calendarEvent + updated := false + now := time.Now() + + if shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) { + var err error + updatedEvent, _, err = calendar.GetAndUpdateEvent(calendarEvent, func(conflict bool) string { + return generateCalendarEventBody(orgName, host.HostDisplayName, conflict) + }) + if err != nil { + return fmt.Errorf("get event calendar on db: %w", err) + } + // Even if fields haven't changed we want to update the calendar_events.updated_at below. + updated = true + // + // TODO(lucas): Check changing updatedEvent to UTC before consuming. + // + } + + if updated { + if err := ds.UpdateCalendarEvent(ctx, + calendarEvent.ID, + updatedEvent.StartTime, + updatedEvent.EndTime, + updatedEvent.Data, + ); err != nil { + return fmt.Errorf("updating event calendar on db: %w", err) + } + } + + eventInFuture := now.Before(updatedEvent.StartTime) + if eventInFuture { + // Nothing else to do as event is in the future. + return nil + } + if now.After(updatedEvent.EndTime) { + return fmt.Errorf( + "unexpected event in the past: now=%s, start_time=%s, end_time=%s", + now, updatedEvent.StartTime, updatedEvent.EndTime, + ) + } + + // + // Event happening now. + // + + if hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusSent { + return nil + } + + online, err := isHostOnline(ctx, ds, host.HostID) + if err != nil { + return fmt.Errorf("host online check: %w", err) + } + if !online { + // If host is offline then there's nothing to do. + return nil + } + + if err := ds.UpdateHostCalendarWebhookStatus(ctx, host.HostID, fleet.CalendarWebhookStatusPending); err != nil { + return fmt.Errorf("update host calendar webhook status: %w", err) + } + + // TODO(lucas): If this doesn't work at scale, then implement a special refetch + // for policies only. + if err := ds.UpdateHostRefetchRequested(ctx, host.HostID, true); err != nil { + return fmt.Errorf("refetch host: %w", err) + } + return nil +} + +func shouldReloadCalendarEvent(now time.Time, calendarEvent *fleet.CalendarEvent, hostCalendarEvent *fleet.HostCalendarEvent) bool { + // Check the user calendar every 30 minutes (and not every cron run) + // to reduce load on both Fleet and the calendar service. + if time.Since(calendarEvent.UpdatedAt) > 30*time.Minute { + return true + } + // If the event is supposed to be happening now, we want to check if the user moved/deleted the + // event on the last minute. + if eventHappeningNow(now, calendarEvent) && hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusNone { + return true + } + return false +} + +func eventHappeningNow(now time.Time, calendarEvent *fleet.CalendarEvent) bool { + return !now.Before(calendarEvent.StartTime) && now.Before(calendarEvent.EndTime) +} + +func sameDate(t1 time.Time, t2 time.Time) bool { + y1, m1, d1 := t1.Date() + y2, m2, d2 := t2.Date() + return y1 == y2 && m1 == m2 && d1 == d2 +} + +func processFailingHostCreateCalendarEvent( + ctx context.Context, + ds fleet.Datastore, + userCalendar fleet.UserCalendar, + orgName string, + host fleet.HostPolicyMembershipData, +) error { + calendarEvent, err := attemptCreatingEventOnUserCalendar(orgName, host, userCalendar) + if err != nil { + return fmt.Errorf("create event on user calendar: %w", err) + } + if _, err := ds.CreateOrUpdateCalendarEvent(ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, host.HostID, fleet.CalendarWebhookStatusNone); err != nil { + return fmt.Errorf("create calendar event on db: %w", err) + } + return nil +} + +func attemptCreatingEventOnUserCalendar( + orgName string, + host fleet.HostPolicyMembershipData, + userCalendar fleet.UserCalendar, +) (*fleet.CalendarEvent, error) { + year, month, today := time.Now().Date() + preferredDate := getPreferredCalendarEventDate(year, month, today) + for { + calendarEvent, err := userCalendar.CreateEvent( + preferredDate, func(conflict bool) string { + return generateCalendarEventBody(orgName, host.HostDisplayName, conflict) + }, + ) + var dee fleet.DayEndedError + switch { + case err == nil: + return calendarEvent, nil + case errors.As(err, &dee): + preferredDate = addBusinessDay(preferredDate) + continue + default: + return nil, fmt.Errorf("create event on user calendar: %w", err) + } + } +} + +func getPreferredCalendarEventDate(year int, month time.Month, today int) time.Time { + const ( + // 3rd Tuesday of Month + preferredWeekDay = time.Tuesday + preferredOrdinal = 3 + ) + + firstDayOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + offset := int(preferredWeekDay - firstDayOfMonth.Weekday()) + if offset < 0 { + offset += 7 + } + preferredDate := firstDayOfMonth.AddDate(0, 0, offset+(7*(preferredOrdinal-1))) + if today > preferredDate.Day() { + // We are past the preferred date, so we move to next month and calculate again. + month := month + 1 + if month == 13 { + month = 1 + year += 1 + } + return getPreferredCalendarEventDate(year, month, 1) + } + return preferredDate +} + +func addBusinessDay(date time.Time) time.Time { + nextBusinessDay := 1 + switch weekday := date.Weekday(); weekday { + case time.Friday: + nextBusinessDay += 2 + case time.Saturday: + nextBusinessDay += 1 + } + return date.AddDate(0, 0, nextBusinessDay) +} + +func removeCalendarEventsFromPassingHosts( + ctx context.Context, + ds fleet.Datastore, + calendarConfig *fleet.GoogleCalendarIntegration, + hosts []fleet.HostPolicyMembershipData, + logger kitlog.Logger, +) { + hostIDsByEmail := make(map[string][]uint) + for _, host := range hosts { + hostIDsByEmail[host.Email] = append(hostIDsByEmail[host.Email], host.HostID) + } + type emailWithHosts struct { + email string + hostIDs []uint + } + emails := make([]emailWithHosts, 0, len(hostIDsByEmail)) + for email, hostIDs := range hostIDsByEmail { + emails = append(emails, emailWithHosts{ + email: email, + hostIDs: hostIDs, + }) + } + + const consumers = 20 + emailsCh := make(chan emailWithHosts) + var wg sync.WaitGroup + + for i := 0; i < consumers; i++ { + wg.Add(+1) + go func() { + defer wg.Done() + + for email := range emailsCh { + + hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEventByEmail(ctx, email.email) + switch { + case err == nil: + if ok := slices.Contains(email.hostIDs, hostCalendarEvent.HostID); !ok { + // None of the hosts belong to this calendar event. + continue + } + case fleet.IsNotFound(err): + continue + default: + level.Error(logger).Log("msg", "get calendar event from DB", "err", err) + continue + } + userCalendar := createUserCalendarFromConfig(ctx, calendarConfig, logger) + if err := deleteCalendarEvent(ctx, ds, userCalendar, calendarEvent); err != nil { + level.Error(logger).Log("msg", "delete user calendar event", "err", err) + continue + } + } + }() + } + + for _, emailWithHostIDs := range emails { + emailsCh <- emailWithHostIDs + } + close(emailsCh) + + wg.Wait() +} + +func logHostsWithoutAssociatedEmail( + domain string, + hosts []fleet.HostPolicyMembershipData, + logger kitlog.Logger, +) { + if len(hosts) == 0 { + return + } + var hostIDs []uint + for _, host := range hosts { + hostIDs = append(hostIDs, host.HostID) + } + // Logging as debug because this might get logged every 5 minutes. + level.Debug(logger).Log( + "msg", fmt.Sprintf("no %s Google account associated with the hosts", domain), + "host_ids", fmt.Sprintf("%+v", hostIDs), + ) +} + +func generateCalendarEventBody(orgName, hostDisplayName string, conflict bool) string { + conflictStr := "" + if conflict { + conflictStr = " because there was no remaining availability" + } + return fmt.Sprintf(`Please leave your computer on and connected to power. + +Expect an automated restart. + +%s reserved this time to fix %s%s.`, orgName, hostDisplayName, conflictStr, + ) +} + +func isHostOnline(ctx context.Context, ds fleet.Datastore, hostID uint) (bool, error) { + hostLite, err := ds.HostLiteByID(ctx, hostID) + if err != nil { + return false, fmt.Errorf("get host lite: %w", err) + } + status := (&fleet.Host{ + DistributedInterval: hostLite.DistributedInterval, + ConfigTLSRefresh: hostLite.ConfigTLSRefresh, + SeenTime: hostLite.SeenTime, + }).Status(time.Now()) + + switch status { + case fleet.StatusOnline, fleet.StatusNew: + return true, nil + case fleet.StatusOffline, fleet.StatusMIA, fleet.StatusMissing: + return false, nil + default: + return false, fmt.Errorf("unknown host status: %s", status) + } +} + +func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error { + appConfig, err := ds.AppConfig(ctx) + if err != nil { + return fmt.Errorf("load app config: %w", err) + } + + var userCalendar fleet.UserCalendar + if len(appConfig.Integrations.GoogleCalendar) > 0 { + googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0] + userCalendar = createUserCalendarFromConfig(ctx, googleCalendarIntegrationConfig, logger) + } + + // If global setting is disabled, we remove all calendar events from the DB + // (we cannot delete the events from the user calendar because there's no configuration anymore). + if userCalendar == nil { + if err := deleteAllCalendarEvents(ctx, ds, nil, nil); err != nil { + return fmt.Errorf("delete all calendar events: %w", err) + } + // We've deleted all calendar events, nothing else to do. + return nil + } + + // + // Feature is configured globally, but now we have to check team by team. + // + + teams, err := ds.ListTeams(ctx, fleet.TeamFilter{ + User: &fleet.User{ + GlobalRole: ptr.String(fleet.RoleAdmin), + }, + }, fleet.ListOptions{}) + if err != nil { + return fmt.Errorf("list teams: %w", err) + } + + for _, team := range teams { + if err := deleteTeamCalendarEvents(ctx, ds, userCalendar, *team); err != nil { + level.Info(logger).Log("msg", "delete team calendar events", "team_id", team.ID, "err", err) + } + } + + // + // Delete calendar events from DB that haven't been updated for a while + // (e.g. host was transferred to another team or global). + // + + outOfDateCalendarEvents, err := ds.ListOutOfDateCalendarEvents(ctx, time.Now().Add(-48*time.Hour)) + if err != nil { + return fmt.Errorf("list out of date calendar events: %w", err) + } + for _, outOfDateCalendarEvent := range outOfDateCalendarEvents { + if err := deleteCalendarEvent(ctx, ds, userCalendar, outOfDateCalendarEvent); err != nil { + return fmt.Errorf("delete user calendar event: %w", err) + } + } + + return nil +} + +func deleteAllCalendarEvents( + ctx context.Context, + ds fleet.Datastore, + userCalendar fleet.UserCalendar, + teamID *uint, +) error { + calendarEvents, err := ds.ListCalendarEvents(ctx, teamID) + if err != nil { + return fmt.Errorf("list calendar events: %w", err) + } + for _, calendarEvent := range calendarEvents { + if err := deleteCalendarEvent(ctx, ds, userCalendar, calendarEvent); err != nil { + return fmt.Errorf("delete user calendar event: %w", err) + } + } + return nil +} + +func deleteTeamCalendarEvents( + 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 + } + return deleteAllCalendarEvents(ctx, ds, userCalendar, &team.ID) +} + +func deleteCalendarEvent(ctx context.Context, ds fleet.Datastore, userCalendar fleet.UserCalendar, calendarEvent *fleet.CalendarEvent) error { + if userCalendar != nil { + // Only delete events from the user's calendar if the event is in the future. + if eventInFuture := time.Now().Before(calendarEvent.StartTime); eventInFuture { + if err := userCalendar.Configure(calendarEvent.Email); err != nil { + return fmt.Errorf("connect to user calendar: %w", err) + } + if err := userCalendar.DeleteEvent(calendarEvent); err != nil { + return fmt.Errorf("delete calendar event: %w", err) + } + } + } + if err := ds.DeleteCalendarEvent(ctx, calendarEvent.ID); err != nil { + return fmt.Errorf("delete db calendar event: %w", err) + } + return nil +} diff --git a/cmd/fleet/calendar_cron_test.go b/cmd/fleet/calendar_cron_test.go new file mode 100644 index 0000000000..4d9133377c --- /dev/null +++ b/cmd/fleet/calendar_cron_test.go @@ -0,0 +1,637 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/ee/server/calendar" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + kitlog "github.com/go-kit/log" + + "github.com/stretchr/testify/require" +) + +func TestGetPreferredCalendarEventDate(t *testing.T) { + t.Parallel() + date := func(year int, month time.Month, day int) time.Time { + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC) + } + for _, tc := range []struct { + name string + year int + month time.Month + daysStart int + daysEnd int + + expected time.Time + }{ + { + name: "March 2024 (before 3rd Tuesday)", + year: 2024, + month: 3, + daysStart: 1, + daysEnd: 19, + + expected: date(2024, 3, 19), + }, + { + name: "March 2024 (past 3rd Tuesday)", + year: 2024, + month: 3, + daysStart: 20, + daysEnd: 31, + + expected: date(2024, 4, 16), + }, + { + name: "April 2024 (before 3rd Tuesday)", + year: 2024, + month: 4, + daysStart: 1, + daysEnd: 16, + + expected: date(2024, 4, 16), + }, + { + name: "April 2024 (after 3rd Tuesday)", + year: 2024, + month: 4, + daysStart: 17, + daysEnd: 30, + + expected: date(2024, 5, 21), + }, + { + name: "May 2024 (before 3rd Tuesday)", + year: 2024, + month: 5, + daysStart: 1, + daysEnd: 21, + + expected: date(2024, 5, 21), + }, + { + name: "May 2024 (after 3rd Tuesday)", + year: 2024, + month: 5, + daysStart: 22, + daysEnd: 31, + + expected: date(2024, 6, 18), + }, + { + name: "Dec 2024 (before 3rd Tuesday)", + year: 2024, + month: 12, + daysStart: 1, + daysEnd: 17, + + expected: date(2024, 12, 17), + }, + { + name: "Dec 2024 (after 3rd Tuesday)", + year: 2024, + month: 12, + daysStart: 18, + daysEnd: 31, + + expected: date(2025, 1, 21), + }, + } { + t.Run(tc.name, func(t *testing.T) { + for day := tc.daysStart; day <= tc.daysEnd; day++ { + actual := getPreferredCalendarEventDate(tc.year, tc.month, day) + require.NotEqual(t, actual.Weekday(), time.Saturday) + require.NotEqual(t, actual.Weekday(), time.Sunday) + require.Equal(t, tc.expected, actual) + } + }) + } +} + +// TestEventForDifferentHost tests case when event exists, but for a different host. Nothing should happen. +// The old event will eventually be cleaned up by the cleanup job, and afterward a new event will be created. +func TestEventForDifferentHost(t *testing.T) { + t.Parallel() + ds := new(mock.Store) + ctx := context.Background() + logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout)) + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + Integrations: fleet.Integrations{ + GoogleCalendar: []*fleet.GoogleCalendarIntegration{ + {}, + }, + }, + }, nil + } + teamID1 := uint(1) + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{ + { + ID: teamID1, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + }, + }, + }, + }, + }, nil + } + policyID1 := uint(10) + ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { + require.Equal(t, teamID1, teamID) + return []fleet.PolicyCalendarData{ + { + ID: policyID1, + Name: "Policy 1", + }, + }, nil + } + hostID1 := uint(100) + hostID2 := uint(101) + userEmail1 := "user@example.com" + ds.GetTeamHostsPolicyMembershipsFunc = func( + ctx context.Context, domain string, teamID uint, policyIDs []uint, + ) ([]fleet.HostPolicyMembershipData, error) { + require.Equal(t, teamID1, teamID) + require.Equal(t, []uint{policyID1}, policyIDs) + return []fleet.HostPolicyMembershipData{ + { + HostID: hostID1, + Email: userEmail1, + Passing: false, + }, + }, nil + } + // Return an existing event, but for a different host + eventTime := time.Now().Add(time.Hour) + ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + require.Equal(t, userEmail1, email) + calEvent := &fleet.CalendarEvent{ + ID: 1, + Email: email, + StartTime: eventTime, + EndTime: eventTime, + } + hcEvent := &fleet.HostCalendarEvent{ + ID: 1, + HostID: hostID2, + CalendarEventID: 1, + WebhookStatus: fleet.CalendarWebhookStatusNone, + } + return hcEvent, calEvent, nil + } + + err := cronCalendarEvents(ctx, ds, logger) + require.NoError(t, err) +} + +func TestCalendarEventsMultipleHosts(t *testing.T) { + ds := new(mock.Store) + ctx := context.Background() + logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout)) + t.Cleanup(func() { + calendar.ClearMockEvents() + }) + + // TODO(lucas): Test! + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + requestBodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + t.Logf("webhook request: %s\n", requestBodyBytes) + })) + t.Cleanup(func() { + webhookServer.Close() + }) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + Integrations: fleet.Integrations{ + GoogleCalendar: []*fleet.GoogleCalendarIntegration{ + { + Domain: "example.com", + ApiKey: map[string]string{ + fleet.GoogleCalendarEmail: "calendar-mock@example.com", + }, + }, + }, + }, + }, nil + } + + teamID1 := uint(1) + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{ + { + ID: teamID1, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: webhookServer.URL, + }, + }, + }, + }, + }, nil + } + + policyID1 := uint(10) + policyID2 := uint(11) + ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { + require.Equal(t, teamID1, teamID) + return []fleet.PolicyCalendarData{ + { + ID: policyID1, + Name: "Policy 1", + }, + { + ID: policyID2, + Name: "Policy 2", + }, + }, nil + } + + hostID1, userEmail1 := uint(100), "user1@example.com" + hostID2, userEmail2 := uint(101), "user2@example.com" + hostID3, userEmail3 := uint(102), "user3@other.com" + hostID4, userEmail4 := uint(103), "user4@other.com" + + ds.GetTeamHostsPolicyMembershipsFunc = func( + ctx context.Context, domain string, teamID uint, policyIDs []uint, + ) ([]fleet.HostPolicyMembershipData, error) { + require.Equal(t, teamID1, teamID) + require.Equal(t, []uint{policyID1, policyID2}, policyIDs) + return []fleet.HostPolicyMembershipData{ + { + HostID: hostID1, + Email: userEmail1, + Passing: false, + }, + { + HostID: hostID2, + Email: userEmail2, + Passing: true, + }, + { + HostID: hostID3, + Email: userEmail3, + Passing: false, + }, + { + HostID: hostID4, + Email: userEmail4, + Passing: true, + }, + }, nil + } + + ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + return nil, nil, notFoundErr{} + } + + ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context, + email string, + startTime, endTime time.Time, + data []byte, + hostID uint, + webhookStatus fleet.CalendarWebhookStatus, + ) (*fleet.CalendarEvent, error) { + switch email { + case userEmail1: + require.Equal(t, hostID1, hostID) + case userEmail2: + require.Equal(t, hostID2, hostID) + case userEmail3: + require.Equal(t, hostID3, hostID) + case userEmail4: + require.Equal(t, hostID4, hostID) + } + require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus) + require.NotEmpty(t, data) + require.NotZero(t, startTime) + require.NotZero(t, endTime) + // Currently, the returned calendar event is unused. + return nil, nil + } + + err := cronCalendarEvents(ctx, ds, logger) + require.NoError(t, err) +} + +type notFoundErr struct{} + +func (n notFoundErr) IsNotFound() bool { + return true +} + +func (n notFoundErr) Error() string { + return "not found" +} + +func TestCalendarEvents1KHosts(t *testing.T) { + ds := new(mock.Store) + ctx := context.Background() + var logger kitlog.Logger + if os.Getenv("CALENDAR_TEST_LOGGING") != "" { + logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stdout)) + } else { + logger = kitlog.NewNopLogger() + } + t.Cleanup(func() { + calendar.ClearMockEvents() + }) + + // TODO(lucas): Use for the test. + webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + requestBodyBytes, err := io.ReadAll(r.Body) + require.NoError(t, err) + t.Logf("webhook request: %s\n", requestBodyBytes) + })) + t.Cleanup(func() { + webhookServer.Close() + }) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + Integrations: fleet.Integrations{ + GoogleCalendar: []*fleet.GoogleCalendarIntegration{ + { + Domain: "example.com", + ApiKey: map[string]string{ + fleet.GoogleCalendarEmail: "calendar-mock@example.com", + }, + }, + }, + }, + }, nil + } + + teamID1 := uint(1) + teamID2 := uint(2) + teamID3 := uint(3) + teamID4 := uint(4) + teamID5 := uint(5) + ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) { + return []*fleet.Team{ + { + ID: teamID1, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: webhookServer.URL, + }, + }, + }, + }, + { + ID: teamID2, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: webhookServer.URL, + }, + }, + }, + }, + { + ID: teamID3, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: webhookServer.URL, + }, + }, + }, + }, + { + ID: teamID4, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: webhookServer.URL, + }, + }, + }, + }, + { + ID: teamID5, + Config: fleet.TeamConfig{ + Integrations: fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: webhookServer.URL, + }, + }, + }, + }, + }, nil + } + + policyID1 := uint(10) + policyID2 := uint(11) + policyID3 := uint(12) + policyID4 := uint(13) + policyID5 := uint(14) + policyID6 := uint(15) + policyID7 := uint(16) + policyID8 := uint(17) + policyID9 := uint(18) + policyID10 := uint(19) + ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { + switch teamID { + case teamID1: + return []fleet.PolicyCalendarData{ + { + ID: policyID1, + Name: "Policy 1", + }, + { + ID: policyID2, + Name: "Policy 2", + }, + }, nil + case teamID2: + return []fleet.PolicyCalendarData{ + { + ID: policyID3, + Name: "Policy 3", + }, + { + ID: policyID4, + Name: "Policy 4", + }, + }, nil + case teamID3: + return []fleet.PolicyCalendarData{ + { + ID: policyID5, + Name: "Policy 5", + }, + { + ID: policyID6, + Name: "Policy 6", + }, + }, nil + case teamID4: + return []fleet.PolicyCalendarData{ + { + ID: policyID7, + Name: "Policy 7", + }, + { + ID: policyID8, + Name: "Policy 8", + }, + }, nil + case teamID5: + return []fleet.PolicyCalendarData{ + { + ID: policyID9, + Name: "Policy 9", + }, + { + ID: policyID10, + Name: "Policy 10", + }, + }, nil + default: + return nil, notFoundErr{} + } + } + + hosts := make([]fleet.HostPolicyMembershipData, 0, 1000) + for i := 0; i < 1000; i++ { + hosts = append(hosts, fleet.HostPolicyMembershipData{ + Email: fmt.Sprintf("user%d@example.com", i), + Passing: i%2 == 0, + HostID: uint(i), + HostDisplayName: fmt.Sprintf("display_name%d", i), + HostHardwareSerial: fmt.Sprintf("serial%d", i), + }) + } + + ds.GetTeamHostsPolicyMembershipsFunc = func( + ctx context.Context, domain string, teamID uint, policyIDs []uint, + ) ([]fleet.HostPolicyMembershipData, error) { + var start, end int + switch teamID { + case teamID1: + start, end = 0, 200 + case teamID2: + start, end = 200, 400 + case teamID3: + start, end = 400, 600 + case teamID4: + start, end = 600, 800 + case teamID5: + start, end = 800, 1000 + } + return hosts[start:end], nil + } + + ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + return nil, nil, notFoundErr{} + } + + eventsCreated := 0 + var eventsCreatedMu sync.Mutex + + eventPerHost := make(map[uint]*fleet.CalendarEvent) + + ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context, + email string, + startTime, endTime time.Time, + data []byte, + hostID uint, + webhookStatus fleet.CalendarWebhookStatus, + ) (*fleet.CalendarEvent, error) { + require.Equal(t, fmt.Sprintf("user%d@example.com", hostID), email) + eventsCreatedMu.Lock() + eventsCreated += 1 + eventPerHost[hostID] = &fleet.CalendarEvent{ + ID: hostID, + Email: email, + StartTime: startTime, + EndTime: endTime, + Data: data, + UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{ + CreateTimestamp: fleet.CreateTimestamp{ + CreatedAt: time.Now(), + }, + UpdateTimestamp: fleet.UpdateTimestamp{ + UpdatedAt: time.Now(), + }, + }, + } + eventsCreatedMu.Unlock() + require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus) + require.NotEmpty(t, data) + require.NotZero(t, startTime) + require.NotZero(t, endTime) + // Currently, the returned calendar event is unused. + return nil, nil + } + + err := cronCalendarEvents(ctx, ds, logger) + require.NoError(t, err) + + createdCalendarEvents := calendar.ListGoogleMockEvents() + require.Equal(t, eventsCreated, 500) + require.Len(t, createdCalendarEvents, 500) + + hosts = make([]fleet.HostPolicyMembershipData, 0, 1000) + for i := 0; i < 1000; i++ { + hosts = append(hosts, fleet.HostPolicyMembershipData{ + Email: fmt.Sprintf("user%d@example.com", i), + Passing: true, + HostID: uint(i), + HostDisplayName: fmt.Sprintf("display_name%d", i), + HostHardwareSerial: fmt.Sprintf("serial%d", i), + }) + } + + ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + hostID, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(email, "user"), "@example.com")) + require.NoError(t, err) + if hostID%2 == 0 { + return nil, nil, notFoundErr{} + } + require.Contains(t, eventPerHost, uint(hostID)) + return &fleet.HostCalendarEvent{ + ID: uint(hostID), + HostID: uint(hostID), + CalendarEventID: uint(hostID), + WebhookStatus: fleet.CalendarWebhookStatusNone, + }, eventPerHost[uint(hostID)], nil + } + + ds.DeleteCalendarEventFunc = func(ctx context.Context, calendarEventID uint) error { + return nil + } + + err = cronCalendarEvents(ctx, ds, logger) + require.NoError(t, err) + + createdCalendarEvents = calendar.ListGoogleMockEvents() + require.Len(t, createdCalendarEvents, 0) +} diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 729cc31801..0971c3e39a 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -768,6 +768,18 @@ the way that the Fleet server works. } } + if license.IsPremium() { + if err := cronSchedules.StartCronSchedule( + func() (fleet.CronSchedule, error) { + return newCalendarSchedule( + ctx, instanceID, ds, logger, + ) + }, + ); err != nil { + initFatal(err, "failed to register calendar schedule") + } + } + level.Info(logger).Log("msg", fmt.Sprintf("started cron schedules: %s", strings.Join(cronSchedules.ScheduleNames(), ", "))) // StartCollectors starts a goroutine per collector, using ctx to cancel. diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index cd9340a19c..e30bd3939d 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -144,7 +144,13 @@ func TestApplyTeamSpecs(t *testing.T) { agentOpts := json.RawMessage(`{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}}`) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return &fleet.AppConfig{AgentOptions: &agentOpts, MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + return &fleet.AppConfig{ + AgentOptions: &agentOpts, + MDM: fleet.MDM{EnabledAndConfigured: true}, + Integrations: fleet.Integrations{ + GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}}, + }, + }, nil } ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { @@ -439,6 +445,46 @@ spec: HostPercentage: 25, }, *teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook, ) + + // Apply calendar integration + filename = writeTmpYml( + t, ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + integrations: + google_calendar: + enable_calendar_events: true + webhook_url: https://example.com/webhook +`, + ) + require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) + require.NotNil(t, teamsByName["team1"].Config.Integrations.GoogleCalendar) + assert.Equal( + t, fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: "https://example.com/webhook", + }, *teamsByName["team1"].Config.Integrations.GoogleCalendar, + ) + + // Apply calendar integration -- invalid webhook destination + filename = writeTmpYml( + t, ` +apiVersion: v1 +kind: team +spec: + team: + name: team1 + integrations: + google_calendar: + enable_calendar_events: true + webhook_url: bozo +`, + ) + _, err = runAppNoChecks([]string{"apply", "-f", filename}) + assert.ErrorContains(t, err, "invalid URI for request") } func writeTmpYml(t *testing.T, contents string) string { diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 8bf99d7742..07bb5b3e1b 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -331,29 +331,31 @@ func TestGetHosts(t *testing.T) { return []*fleet.HostPolicy{ { PolicyData: fleet.PolicyData{ - ID: 1, - Name: "query1", - Query: defaultPolicyQuery, - Description: "Some description", - AuthorID: ptr.Uint(1), - AuthorName: "Alice", - AuthorEmail: "alice@example.com", - Resolution: ptr.String("Some resolution"), - TeamID: ptr.Uint(1), + ID: 1, + Name: "query1", + Query: defaultPolicyQuery, + Description: "Some description", + AuthorID: ptr.Uint(1), + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + Resolution: ptr.String("Some resolution"), + TeamID: ptr.Uint(1), + CalendarEventsEnabled: true, }, Response: "passes", }, { PolicyData: fleet.PolicyData{ - ID: 2, - Name: "query2", - Query: defaultPolicyQuery, - Description: "", - AuthorID: ptr.Uint(1), - AuthorName: "Alice", - AuthorEmail: "alice@example.com", - Resolution: nil, - TeamID: nil, + ID: 2, + Name: "query2", + Query: defaultPolicyQuery, + Description: "", + AuthorID: ptr.Uint(1), + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + Resolution: nil, + TeamID: nil, + CalendarEventsEnabled: false, }, Response: "fails", }, diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 52fdba57ef..6a8fab6482 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -360,6 +360,8 @@ func TestFullGlobalGitOps(t *testing.T) { assert.Len(t, appliedScripts, 1) assert.Len(t, appliedMacProfiles, 1) assert.Len(t, appliedWinProfiles, 1) + require.Len(t, savedAppConfig.Integrations.GoogleCalendar, 1) + assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"]) } func TestFullTeamGitOps(t *testing.T) { @@ -389,6 +391,9 @@ func TestFullTeamGitOps(t *testing.T) { EnabledAndConfigured: true, WindowsEnabledAndConfigured: true, }, + Integrations: fleet.Integrations{ + GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}}, + }, }, nil } @@ -536,6 +541,8 @@ func TestFullTeamGitOps(t *testing.T) { assert.Len(t, appliedWinProfiles, 1) assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) + require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar) + assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable) // Now clear the settings tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") @@ -569,6 +576,9 @@ team_settings: assert.Equal(t, secret, enrolledSecrets[0].Secret) assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) + assert.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar) + assert.False(t, savedTeam.Config.Integrations.GoogleCalendar.Enable) + assert.Empty(t, savedTeam.Config.Integrations.GoogleCalendar) assert.Empty(t, savedTeam.Config.MDM.MacOSSettings.CustomSettings) assert.Empty(t, savedTeam.Config.MDM.WindowsSettings.CustomSettings.Value) assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.Deadline.Value) diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index cf01e91342..985bd03f3e 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -79,7 +79,8 @@ }, "integrations": { "jira": null, - "zendesk": null + "zendesk": null, + "google_calendar": null }, "mdm": { "apple_bm_terms_expired": false, diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 7597345851..1e517e952d 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -11,6 +11,7 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + google_calendar: null jira: null zendesk: null mdm: diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 216dbd75b3..25550bf507 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -119,7 +119,8 @@ }, "integrations": { "jira": null, - "zendesk": null + "zendesk": null, + "google_calendar": null }, "update_interval": { "osquery_detail": "1h0m0s", diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 3d614097fa..4ed7f3ed7a 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -11,6 +11,7 @@ spec: enable_host_users: true enable_software_inventory: false integrations: + google_calendar: null jira: null zendesk: null mdm: diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index d19784f2fd..08f5fcbf3e 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -22,7 +22,8 @@ }, "integrations": { "jira": null, - "zendesk": null + "zendesk": null, + "google_calendar": null }, "features": { "enable_host_users": true, @@ -92,7 +93,8 @@ }, "integrations": { "jira": null, - "zendesk": null + "zendesk": null, + "google_calendar": null }, "features": { "enable_host_users": false, diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index 1249b3e5fd..c0e3ff6dc6 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -9,6 +9,8 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_updates: @@ -49,6 +51,8 @@ spec: host_expiry_settings: host_expiry_enabled: true host_expiry_window: 15 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_updates: diff --git a/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json b/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json index 8c6e8dc3a7..0a05fb8b08 100644 --- a/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json +++ b/cmd/fleetctl/testdata/expectedHostDetailResponseJson.json @@ -76,7 +76,8 @@ "team_id": 1, "updated_at": "0001-01-01T00:00:00Z", "created_at": "0001-01-01T00:00:00Z", - "critical": false + "critical": false, + "calendar_events_enabled": true }, { "id": 2, @@ -91,7 +92,8 @@ "team_id": null, "updated_at": "0001-01-01T00:00:00Z", "created_at": "0001-01-01T00:00:00Z", - "critical": false + "critical": false, + "calendar_events_enabled": false } ], "status": "offline", diff --git a/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml b/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml index fc4431c9e1..5b1f81d4a1 100644 --- a/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml +++ b/cmd/fleetctl/testdata/expectedHostDetailResponseYaml.yml @@ -62,6 +62,7 @@ spec: created_at: "0001-01-01T00:00:00Z" updated_at: "0001-01-01T00:00:00Z" critical: false + calendar_events_enabled: true - author_email: "alice@example.com" author_id: 1 author_name: Alice @@ -75,6 +76,7 @@ spec: created_at: "0001-01-01T00:00:00Z" updated_at: "0001-01-01T00:00:00Z" critical: false + calendar_events_enabled: false policy_updated_at: "0001-01-01T00:00:00Z" public_ip: "" primary_ip: "" diff --git a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml index 7d0b81e96d..b487bf46e7 100644 --- a/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/global_config_no_paths.yml @@ -137,6 +137,12 @@ org_settings: integrations: jira: [] zendesk: [] + google_calendar: + - domain: example.com + api_key_json: { + "client_email": "service@example.com", + "private_key": "google_calendar_private_key", + } mdm: apple_bm_default_team: "" end_user_authentication: diff --git a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml index 3295e75bbb..4785c72a73 100644 --- a/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml +++ b/cmd/fleetctl/testdata/gitops/team_config_no_paths.yml @@ -15,6 +15,10 @@ team_settings: host_expiry_settings: host_expiry_enabled: true host_expiry_window: 30 + integrations: + google_calendar: + enable_calendar_events: true + webhook_url: https://example.com/google_calendar_webhook agent_options: command_line_flags: distributed_denylist_duration: 0 @@ -89,6 +93,7 @@ policies: description: This policy should always fail. resolution: There is no resolution for this policy. query: SELECT 1 FROM osquery_info WHERE start_time < 0; + calendar_events_enabled: true - name: Passing policy platform: linux,windows,darwin,chrome description: This policy should always pass. diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index a453a2f7f5..cf831eac67 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -11,6 +11,7 @@ spec: host_expiry_enabled: false host_expiry_window: 0 integrations: + google_calendar: null jira: null zendesk: null mdm: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 5826922729..7fed6f8836 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -11,6 +11,7 @@ spec: host_expiry_enabled: false host_expiry_window: 0 integrations: + google_calendar: null jira: null zendesk: null mdm: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 7315325b48..036b0320ec 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -9,6 +9,8 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_settings: @@ -40,6 +42,8 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_settings: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index 1cce56630c..d281e9089b 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -9,6 +9,8 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_settings: @@ -40,6 +42,8 @@ spec: host_expiry_settings: host_expiry_enabled: false host_expiry_window: 0 + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_settings: diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index c6e8b1653b..b685d3488d 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -9,6 +9,8 @@ spec: features: enable_host_users: false enable_software_inventory: false + integrations: + google_calendar: null mdm: enable_disk_encryption: false macos_settings: diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go new file mode 100644 index 0000000000..7283269dfe --- /dev/null +++ b/ee/server/calendar/google_calendar.go @@ -0,0 +1,573 @@ +package calendar + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/jwt" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + "google.golang.org/api/option" +) + +// The calendar package has the following features for testing: +// 1. High level UserCalendar interface and Low level GoogleCalendarAPI interface can have a custom implementations. +// 2. Setting "client_email" to "calendar-mock@example.com" in the API key will use a mock in-memory implementation GoogleCalendarMockAPI of GoogleCalendarAPI. +// 3. Setting FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING environment variable to "1" will strip the "plus addressing" from the user email, effectively allowing a single user +// to create multiple events in the same calendar. This is useful for load testing. For example: john+test@example.com becomes john@example.com + +const ( + eventTitle = "💻🚫Downtime" + startHour = 9 + endHour = 17 + eventLength = 30 * time.Minute + calendarID = "primary" + mockEmail = "calendar-mock@example.com" + loadEmail = "calendar-load@example.com" +) + +var ( + calendarScopes = []string{ + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/calendar.settings.readonly", + } + plusAddressing = os.Getenv("FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING") == "1" + plusAddressingRegex = regexp.MustCompile(`\+.*@`) +) + +type GoogleCalendarConfig struct { + Context context.Context + IntegrationConfig *fleet.GoogleCalendarIntegration + Logger kitlog.Logger + // Should be nil for production + API GoogleCalendarAPI +} + +// GoogleCalendar is an implementation of the UserCalendar interface that uses the +// Google Calendar API to manage events. +type GoogleCalendar struct { + config *GoogleCalendarConfig + currentUserEmail string + adjustedUserEmail string + location *time.Location +} + +func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar { + switch { + case config.API != nil: + // Use the provided API. + case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == loadEmail: + config.API = &GoogleCalendarLoadAPI{Logger: config.Logger} + case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == mockEmail: + config.API = &GoogleCalendarMockAPI{config.Logger} + default: + config.API = &GoogleCalendarLowLevelAPI{logger: config.Logger} + } + return &GoogleCalendar{ + config: config, + } +} + +type GoogleCalendarAPI interface { + Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error + GetSetting(name string) (*calendar.Setting, error) + ListEvents(timeMin, timeMax string) (*calendar.Events, error) + CreateEvent(event *calendar.Event) (*calendar.Event, error) + GetEvent(id, eTag string) (*calendar.Event, error) + DeleteEvent(id string) error +} + +type eventDetails struct { + ID string `json:"id"` + ETag string `json:"etag"` +} + +type GoogleCalendarLowLevelAPI struct { + service *calendar.Service + logger kitlog.Logger +} + +// Configure creates a new Google Calendar service using the provided credentials. +func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure( + ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string, +) error { + // Create a new calendar service + conf := &jwt.Config{ + Email: serviceAccountEmail, + Scopes: calendarScopes, + PrivateKey: []byte(privateKey), + TokenURL: google.JWTTokenURL, + Subject: userToImpersonateEmail, + } + client := conf.Client(ctx) + service, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return err + } + lowLevelAPI.service = service + return nil +} + +func adjustEmail(email string) string { + if plusAddressing { + return plusAddressingRegex.ReplaceAllString(email, "@") + } + return email +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) { + result, err := lowLevelAPI.withRetry( + func() (any, error) { + return lowLevelAPI.service.Settings.Get(name).Do() + }, + ) + return result.(*calendar.Setting), err +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { + result, err := lowLevelAPI.withRetry( + func() (any, error) { + return lowLevelAPI.service.Events.Insert(calendarID, event).Do() + }, + ) + return result.(*calendar.Event), err +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) { + result, err := lowLevelAPI.withRetry( + func() (any, error) { + return lowLevelAPI.service.Events.Get(calendarID, id).IfNoneMatch(eTag).Do() + }, + ) + return result.(*calendar.Event), err +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) { + result, err := lowLevelAPI.withRetry( + func() (any, error) { + // Default maximum number of events returned is 250, which should be sufficient for most calendars. + return lowLevelAPI.service.Events.List(calendarID). + EventTypes("default"). + OrderBy("startTime"). + SingleEvents(true). + TimeMin(timeMin). + TimeMax(timeMax). + ShowDeleted(false). + Do() + }, + ) + return result.(*calendar.Events), err +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { + _, err := lowLevelAPI.withRetry( + func() (any, error) { + return nil, lowLevelAPI.service.Events.Delete(calendarID, id).Do() + }, + ) + return err +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) withRetry(fn func() (any, error)) (any, error) { + retryStrategy := backoff.NewExponentialBackOff() + retryStrategy.MaxElapsedTime = 10 * time.Minute + var result any + err := backoff.Retry( + func() error { + var err error + result, err = fn() + if err != nil { + if isRateLimited(err) { + level.Debug(lowLevelAPI.logger).Log("msg", "rate limited by Google calendar API", "err", err) + return err + } + return backoff.Permanent(err) + } + return nil + }, retryStrategy, + ) + return result, err +} + +func (c *GoogleCalendar) Configure(userEmail string) error { + adjustedUserEmail := adjustEmail(userEmail) + err := c.config.API.Configure( + c.config.Context, c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail], + c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], adjustedUserEmail, + ) + if err != nil { + return ctxerr.Wrap(c.config.Context, err, "creating Google calendar service") + } + c.currentUserEmail = userEmail + c.adjustedUserEmail = adjustedUserEmail + // Clear the timezone offset so that it will be recalculated + c.location = nil + return nil +} + +func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) ( + *fleet.CalendarEvent, bool, error, +) { + // We assume that the Fleet event has not already ended. We will simply return it if it has not been modified. + details, err := c.unmarshalDetails(event) + if err != nil { + return nil, false, err + } + gEvent, err := c.config.API.GetEvent(details.ID, details.ETag) + var deleted bool + switch { + // http.StatusNotModified is returned sometimes, but not always, so we need to check ETag explicitly later + case googleapi.IsNotModified(err): + return event, false, nil + // http.StatusNotFound should be very rare -- Google keeps events for a while after they are deleted + case isNotFound(err): + deleted = true + case err != nil: + return nil, false, ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event") + } + if !deleted && gEvent.Status != "cancelled" { + if details.ETag != "" && details.ETag == gEvent.Etag { + // Event was not modified + return event, false, nil + } + if gEvent.End == nil || (gEvent.End.DateTime == "" && gEvent.End.Date == "") { + // We should not see this error. If we do, we can work around by treating event as deleted. + return nil, false, ctxerr.Errorf(c.config.Context, "missing end date/time for Google calendar event: %s", gEvent.Id) + } + + if gEvent.End.DateTime == "" { + // User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone. + // We won't handle all-day events at this time, and treat the event as deleted. + err = c.DeleteEvent(event) + if err != nil { + level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err) + } + deleted = true + } + + var endTime *time.Time + if !deleted { + endTime, err = c.parseDateTime(gEvent.End) + if err != nil { + return nil, false, err + } + if !endTime.After(time.Now()) { + // If event already ended, it is effectively deleted + // Delete this event to prevent confusion. This operation should be rare. + err = c.DeleteEvent(event) + if err != nil { + level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which is in the past", "err", err) + } + deleted = true + } + } + if !deleted { + if gEvent.Start == nil || (gEvent.Start.DateTime == "" && gEvent.Start.Date == "") { + // We should not see this error. If we do, we can work around by treating event as deleted. + return nil, false, ctxerr.Errorf(c.config.Context, "missing start date/time for Google calendar event: %s", gEvent.Id) + } + if gEvent.Start.DateTime == "" { + // User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone. + // We won't handle all-day events at this time, and treat the event as deleted. + err = c.DeleteEvent(event) + if err != nil { + level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err) + } + deleted = true + } + } + if !deleted { + startTime, err := c.parseDateTime(gEvent.Start) + if err != nil { + return nil, false, err + } + fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent) + if err != nil { + return nil, false, err + } + return fleetEvent, true, nil + } + } + + newStartDate := calculateNewEventDate(event.StartTime) + + fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn) + if err != nil { + return nil, false, err + } + return fleetEvent, true, nil +} + +func calculateNewEventDate(oldStartDate time.Time) time.Time { + // Note: we do not handle time changes (daylight savings time, etc.) -- assuming 1 day is always 24 hours. + newStartDate := oldStartDate.Add(24 * time.Hour) + if newStartDate.Weekday() == time.Saturday { + newStartDate = newStartDate.Add(48 * time.Hour) + } else if newStartDate.Weekday() == time.Sunday { + newStartDate = newStartDate.Add(24 * time.Hour) + } + return newStartDate +} + +func (c *GoogleCalendar) parseDateTime(eventDateTime *calendar.EventDateTime) (*time.Time, error) { + var t time.Time + var err error + if eventDateTime.TimeZone != "" { + loc := getLocation(eventDateTime.TimeZone, c.config) + t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc) + } else { + t, err = time.Parse(time.RFC3339, eventDateTime.DateTime) + } + if err != nil { + return nil, ctxerr.Wrap( + c.config.Context, err, fmt.Sprintf("parsing Google calendar event time: %s", eventDateTime.DateTime), + ) + } + return &t, nil +} + +func isNotFound(err error) bool { + if err == nil { + return false + } + var ae *googleapi.Error + ok := errors.As(err, &ae) + return ok && ae.Code == http.StatusNotFound +} + +func isAlreadyDeleted(err error) bool { + if err == nil { + return false + } + var ae *googleapi.Error + ok := errors.As(err, &ae) + return ok && ae.Code == http.StatusGone +} + +func isRateLimited(err error) bool { + if err == nil { + return false + } + var ae *googleapi.Error + ok := errors.As(err, &ae) + return ok && (ae.Code == http.StatusTooManyRequests || + (ae.Code == http.StatusForbidden && + (ae.Message == "Rate Limit Exceeded" || ae.Message == "User Rate Limit Exceeded" || ae.Message == "Calendar usage limits exceeded." || strings.HasPrefix(ae.Message, "Quota exceeded")))) +} + +func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDetails, error) { + var details eventDetails + err := json.Unmarshal(event.Data, &details) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "unmarshaling Google calendar event details") + } + if details.ID == "" { + return nil, ctxerr.Errorf(c.config.Context, "missing Google calendar event ID") + } + // ETag is optional, but we need it to check if the event was modified + return &details, nil +} + +func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) { + return c.createEvent(dayOfEvent, genBodyFn, time.Now) +} + +// createEvent creates a new event on the calendar on the given date. timeNow is a function that returns the current time. +// timeNow can be overwritten for testing +func (c *GoogleCalendar) createEvent( + dayOfEvent time.Time, genBodyFn func(conflict bool) string, timeNow func() time.Time, +) (*fleet.CalendarEvent, error) { + var err error + if c.location == nil { + c.location, err = getTimezone(c) + if err != nil { + return nil, err + } + } + + dayStart := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), startHour, 0, 0, 0, c.location) + dayEnd := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), endHour, 0, 0, 0, c.location) + + now := timeNow().In(c.location) + if dayEnd.Before(now) { + // The workday has already ended. + return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "cannot schedule an event for a day that has already ended"}) + } + + // Adjust day start if workday already started + if !dayStart.After(now) { + dayStart = now.Truncate(eventLength) + if dayStart.Before(now) { + dayStart = dayStart.Add(eventLength) + } + if !dayStart.Before(dayEnd) { + return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "no time available for event"}) + } + } + eventStart := dayStart + eventEnd := dayStart.Add(eventLength) + + events, err := c.config.API.ListEvents(dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "listing Google calendar events") + } + var conflict bool + for _, gEvent := range events.Items { + // Ignore cancelled events + if gEvent.Status == "cancelled" { + continue + } + + // Ignore all day events + if gEvent.Start == nil || gEvent.Start.DateTime == "" || gEvent.End == nil || gEvent.End.DateTime == "" { + continue + } + + // Ignore events that the user has declined + var declined bool + for _, attendee := range gEvent.Attendees { + if attendee.Email == c.adjustedUserEmail { + // The user has declined the event, so this time is open for scheduling + if attendee.ResponseStatus == "declined" { + declined = true + break + } + } + } + if declined { + continue + } + + // Ignore events that will end before our event + endTime, err := c.parseDateTime(gEvent.End) + if err != nil { + return nil, err + } + if !endTime.After(eventStart) { + continue + } + + startTime, err := c.parseDateTime(gEvent.Start) + if err != nil { + return nil, err + } + + if startTime.Before(eventEnd) { + // Event occurs during our event, so we need to adjust. + var isLastSlot bool + eventStart, eventEnd, isLastSlot, conflict = adjustEventTimes(*endTime, dayEnd) + if isLastSlot { + break + } + continue + } + // Since events are sorted by startTime, all subsequent events are after our event, so we can stop processing + break + } + + event := &calendar.Event{} + event.Start = &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)} + event.End = &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)} + event.Summary = eventTitle + event.Description = genBodyFn(conflict) + event, err = c.config.API.CreateEvent(event) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event") + } + + // Convert Google event to Fleet event + fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event) + if err != nil { + return nil, err + } + level.Debug(c.config.Logger).Log( + "msg", "created Google calendar event", "user", c.adjustedUserEmail, "startTime", eventStart, "timezone", c.location.String(), + ) + + return fleetEvent, nil +} + +func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool, conflict bool) { + eventStart = endTime.Truncate(eventLength) + if eventStart.Before(endTime) { + eventStart = eventStart.Add(eventLength) + } + eventEnd = eventStart.Add(eventLength) + // If we are at the end of the day, pick the last slot + if eventEnd.After(dayEnd) { + eventEnd = dayEnd + eventStart = eventEnd.Add(-eventLength) + isLastSlot = true + conflict = true + } else if eventEnd.Equal(dayEnd) { + isLastSlot = true + } + return eventStart, eventEnd, isLastSlot, conflict +} + +func getTimezone(gCal *GoogleCalendar) (*time.Location, error) { + config := gCal.config + setting, err := config.API.GetSetting("timezone") + if err != nil { + return nil, ctxerr.Wrap(config.Context, err, "retrieving Google calendar timezone") + } + + return getLocation(setting.Value, config), nil +} + +func getLocation(name string, config *GoogleCalendarConfig) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + // Could not load location, use EST + level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", name, "err", err) + loc, _ = time.LoadLocation("America/New_York") + } + return loc +} + +func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event) ( + *fleet.CalendarEvent, error, +) { + fleetEvent := &fleet.CalendarEvent{} + fleetEvent.StartTime = startTime + fleetEvent.EndTime = endTime + fleetEvent.Email = c.currentUserEmail + details := &eventDetails{ + ID: event.Id, + ETag: event.Etag, + } + detailsJson, err := json.Marshal(details) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "marshaling Google calendar event details") + } + fleetEvent.Data = detailsJson + return fleetEvent, nil +} + +func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error { + details, err := c.unmarshalDetails(event) + if err != nil { + return err + } + err = c.config.API.DeleteEvent(details.ID) + switch { + case isAlreadyDeleted(err): + return nil + case err != nil: + return ctxerr.Wrap(c.config.Context, err, "deleting Google calendar event") + } + return nil +} diff --git a/ee/server/calendar/google_calendar_integration_test.go b/ee/server/calendar/google_calendar_integration_test.go new file mode 100644 index 0000000000..7f42b23b22 --- /dev/null +++ b/ee/server/calendar/google_calendar_integration_test.go @@ -0,0 +1,132 @@ +package calendar + +import ( + "context" + "github.com/fleetdm/fleet/v4/ee/server/calendar/load_test" + "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "net/http/httptest" + "os" + "testing" + "time" +) + +type googleCalendarIntegrationTestSuite struct { + suite.Suite + server *httptest.Server + dbFile *os.File +} + +func (s *googleCalendarIntegrationTestSuite) SetupSuite() { + dbFile, err := os.CreateTemp("", "calendar.db") + s.Require().NoError(err) + handler, err := calendartest.Configure(dbFile.Name()) + s.Require().NoError(err) + server := httptest.NewUnstartedServer(handler) + server.Listener.Addr() + server.Start() + s.server = server +} + +func (s *googleCalendarIntegrationTestSuite) TearDownSuite() { + if s.dbFile != nil { + s.dbFile.Close() + _ = os.Remove(s.dbFile.Name()) + } + if s.server != nil { + s.server.Close() + } + calendartest.Close() +} + +// TestGoogleCalendarIntegration tests should be able to be run in parallel, but this is not natively supported by suites: https://github.com/stretchr/testify/issues/187 +// There are workarounds that can be explored. +func TestGoogleCalendarIntegration(t *testing.T) { + testingSuite := new(googleCalendarIntegrationTestSuite) + suite.Run(t, testingSuite) +} + +func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() { + t := s.T() + userEmail := "user1@example.com" + config := &GoogleCalendarConfig{ + Context: context.Background(), + IntegrationConfig: &fleet.GoogleCalendarIntegration{ + Domain: "example.com", + ApiKey: map[string]string{ + "client_email": loadEmail, + "private_key": s.server.URL, + }, + }, + Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)), + } + gCal := NewGoogleCalendar(config) + err := gCal.Configure(userEmail) + require.NoError(t, err) + genBodyFn := func(bool) string { + return "Test event" + } + eventDate := time.Now().Add(48 * time.Hour) + event, err := gCal.CreateEvent(eventDate, genBodyFn) + require.NoError(t, err) + assert.Equal(t, startHour, event.StartTime.Hour()) + assert.Equal(t, 0, event.StartTime.Minute()) + + eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.False(t, updated) + assert.Equal(t, event, eventRsp) + + err = gCal.DeleteEvent(event) + assert.NoError(t, err) + // delete again + err = gCal.DeleteEvent(event) + assert.NoError(t, err) + + // Try to get deleted event + eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event.StartTime.UTC().Truncate(24*time.Hour), eventRsp.StartTime.UTC().Truncate(24*time.Hour)) +} + +func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() { + t := s.T() + userEmail := "user2@example.com" + config := &GoogleCalendarConfig{ + Context: context.Background(), + IntegrationConfig: &fleet.GoogleCalendarIntegration{ + Domain: "example.com", + ApiKey: map[string]string{ + "client_email": loadEmail, + "private_key": s.server.URL, + }, + }, + Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)), + } + gCal := NewGoogleCalendar(config) + err := gCal.Configure(userEmail) + require.NoError(t, err) + genBodyFn := func(bool) string { + return "Test event" + } + eventDate := time.Now().Add(48 * time.Hour) + event, err := gCal.CreateEvent(eventDate, genBodyFn) + require.NoError(t, err) + assert.Equal(t, startHour, event.StartTime.Hour()) + assert.Equal(t, 0, event.StartTime.Minute()) + + currentEventTime := event.StartTime + for i := 0; i < 20; i++ { + if !(currentEventTime.Hour() == endHour-1 && currentEventTime.Minute() == 30) { + currentEventTime = currentEventTime.Add(30 * time.Minute) + } + event, err = gCal.CreateEvent(eventDate, genBodyFn) + require.NoError(t, err) + assert.Equal(t, currentEventTime.UTC(), event.StartTime.UTC()) + } + +} diff --git a/ee/server/calendar/google_calendar_load.go b/ee/server/calendar/google_calendar_load.go new file mode 100644 index 0000000000..8446af20c5 --- /dev/null +++ b/ee/server/calendar/google_calendar_load.go @@ -0,0 +1,234 @@ +package calendar + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + kitlog "github.com/go-kit/log" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + "io" + "net/http" + "net/url" + "os" +) + +// GoogleCalendarLoadAPI is used for load testing. +type GoogleCalendarLoadAPI struct { + Logger kitlog.Logger + baseUrl string + userToImpersonate string + ctx context.Context + client *http.Client +} + +// Configure creates a new Google Calendar service using the provided credentials. +func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string) error { + if lowLevelAPI.Logger == nil { + lowLevelAPI.Logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarLoadAPI", "user", userToImpersonate) + } + lowLevelAPI.baseUrl = privateKey + lowLevelAPI.userToImpersonate = userToImpersonate + lowLevelAPI.ctx = ctx + if lowLevelAPI.client == nil { + lowLevelAPI.client = fleethttp.NewClient() + } + return nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) GetSetting(name string) (*calendar.Setting, error) { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/settings") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("name", name) + query.Set("email", lowLevelAPI.userToImpersonate) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil) + if err != nil { + return nil, err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var setting calendar.Setting + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &setting) + if err != nil { + return nil, err + } + return &setting, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { + body, err := json.Marshal(event) + if err != nil { + return nil, err + } + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/add") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("email", lowLevelAPI.userToImpersonate) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "POST", reqUrl.String(), bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode != http.StatusCreated { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var rspEvent calendar.Event + body, err = io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &rspEvent) + if err != nil { + return nil, err + } + return &rspEvent, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) GetEvent(id, _ string) (*calendar.Event, error) { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("id", id) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil) + if err != nil { + return nil, err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode == http.StatusNotFound { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var rspEvent calendar.Event + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &rspEvent) + if err != nil { + return nil, err + } + return &rspEvent, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) ListEvents(timeMin string, timeMax string) (*calendar.Events, error) { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/list") + if err != nil { + return nil, err + } + query := reqUrl.Query() + query.Set("timemin", timeMin) + query.Set("timemax", timeMax) + query.Set("email", lowLevelAPI.userToImpersonate) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil) + if err != nil { + return nil, err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return nil, err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + var events calendar.Events + body, err := io.ReadAll(rsp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(body, &events) + if err != nil { + return nil, err + } + return &events, nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) DeleteEvent(id string) error { + reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/delete") + if err != nil { + return err + } + query := reqUrl.Query() + query.Set("id", id) + reqUrl.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "DELETE", reqUrl.String(), nil) + if err != nil { + return err + } + rsp, err := lowLevelAPI.client.Do(req) + if err != nil { + return err + } + defer func() { + _ = rsp.Body.Close() + }() + if rsp.StatusCode == http.StatusGone { + return &googleapi.Error{Code: http.StatusGone} + } + if rsp.StatusCode != http.StatusOK { + var data []byte + if rsp.Body != nil { + data, _ = io.ReadAll(rsp.Body) + } + return fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data)) + } + return nil +} diff --git a/ee/server/calendar/google_calendar_mock.go b/ee/server/calendar/google_calendar_mock.go new file mode 100644 index 0000000000..08e3a72e20 --- /dev/null +++ b/ee/server/calendar/google_calendar_mock.go @@ -0,0 +1,95 @@ +package calendar + +import ( + "context" + "errors" + "net/http" + "os" + "strconv" + "sync" + "time" + + kitlog "github.com/go-kit/log" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" +) + +type GoogleCalendarMockAPI struct { + logger kitlog.Logger +} + +var ( + mockEvents = make(map[string]*calendar.Event) + mu sync.Mutex + id uint64 +) + +const latency = 500 * time.Millisecond + +// Configure creates a new Google Calendar service using the provided credentials. +func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error { + if lowLevelAPI.logger == nil { + lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate) + } + return nil +} + +func (lowLevelAPI *GoogleCalendarMockAPI) GetSetting(name string) (*calendar.Setting, error) { + time.Sleep(latency) + lowLevelAPI.logger.Log("msg", "GetSetting", "name", name) + if name == "timezone" { + return &calendar.Setting{ + Id: "timezone", + Value: "America/Chicago", + }, nil + } + return nil, errors.New("setting not supported") +} + +func (lowLevelAPI *GoogleCalendarMockAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { + time.Sleep(latency) + mu.Lock() + defer mu.Unlock() + id += 1 + event.Id = strconv.FormatUint(id, 10) + lowLevelAPI.logger.Log("msg", "CreateEvent", "id", event.Id, "start", event.Start.DateTime) + mockEvents[event.Id] = event + return event, nil +} + +func (lowLevelAPI *GoogleCalendarMockAPI) GetEvent(id, _ string) (*calendar.Event, error) { + time.Sleep(latency) + mu.Lock() + defer mu.Unlock() + event, ok := mockEvents[id] + if !ok { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } + lowLevelAPI.logger.Log("msg", "GetEvent", "id", id, "start", event.Start.DateTime) + return event, nil +} + +func (lowLevelAPI *GoogleCalendarMockAPI) ListEvents(string, string) (*calendar.Events, error) { + time.Sleep(latency) + lowLevelAPI.logger.Log("msg", "ListEvents") + return &calendar.Events{}, nil +} + +func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error { + time.Sleep(latency) + mu.Lock() + defer mu.Unlock() + lowLevelAPI.logger.Log("msg", "DeleteEvent", "id", id) + delete(mockEvents, id) + return nil +} + +func ListGoogleMockEvents() map[string]*calendar.Event { + return mockEvents +} + +func ClearMockEvents() { + mu.Lock() + defer mu.Unlock() + mockEvents = make(map[string]*calendar.Event) +} diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go new file mode 100644 index 0000000000..02d024792e --- /dev/null +++ b/ee/server/calendar/google_calendar_test.go @@ -0,0 +1,650 @@ +package calendar + +import ( + "context" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/go-kit/kit/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/googleapi" + "net/http" + "os" + "testing" + "time" +) + +const ( + baseServiceEmail = "service@example.com" + basePrivateKey = "private-key" + baseUserEmail = "user@example.com" +) + +var ( + baseCtx = context.Background() + logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout)) +) + +type MockGoogleCalendarLowLevelAPI struct { + ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error + GetSettingFunc func(name string) (*calendar.Setting, error) + ListEventsFunc func(timeMin, timeMax string) (*calendar.Events, error) + CreateEventFunc func(event *calendar.Event) (*calendar.Event, error) + GetEventFunc func(id, eTag string) (*calendar.Event, error) + DeleteEventFunc func(id string) error +} + +func (m *MockGoogleCalendarLowLevelAPI) Configure( + ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string, +) error { + return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail) +} + +func (m *MockGoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) { + return m.GetSettingFunc(name) +} + +func (m *MockGoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) { + return m.ListEventsFunc(timeMin, timeMax) +} + +func (m *MockGoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) { + return m.CreateEventFunc(event) +} + +func (m *MockGoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) { + return m.GetEventFunc(id, eTag) +} + +func (m *MockGoogleCalendarLowLevelAPI) DeleteEvent(id string) error { + return m.DeleteEventFunc(id) +} + +func TestGoogleCalendar_Configure(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + assert.Equal(t, baseCtx, ctx) + assert.Equal(t, baseServiceEmail, serviceAccountEmail) + assert.Equal(t, basePrivateKey, privateKey) + assert.Equal(t, baseUserEmail, userToImpersonateEmail) + return nil + } + + // Happy path test + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + + // Configure error test + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + return assert.AnError + } + err = cal.Configure(baseUserEmail) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) { + // Do not run this test in t.Parallel(), since it involves modifying a global variable + plusAddressing = true + t.Cleanup( + func() { + plusAddressing = false + }, + ) + email := "user+my_test+email@example.com" + mockAPI := &MockGoogleCalendarLowLevelAPI{} + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + assert.Equal(t, baseCtx, ctx) + assert.Equal(t, baseServiceEmail, serviceAccountEmail) + assert.Equal(t, basePrivateKey, privateKey) + assert.Equal(t, "user@example.com", userToImpersonateEmail) + return nil + } + + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(email) + assert.NoError(t, err) +} + +func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig { + if mockAPI != nil && mockAPI.ConfigureFunc == nil { + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + return nil + } + } + config := &GoogleCalendarConfig{ + Context: context.Background(), + IntegrationConfig: &fleet.GoogleCalendarIntegration{ + ApiKey: map[string]string{ + fleet.GoogleCalendarEmail: baseServiceEmail, + fleet.GoogleCalendarPrivateKey: basePrivateKey, + }, + }, + Logger: logger, + API: mockAPI, + } + return config +} + +func TestGoogleCalendar_DeleteEvent(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + mockAPI.DeleteEventFunc = func(id string) error { + assert.Equal(t, "event-id", id) + return nil + } + + // Happy path test + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) + assert.NoError(t, err) + + // API error test + mockAPI.DeleteEventFunc = func(id string) error { + return assert.AnError + } + err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) + assert.ErrorIs(t, err, assert.AnError) + + // Event already deleted + mockAPI.DeleteEventFunc = func(id string) error { + return &googleapi.Error{Code: http.StatusGone} + } + err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)}) + assert.NoError(t, err) +} + +func TestGoogleCalendar_unmarshalDetails(t *testing.T) { + t.Parallel() + var gCal = NewGoogleCalendar(makeConfig(&MockGoogleCalendarLowLevelAPI{})) + err := gCal.Configure(baseUserEmail) + assert.NoError(t, err) + details, err := gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id","etag":"event-eTag"}`)}) + assert.NoError(t, err) + assert.Equal(t, "event-id", details.ID) + assert.Equal(t, "event-eTag", details.ETag) + + // Missing ETag is OK + details, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id"}`)}) + assert.NoError(t, err) + assert.Equal(t, "event-id", details.ID) + assert.Equal(t, "", details.ETag) + + // Bad JSON + _, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"bozo`)}) + assert.Error(t, err) + + // Missing id + _, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"myId":"event-id","etag":"event-eTag"}`)}) + assert.Error(t, err) +} + +func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + const baseETag = "event-eTag" + const baseEventID = "event-id" + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + assert.Equal(t, baseEventID, id) + assert.Equal(t, baseETag, eTag) + return &calendar.Event{ + Etag: baseETag, // ETag matches -- no modifications to event + }, nil + } + genBodyFn := func(bool) string { + t.Error("genBodyFn should not be called") + return "event-body" + } + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + + eventStartTime := time.Now().UTC() + event := &fleet.CalendarEvent{ + StartTime: eventStartTime, + EndTime: time.Now().Add(time.Hour), + Data: []byte(`{"ID":"` + baseEventID + `","ETag":"` + baseETag + `"}`), + } + + // ETag matches + retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.False(t, updated) + assert.Equal(t, event, retrievedEvent) + + // http.StatusNotModified response (ETag matches) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return nil, &googleapi.Error{Code: http.StatusNotModified} + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.False(t, updated) + assert.Equal(t, event, retrievedEvent) + + // Cannot unmarshal details + eventBadDetails := &fleet.CalendarEvent{ + StartTime: time.Now(), + EndTime: time.Now().Add(time.Hour), + Data: []byte(`{"bozo`), + } + _, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn) + assert.Error(t, err) + + // API error test + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return nil, assert.AnError + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.ErrorIs(t, err, assert.AnError) + + // Event has been modified + startTime := time.Now().Add(time.Minute).Truncate(time.Second) + endTime := time.Now().Add(time.Hour).Truncate(time.Second) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + }, nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event, retrievedEvent) + require.NotNil(t, retrievedEvent) + assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC()) + assert.Equal(t, baseUserEmail, retrievedEvent.Email) + gCal, _ := cal.(*GoogleCalendar) + details, err := gCal.unmarshalDetails(retrievedEvent) + require.NoError(t, err) + assert.Equal(t, "new-eTag", details.ETag) + assert.Equal(t, baseEventID, details.ID) + + // missing end time + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: ""}, + }, nil + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.Error(t, err) + + // missing start time + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + }, nil + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.Error(t, err) + + // Bad time format + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: "bozo"}, + }, nil + } + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.Error(t, err) + + // Event has been modified, with custom timezone. + tzId := "Africa/Kinshasa" + location, _ := time.LoadLocation(tzId) + startTime = time.Now().Add(time.Minute).Truncate(time.Second).In(location) + endTime = time.Now().Add(time.Hour).Truncate(time.Second).In(location) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.UTC().Format(time.RFC3339), TimeZone: tzId}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339), TimeZone: tzId}, + }, nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + assert.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event, retrievedEvent) + require.NotNil(t, retrievedEvent) + assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC()) + assert.Equal(t, baseUserEmail, retrievedEvent.Email) + + // 404 response (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } + mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) { + return &calendar.Setting{Value: "UTC"}, nil + } + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return &calendar.Events{}, nil + } + genBodyFn = func(conflict bool) string { + assert.False(t, conflict) + return "event-body" + } + eventCreated := false + mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { + assert.Equal(t, eventTitle, event.Summary) + assert.Equal(t, genBodyFn(false), event.Description) + event.Id = baseEventID + event.Etag = baseETag + eventCreated = true + return event, nil + } + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + assert.NotEqual(t, event, retrievedEvent) + require.NotNil(t, retrievedEvent) + assert.Equal(t, baseUserEmail, retrievedEvent.Email) + newEventDate := calculateNewEventDate(eventStartTime) + expectedStartTime := time.Date(newEventDate.Year(), newEventDate.Month(), newEventDate.Day(), startHour, 0, 0, 0, time.UTC) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) + + // cancelled (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + Status: "cancelled", + }, nil + } + eventCreated = false + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + require.NotNil(t, retrievedEvent) + assert.NotEqual(t, event, retrievedEvent) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) + + // all day event (deleted) + mockAPI.DeleteEventFunc = func(id string) error { + assert.Equal(t, baseEventID, id) + return nil + } + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{Date: startTime.Format("2006-01-02")}, + End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, + }, nil + } + eventCreated = false + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + require.NotNil(t, retrievedEvent) + assert.NotEqual(t, event, retrievedEvent) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) + + // moved in the past event (deleted) + mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { + return &calendar.Event{ + Id: baseEventID, + Etag: "new-eTag", + Start: &calendar.EventDateTime{DateTime: startTime.Add(-2 * time.Hour).Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: endTime.Add(-2 * time.Hour).Format(time.RFC3339)}, + }, nil + } + eventCreated = false + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + require.NoError(t, err) + assert.True(t, updated) + require.NotNil(t, retrievedEvent) + assert.NotEqual(t, event, retrievedEvent) + assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC()) + assert.True(t, eventCreated) +} + +func TestGoogleCalendar_CreateEvent(t *testing.T) { + t.Parallel() + mockAPI := &MockGoogleCalendarLowLevelAPI{} + const baseEventID = "event-id" + const baseETag = "event-eTag" + const eventBody = "event-body" + var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) + err := cal.Configure(baseUserEmail) + assert.NoError(t, err) + + tzId := "Africa/Kinshasa" + mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) { + return &calendar.Setting{Value: tzId}, nil + } + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return &calendar.Events{}, nil + } + mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { + assert.Equal(t, eventTitle, event.Summary) + assert.Equal(t, eventBody, event.Description) + event.Id = baseEventID + event.Etag = baseETag + return event, nil + } + genBodyFn := func(conflict bool) string { + assert.False(t, conflict) + return eventBody + } + genBodyConflictFn := func(conflict bool) string { + assert.True(t, conflict) + return eventBody + } + + // Happy path test -- empty calendar + date := time.Now().Add(48 * time.Hour) + location, _ := time.LoadLocation(tzId) + expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + _, expectedOffset := expectedStartTime.Zone() + event, err := cal.CreateEvent(date, genBodyFn) + require.NoError(t, err) + assert.Equal(t, baseUserEmail, event.Email) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + _, offset := event.StartTime.Zone() + assert.Equal(t, expectedOffset, offset) + _, offset = event.EndTime.Zone() + assert.Equal(t, expectedOffset, offset) + gCal, _ := cal.(*GoogleCalendar) + details, err := gCal.unmarshalDetails(event) + require.NoError(t, err) + assert.Equal(t, baseETag, details.ETag) + assert.Equal(t, baseEventID, details.ID) + + // Workday already ended + date = time.Now().Add(-48 * time.Hour) + _, err = cal.CreateEvent(date, genBodyFn) + assert.ErrorAs(t, err, &fleet.DayEndedError{}) + + // There is no time left in the day to schedule an event + date = time.Now().Add(48 * time.Hour) + timeNow := func() time.Time { + now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location) + return now + } + _, err = gCal.createEvent(date, genBodyFn, timeNow) + assert.ErrorAs(t, err, &fleet.DayEndedError{}) + + // Workday already started + date = time.Now().Add(48 * time.Hour) + expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location) + timeNow = func() time.Time { + return expectedStartTime + } + event, err = gCal.createEvent(date, genBodyFn, timeNow) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // Busy calendar + date = time.Now().Add(48 * time.Hour) + dayStart := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + dayEnd := time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location) + gEvents := &calendar.Events{} + // Cancelled event + gEvent := &calendar.Event{ + Id: "cancelled-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + Status: "cancelled", + } + gEvents.Items = append(gEvents.Items, gEvent) + // All day events + gEvent = &calendar.Event{ + Id: "all-day-event-id", + Start: &calendar.EventDateTime{Date: dayStart.Format(time.DateOnly)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + gEvent = &calendar.Event{ + Id: "all-day2-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{Date: dayEnd.Format(time.DateOnly)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + // User-declined event + gEvent = &calendar.Event{ + Id: "user-declined-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "declined"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + // Event before day + gEvent = &calendar.Event{ + Id: "before-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Add(-time.Hour).Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayStart.Add(-30 * time.Minute).Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + + // Event from 6am to 11am + eventStart := time.Date(date.Year(), date.Month(), date.Day(), 6, 0, 0, 0, location) + eventEnd := time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location) + gEvent = &calendar.Event{ + Id: "6-to-11-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + + // Event from 10am to 10:30am + eventStart = time.Date(date.Year(), date.Month(), date.Day(), 10, 0, 0, 0, location) + eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 10, 30, 0, 0, location) + gEvent = &calendar.Event{ + Id: "10-to-10-30-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + // Event from 11am to 11:45am + eventStart = time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location) + eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 11, 45, 0, 0, location) + gEvent = &calendar.Event{ + Id: "11-to-11-45-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + + // Event after day + eventStart = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location) + eventEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 45, 0, 0, location) + gEvent = &calendar.Event{ + Id: "after-event-id", + Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}, + Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}}, + } + gEvents.Items = append(gEvents.Items, gEvent) + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return gEvents, nil + } + expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location) + event, err = gCal.CreateEvent(date, genBodyFn) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // Full schedule -- pick the last slot + date = time.Now().Add(48 * time.Hour) + dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location) + gEvents = &calendar.Events{} + gEvent = &calendar.Event{ + Id: "9-to-5-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return gEvents, nil + } + expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location) + event, err = gCal.CreateEvent(date, genBodyConflictFn) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // Almost full schedule -- pick the last slot + date = time.Now().Add(48 * time.Hour) + dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) + dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location) + gEvents = &calendar.Events{} + gEvent = &calendar.Event{ + Id: "9-to-4-30-event-id", + Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)}, + End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)}, + } + gEvents.Items = append(gEvents.Items, gEvent) + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return gEvents, nil + } + expectedStartTime = dayEnd + event, err = gCal.CreateEvent(date, genBodyFn) + require.NoError(t, err) + assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) + assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) + + // API error in ListEvents + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return nil, assert.AnError + } + _, err = gCal.CreateEvent(date, genBodyFn) + assert.ErrorIs(t, err, assert.AnError) + + // API error in CreateEvent + mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { + return &calendar.Events{}, nil + } + mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { + return nil, assert.AnError + } + _, err = gCal.CreateEvent(date, genBodyFn) + assert.ErrorIs(t, err, assert.AnError) +} diff --git a/ee/server/calendar/load_test/calendar_http_handler.go b/ee/server/calendar/load_test/calendar_http_handler.go new file mode 100644 index 0000000000..e69c945523 --- /dev/null +++ b/ee/server/calendar/load_test/calendar_http_handler.go @@ -0,0 +1,343 @@ +// Package calendartest is not imported in production code, so it will not be compiled for Fleet server. +package calendartest + +import ( + "context" + "crypto/md5" //nolint:gosec // (only used in testing) + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + _ "github.com/mattn/go-sqlite3" + "google.golang.org/api/calendar/v3" + "hash/fnv" + "io" + "log" + "net/http" + "os" + "time" +) + +// This calendar does not support all-day events. + +var db *sql.DB +var timezones = []string{ + "America/Chicago", + "America/New_York", + "America/Los_Angeles", + "America/Anchorage", + "Pacific/Honolulu", + "America/Argentina/Buenos_Aires", + "Asia/Kolkata", + "Europe/London", + "Europe/Paris", + "Australia/Sydney", +} + +func Configure(dbPath string) (http.Handler, error) { + var err error + db, err = sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatal(err) + } + + logger := log.New(os.Stdout, "", log.LstdFlags) + logger.Println("Server is starting...") + + // Initialize the database schema if needed + err = initializeSchema() + if err != nil { + return nil, err + } + + router := http.NewServeMux() + router.HandleFunc("/settings", getSetting) + router.HandleFunc("/events", getEvent) + router.HandleFunc("/events/list", getEvents) + router.HandleFunc("/events/add", addEvent) + router.HandleFunc("/events/delete", deleteEvent) + return logging(logger)(router), nil +} + +func logging(logger *log.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer func() { + logger.Println(r.Method, r.URL.String(), r.RemoteAddr) + }() + next.ServeHTTP(w, r) + }, + ) + } +} + +func Close() { + _ = db.Close() +} + +func getSetting(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "missing name", http.StatusBadRequest) + return + } + if name != "timezone" { + http.Error(w, "unsupported setting", http.StatusNotFound) + return + } + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + timezone := getTimezone(email) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + setting := calendar.Setting{Value: timezone} + err := json.NewEncoder(w).Encode(setting) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// The timezone is determined by the user's email address +func getTimezone(email string) string { + index := hash(email) % uint32(len(timezones)) + timezone := timezones[index] + return timezone +} + +func hash(s string) uint32 { + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return h.Sum32() +} + +// getEvent handles GET /events?id=123 +func getEvent(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + sqlStmt := "SELECT email, start, end, summary, description, status FROM events WHERE id = ?" + var start, end int64 + var email, summary, description, status string + err := db.QueryRow(sqlStmt, id).Scan(&email, &start, &end, &summary, &description, &status) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + timezone := getTimezone(email) + loc, err := time.LoadLocation(timezone) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + calEvent := calendar.Event{} + calEvent.Id = id + calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)} + calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)} + calEvent.Summary = summary + calEvent.Description = description + calEvent.Status = status + calEvent.Etag = computeETag(start, end, summary, description, status) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(calEvent) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func getEvents(w http.ResponseWriter, r *http.Request) { + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + timeMin := r.URL.Query().Get("timemin") + if email == "" { + http.Error(w, "missing timemin", http.StatusBadRequest) + return + } + timeMax := r.URL.Query().Get("timemax") + if email == "" { + http.Error(w, "missing timemax", http.StatusBadRequest) + return + } + minTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMin}) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + maxTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMax}) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + sqlStmt := "SELECT id, start, end, summary, description, status FROM events WHERE email = ? AND end > ? AND start < ?" + rows, err := db.Query(sqlStmt, email, minTime.Unix(), maxTime.Unix()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + timezone := getTimezone(email) + loc, err := time.LoadLocation(timezone) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + events := calendar.Events{} + events.Items = make([]*calendar.Event, 0) + for rows.Next() { + var id, start, end int64 + var summary, description, status string + err = rows.Scan(&id, &start, &end, &summary, &description, &status) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + calEvent := calendar.Event{} + calEvent.Id = fmt.Sprintf("%d", id) + calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)} + calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)} + calEvent.Summary = summary + calEvent.Description = description + calEvent.Status = status + calEvent.Etag = computeETag(start, end, summary, description, status) + events.Items = append(events.Items, &calEvent) + } + if err = rows.Err(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + err = json.NewEncoder(w).Encode(events) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// addEvent handles POST /events/add?email=user@example.com +func addEvent(w http.ResponseWriter, r *http.Request) { + var event calendar.Event + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + err = json.Unmarshal(body, &event) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email := r.URL.Query().Get("email") + if email == "" { + http.Error(w, "missing email", http.StatusBadRequest) + return + } + start, err := parseDateTime(r.Context(), event.Start) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + end, err := parseDateTime(r.Context(), event.End) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + status := "confirmed" + sqlStmt := `INSERT INTO events (email, start, end, summary, description, status) VALUES (?, ?, ?, ?, ?, ?)` + result, err := db.Exec(sqlStmt, email, start.Unix(), end.Unix(), event.Summary, event.Description, status) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + id, err := result.LastInsertId() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + event.Id = fmt.Sprintf("%d", id) + event.Etag = computeETag(start.Unix(), end.Unix(), event.Summary, event.Description, status) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(event) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func computeETag(args ...any) string { + h := md5.New() //nolint:gosec // (only used for tests) + _, _ = fmt.Fprint(h, args...) + checksum := h.Sum(nil) + return hex.EncodeToString(checksum) +} + +// deleteEvent handles DELETE /events/delete?id=123 +func deleteEvent(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + sqlStmt := "DELETE FROM events WHERE id = ?" + _, err := db.Exec(sqlStmt, id) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "not found", http.StatusGone) + return + } +} + +func initializeSchema() error { + createTableSQL := `CREATE TABLE IF NOT EXISTS events ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "start" INTEGER NOT NULL, + "end" INTEGER NOT NULL, + "summary" TEXT NOT NULL, + "description" TEXT NOT NULL, + "status" TEXT NOT NULL + );` + _, err := db.Exec(createTableSQL) + if err != nil { + return fmt.Errorf("failed to create table: %w", err) + } + return nil +} + +func parseDateTime(ctx context.Context, eventDateTime *calendar.EventDateTime) (*time.Time, error) { + var t time.Time + var err error + if eventDateTime.TimeZone != "" { + var loc *time.Location + loc, err = time.LoadLocation(eventDateTime.TimeZone) + if err == nil { + t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc) + } + } else { + t, err = time.Parse(time.RFC3339, eventDateTime.DateTime) + } + if err != nil { + return nil, ctxerr.Wrap( + ctx, err, fmt.Sprintf("parsing calendar event time: %s", eventDateTime.DateTime), + ) + } + return &t, nil +} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 95588cd466..fe81963ab5 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/authz" @@ -195,18 +196,29 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } if payload.Integrations != nil { - // the team integrations must reference an existing global config integration. - if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil { - return nil, fleet.NewInvalidArgumentError("integrations", err.Error()) - } + if payload.Integrations.Jira != nil || payload.Integrations.Zendesk != nil { + // the team integrations must reference an existing global config integration. + if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil { + return nil, fleet.NewInvalidArgumentError("integrations", err.Error()) + } - // integrations must be unique - if err := payload.Integrations.Validate(); err != nil { - return nil, fleet.NewInvalidArgumentError("integrations", err.Error()) - } + // integrations must be unique + if err := payload.Integrations.Validate(); err != nil { + return nil, fleet.NewInvalidArgumentError("integrations", err.Error()) + } - team.Config.Integrations.Jira = payload.Integrations.Jira - team.Config.Integrations.Zendesk = payload.Integrations.Zendesk + team.Config.Integrations.Jira = payload.Integrations.Jira + team.Config.Integrations.Zendesk = payload.Integrations.Zendesk + } + // Only update the calendar integration if it's not nil + if payload.Integrations.GoogleCalendar != nil { + invalid := &fleet.InvalidArgumentError{} + _ = svc.validateTeamCalendarIntegrations(payload.Integrations.GoogleCalendar, appCfg, invalid) + if invalid.HasErrors() { + return nil, ctxerr.Wrap(ctx, invalid) + } + team.Config.Integrations.GoogleCalendar = payload.Integrations.GoogleCalendar + } } if payload.WebhookSettings != nil || payload.Integrations != nil { @@ -1068,6 +1080,15 @@ func (svc *Service) editTeamFromSpec( fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid) team.Config.WebhookSettings.HostStatusWebhook = spec.WebhookSettings.HostStatusWebhook } + + if spec.Integrations.GoogleCalendar != nil { + err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, invalid) + if err != nil { + return ctxerr.Wrap(ctx, err, "validate team calendar integrations") + } + team.Config.Integrations.GoogleCalendar = spec.Integrations.GoogleCalendar + } + if invalid.HasErrors() { return ctxerr.Wrap(ctx, invalid) } @@ -1124,7 +1145,9 @@ func (svc *Service) editTeamFromSpec( } if didUpdateMacOSEndUserAuth { - if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil { + if err := svc.updateMacOSSetupEnableEndUserAuth( + ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name, + ); err != nil { return err } } @@ -1132,6 +1155,26 @@ func (svc *Service) editTeamFromSpec( return nil } +func (svc *Service) validateTeamCalendarIntegrations( + calendarIntegration *fleet.TeamGoogleCalendarIntegration, + appCfg *fleet.AppConfig, invalid *fleet.InvalidArgumentError, +) error { + if !calendarIntegration.Enable { + return nil + } + // Check that global configs exist + if len(appCfg.Integrations.GoogleCalendar) == 0 { + invalid.Append("integrations.google_calendar.enable_calendar_events", "global Google Calendar integration is not configured") + } + // Validate URL + if u, err := url.ParseRequestURI(calendarIntegration.WebhookURL); err != nil { + invalid.Append("integrations.google_calendar.webhook_url", err.Error()) + } else if u.Scheme != "https" && u.Scheme != "http" { + invalid.Append("integrations.google_calendar.webhook_url", "webhook_url must be https or http") + } + return nil +} + func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.TeamSpec, applyUpon *fleet.MacOSSettings) error { oldCustomSettings := applyUpon.CustomSettings setFields, err := applyUpon.FromMap(spec.MDM.MacOSSettings) diff --git a/frontend/__mocks__/configMock.ts b/frontend/__mocks__/configMock.ts index 26b996662a..03e5554851 100644 --- a/frontend/__mocks__/configMock.ts +++ b/frontend/__mocks__/configMock.ts @@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = { integrations: { jira: [], zendesk: [], + google_calendar: [], }, logging: { debug: false, diff --git a/frontend/__mocks__/policyMock.ts b/frontend/__mocks__/policyMock.ts index 048dd6d496..c66c58a0bc 100644 --- a/frontend/__mocks__/policyMock.ts +++ b/frontend/__mocks__/policyMock.ts @@ -22,6 +22,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = { webhook: "Off", has_run: true, next_update_ms: 3600000, + calendar_events_enabled: true, }; const createMockPolicy = (overrides?: Partial): IPolicyStats => { diff --git a/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss b/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss index 7182a6968b..6af47c2b72 100644 --- a/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss +++ b/frontend/components/AddHostsModal/PlatformWrapper/_styles.scss @@ -51,12 +51,7 @@ } &__copy-message { - font-weight: $regular; - vertical-align: top; - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } .buttons { @@ -122,9 +117,6 @@ } &__copy-message { - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } } diff --git a/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss b/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss index c400a9f658..1b5d033a57 100644 --- a/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss +++ b/frontend/components/EnrollSecrets/EnrollSecretTable/EnrollSecretRow/_styles.scss @@ -40,10 +40,7 @@ } &__copy-message { - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } &__action-overlay { diff --git a/frontend/components/forms/FormField/FormField.tsx b/frontend/components/forms/FormField/FormField.tsx index 68d07507a7..80bf5833a2 100644 --- a/frontend/components/forms/FormField/FormField.tsx +++ b/frontend/components/forms/FormField/FormField.tsx @@ -3,6 +3,7 @@ import classnames from "classnames"; import { isEmpty } from "lodash"; import TooltipWrapper from "components/TooltipWrapper"; +import { PlacesType } from "react-tooltip-5"; // all form-field styles are defined in _global.scss, which apply here and elsewhere const baseClass = "form-field"; @@ -16,6 +17,7 @@ export interface IFormFieldProps { name: string; type: string; tooltip?: React.ReactNode; + labelTooltipPosition?: PlacesType; } const FormField = ({ @@ -27,6 +29,7 @@ const FormField = ({ name, type, tooltip, + labelTooltipPosition, }: IFormFieldProps): JSX.Element => { const renderLabel = () => { const labelWrapperClasses = classnames(`${baseClass}__label`, { @@ -45,7 +48,10 @@ const FormField = ({ > {error || (tooltip ? ( - + {label as string} ) : ( diff --git a/frontend/components/forms/fields/InputField/InputField.jsx b/frontend/components/forms/fields/InputField/InputField.jsx index 83c892eb16..72969c256b 100644 --- a/frontend/components/forms/fields/InputField/InputField.jsx +++ b/frontend/components/forms/fields/InputField/InputField.jsx @@ -33,6 +33,7 @@ class InputField extends Component { ]).isRequired, parseTarget: PropTypes.bool, tooltip: PropTypes.string, + labelTooltipPosition: PropTypes.string, helpText: PropTypes.oneOfType([ PropTypes.string, PropTypes.arrayOf(PropTypes.string), @@ -55,6 +56,7 @@ class InputField extends Component { value: "", parseTarget: false, tooltip: "", + labelTooltipPosition: "", helpText: "", enableCopy: false, ignore1password: false, @@ -124,6 +126,7 @@ class InputField extends Component { "error", "name", "tooltip", + "labelTooltipPosition", ]); const copyValue = (e) => { diff --git a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss index 89b07fdaf2..4ac1e32a41 100644 --- a/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss +++ b/frontend/components/forms/fields/InputFieldHiddenContent/_styles.scss @@ -43,9 +43,6 @@ } &__copy-message { - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } } diff --git a/frontend/components/forms/fields/Slider/Slider.tsx b/frontend/components/forms/fields/Slider/Slider.tsx index 2b368275f1..db21fe6c9a 100644 --- a/frontend/components/forms/fields/Slider/Slider.tsx +++ b/frontend/components/forms/fields/Slider/Slider.tsx @@ -6,7 +6,10 @@ import FormField from "components/forms/FormField"; import { IFormFieldProps } from "components/forms/FormField/FormField"; interface ISliderProps { - onChange: () => void; + onChange: (newValue?: { + name: string; + value: string | number | boolean; + }) => void; value: boolean; inactiveText: string; activeText: string; diff --git a/frontend/components/graphics/CalendarEventPreview.tsx b/frontend/components/graphics/CalendarEventPreview.tsx new file mode 100644 index 0000000000..4c29770a68 --- /dev/null +++ b/frontend/components/graphics/CalendarEventPreview.tsx @@ -0,0 +1,1184 @@ +import React from "react"; + +const CalendarEventPreview = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 💻 🚫  + + + + + + + + + + + + + + + + + 💻 🚫  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CalendarEventPreview; diff --git a/frontend/components/graphics/index.ts b/frontend/components/graphics/index.ts index 84e10a3711..fb3b0c5fd4 100644 --- a/frontend/components/graphics/index.ts +++ b/frontend/components/graphics/index.ts @@ -17,6 +17,7 @@ import EmptyTeams from "./EmptyTeams"; import EmptyPacks from "./EmptyPacks"; import EmptySchedule from "./EmptySchedule"; import CollectingResults from "./CollectingResults"; +import CalendarEventPreview from "./CalendarEventPreview"; export const GRAPHIC_MAP = { // Empty state graphics @@ -41,6 +42,7 @@ export const GRAPHIC_MAP = { "file-pem": FilePem, // Other graphics "collecting-results": CollectingResults, + "calendar-event-preview": CalendarEventPreview, }; export type GraphicNames = keyof typeof GRAPHIC_MAP; diff --git a/frontend/hooks/useCheckboxListStateManagement.tsx b/frontend/hooks/useCheckboxListStateManagement.tsx new file mode 100644 index 0000000000..4c1cf9d88d --- /dev/null +++ b/frontend/hooks/useCheckboxListStateManagement.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +import { IPolicy } from "interfaces/policy"; + +interface ICheckedPolicy { + name?: string; + id: number; + isChecked: boolean; +} + +const useCheckboxListStateManagement = ( + allPolicies: IPolicy[], + automatedPolicies: number[] | undefined +) => { + const [policyItems, setPolicyItems] = useState(() => { + return allPolicies.map(({ name, id }) => ({ + name, + id, + isChecked: !!automatedPolicies?.includes(id), + })); + }); + + const updatePolicyItems = (policyId: number) => { + setPolicyItems((prevItems) => + prevItems.map((policy) => + policy.id !== policyId + ? policy + : { ...policy, isChecked: !policy.isChecked } + ) + ); + }; + + return { policyItems, updatePolicyItems }; +}; + +export default useCheckboxListStateManagement; diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 1df44de33d..8eee167f12 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -4,7 +4,7 @@ import { IWebhookFailingPolicies, IWebhookSoftwareVulnerabilities, } from "interfaces/webhook"; -import { IIntegrations } from "./integration"; +import { IGlobalIntegrations } from "./integration"; export interface ILicense { tier: string; @@ -175,7 +175,7 @@ export interface IConfig { // databases_path: string; // }; webhook_settings: IWebhookSettings; - integrations: IIntegrations; + integrations: IGlobalIntegrations; logging: { debug: boolean; json: boolean; diff --git a/frontend/interfaces/integration.ts b/frontend/interfaces/integration.ts index adcbeeb7e7..f6302a67b8 100644 --- a/frontend/interfaces/integration.ts +++ b/frontend/interfaces/integration.ts @@ -60,7 +60,32 @@ export interface IIntegrationFormErrors { enableSoftwareVulnerabilities?: boolean; } -export interface IIntegrations { +export interface IGlobalCalendarIntegration { + domain: string; + api_key_json: string; +} + +interface ITeamCalendarSettings { + enable_calendar_events: boolean; + webhook_url: string; +} + +// zendesk and jira fields are coupled – if one is present, the other needs to be present. If +// one is present and the other is null/missing, the other will be nullified. google_calendar is +// separated – it can be present without the other 2 without nullifying them. +// TODO: Update these types to reflect this. + +export interface IZendeskJiraIntegrations { zendesk: IZendeskIntegration[]; jira: IJiraIntegration[]; } + +// reality is that IZendeskJiraIntegrations are optional – should be something like `extends +// Partial`, but that leads to a mess of types to resolve. +export interface IGlobalIntegrations extends IZendeskJiraIntegrations { + google_calendar?: IGlobalCalendarIntegration[] | null; +} + +export interface ITeamIntegrations extends IZendeskJiraIntegrations { + google_calendar?: ITeamCalendarSettings | null; +} diff --git a/frontend/interfaces/policy.ts b/frontend/interfaces/policy.ts index 4858de5f37..056ab70406 100644 --- a/frontend/interfaces/policy.ts +++ b/frontend/interfaces/policy.ts @@ -40,6 +40,7 @@ export interface IPolicy { created_at: string; updated_at: string; critical: boolean; + calendar_events_enabled: boolean; } // Used on the manage hosts page and other places where aggregate stats are displayed @@ -90,6 +91,7 @@ export interface IPolicyFormData { query?: string | number | boolean | undefined; team_id?: number; id?: number; + calendar_events_enabled?: boolean; } export interface IPolicyNew { diff --git a/frontend/interfaces/team.ts b/frontend/interfaces/team.ts index 435075902a..8fa4726022 100644 --- a/frontend/interfaces/team.ts +++ b/frontend/interfaces/team.ts @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; import { IConfigFeatures, IWebhookSettings } from "./config"; import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret"; -import { IIntegrations } from "./integration"; +import { ITeamIntegrations } from "./integration"; import { UserRole } from "./user"; export default PropTypes.shape({ @@ -82,7 +82,7 @@ export type ITeamWebhookSettings = Pick< */ export interface ITeamAutomationsConfig { webhook_settings: ITeamWebhookSettings; - integrations: IIntegrations; + integrations: ITeamIntegrations; } /** diff --git a/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss b/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss index fee0ee67cc..bca40979df 100644 --- a/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss +++ b/frontend/pages/AccountPage/APITokenModal/TokenSecretField/_styles.scss @@ -31,12 +31,7 @@ } &__copy-message { - font-weight: $regular; - vertical-align: top; - background-color: $ui-light-grey; - border: solid 1px #e2e4ea; - border-radius: 10px; - padding: 2px 6px; + @include copy-message; } &__secret-download-icon { diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 0d513c3481..20ccb325e4 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -11,7 +11,7 @@ import { import { IJiraIntegration, IZendeskIntegration, - IIntegrations, + IZendeskJiraIntegrations, } from "interfaces/integration"; import { ITeamConfig } from "interfaces/team"; import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook"; @@ -186,7 +186,9 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => { const vulnWebhookSettings = softwareConfig?.webhook_settings?.vulnerabilities_webhook; const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook; - const isVulnIntegrationEnabled = (integrations?: IIntegrations) => { + const isVulnIntegrationEnabled = ( + integrations?: IZendeskJiraIntegrations + ) => { return ( !!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) || !!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities) diff --git a/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx b/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx index 002bca8924..2869dd799e 100644 --- a/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx +++ b/frontend/pages/SoftwarePage/components/ManageSoftwareAutomationsModal/ManageSoftwareAutomationsModal.tsx @@ -8,7 +8,7 @@ import { IJiraIntegration, IZendeskIntegration, IIntegration, - IIntegrations, + IGlobalIntegrations, IIntegrationType, } from "interfaces/integration"; import { @@ -124,7 +124,7 @@ const ManageAutomationsModal = ({ } }, [destinationUrl]); - const { data: integrations } = useQuery( + const { data: integrations } = useQuery( ["integrations"], () => configAPI.loadAll(), { diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx index 870444974b..a3f8734da8 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationNavItems.tsx @@ -4,32 +4,34 @@ import { ISideNavItem } from "../components/SideNav/SideNav"; import Integrations from "./cards/Integrations"; import Mdm from "./cards/MdmSettings/MdmSettings"; import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment"; +import Calendars from "./cards/Calendars/Calendars"; -const getFilteredIntegrationSettingsNavItems = ( - isSandboxMode = false -): ISideNavItem[] => { - return [ - // TODO: types - { - title: "Ticket destinations", - urlSection: "ticket-destinations", - path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS, - Card: Integrations, - }, - { - title: "Mobile device management (MDM)", - urlSection: "mdm", - path: PATHS.ADMIN_INTEGRATIONS_MDM, - Card: Mdm, - exclude: isSandboxMode, - }, - { - title: "Automatic enrollment", - urlSection: "automatic-enrollment", - path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT, - Card: AutomaticEnrollment, - }, - ].filter((navItem) => !navItem.exclude); -}; +const integrationSettingsNavItems: ISideNavItem[] = [ + // TODO: types + { + title: "Ticket destinations", + urlSection: "ticket-destinations", + path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS, + Card: Integrations, + }, + { + title: "Mobile device management (MDM)", + urlSection: "mdm", + path: PATHS.ADMIN_INTEGRATIONS_MDM, + Card: Mdm, + }, + { + title: "Automatic enrollment", + urlSection: "automatic-enrollment", + path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT, + Card: AutomaticEnrollment, + }, + { + title: "Calendars", + urlSection: "calendars", + path: PATHS.ADMIN_INTEGRATIONS_CALENDARS, + Card: Calendars, + }, +]; -export default getFilteredIntegrationSettingsNavItems; +export default integrationSettingsNavItems; diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx index 019a219d51..bae02c33ca 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationsPage.tsx @@ -1,9 +1,8 @@ -import { AppContext } from "context/app"; -import React, { useContext } from "react"; +import React from "react"; import { InjectedRouter, Params } from "react-router/lib/Router"; import SideNav from "../components/SideNav"; -import getFilteredIntegrationSettingsNavItems from "./IntegrationNavItems"; +import integrationSettingsNavItems from "./IntegrationNavItems"; const baseClass = "integrations"; @@ -16,9 +15,8 @@ const IntegrationsPage = ({ router, params, }: IIntegrationSettingsPageProps) => { - const { isSandboxMode } = useContext(AppContext); const { section } = params; - const navItems = getFilteredIntegrationSettingsNavItems(isSandboxMode); + const navItems = integrationSettingsNavItems; const DEFAULT_SETTINGS_SECTION = navItems[0]; const currentSection = navItems.find((item) => item.urlSection === section) ?? diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx new file mode 100644 index 0000000000..da22ea4801 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/Calendars.tsx @@ -0,0 +1,428 @@ +import React, { useState, useContext, useCallback } from "react"; +import { useQuery } from "react-query"; + +import { IConfig } from "interfaces/config"; +import { NotificationContext } from "context/notification"; +import { AppContext } from "context/app"; +import configAPI from "services/entities/config"; +// @ts-ignore +import { stringToClipboard } from "utilities/copy_text"; + +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import Button from "components/buttons/Button"; +import SectionHeader from "components/SectionHeader"; +import CustomLink from "components/CustomLink"; +import Spinner from "components/Spinner"; +import DataError from "components/DataError"; +import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage"; +import Icon from "components/Icon"; + +const CREATING_SERVICE_ACCOUNT = + "https://www.fleetdm.com/learn-more-about/creating-service-accounts"; +const GOOGLE_WORKSPACE_DOMAINS = + "https://www.fleetdm.com/learn-more-about/google-workspace-domains"; +const DOMAIN_WIDE_DELEGATION = + "https://www.fleetdm.com/learn-more-about/domain-wide-delegation"; +const ENABLING_CALENDAR_API = + "fleetdm.com/learn-more-about/enabling-calendar-api"; +const OAUTH_SCOPES = + "https://www.googleapis.com/auth/calendar.events,https://www.googleapis.com/auth/calendar.settings.readonly"; + +const API_KEY_JSON_PLACEHOLDER = `{ + "type": "service_account", + "project_id": "fleet-in-your-calendar", + "private_key_id": "", + "private_key": "-----BEGIN PRIVATE KEY----\\n\\n-----END PRIVATE KEY-----\\n", + "client_email": "fleet-calendar-events@fleet-in-your-calendar.iam.gserviceaccount.com", + "client_id": "", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/fleet-calendar-events%40fleet-in-your-calendar.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +}`; + +interface IFormField { + name: string; + value: string | boolean | number; +} + +interface ICalendarsFormErrors { + domain?: string | null; + apiKeyJson?: string | null; +} + +interface ICalendarsFormData { + domain?: string; + apiKeyJson?: string; +} + +// Used to surface error.message in UI of unknown error type +type ErrorWithMessage = { + message: string; + [key: string]: unknown; +}; + +const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { + return (error as ErrorWithMessage).message !== undefined; +}; + +const baseClass = "calendars-integration"; + +const Calendars = (): JSX.Element => { + const { renderFlash } = useContext(NotificationContext); + const { isPremiumTier } = useContext(AppContext); + + const [formData, setFormData] = useState({ + domain: "", + apiKeyJson: "", + }); + const [isUpdatingSettings, setIsUpdatingSettings] = useState(false); + const [formErrors, setFormErrors] = useState({}); + const [copyMessage, setCopyMessage] = useState(""); + + const { + isLoading: isLoadingAppConfig, + refetch: refetchConfig, + error: errorAppConfig, + } = useQuery(["config"], () => configAPI.loadAll(), { + select: (data: IConfig) => data, + onSuccess: (data) => { + if (data.integrations.google_calendar) { + setFormData({ + domain: data.integrations.google_calendar[0].domain, + // Formats string for better UI readability + apiKeyJson: JSON.stringify( + data.integrations.google_calendar[0].api_key_json, + null, + "\t" + ), + }); + } + }, + }); + + const { apiKeyJson, domain } = formData; + + const validateForm = (curFormData: ICalendarsFormData) => { + const errors: ICalendarsFormErrors = {}; + + // Must set all keys or no keys at all + if (!curFormData.apiKeyJson && !!curFormData.domain) { + errors.apiKeyJson = "API key JSON must be present"; + } + if (!curFormData.domain && !!curFormData.apiKeyJson) { + errors.domain = "Domain must be present"; + } + if (curFormData.apiKeyJson) { + try { + JSON.parse(curFormData.apiKeyJson); + } catch (e: unknown) { + if (isErrorWithMessage(e)) { + errors.apiKeyJson = e.message.toString(); + } else { + throw e; + } + } + } + return errors; + }; + + const onInputChange = useCallback( + ({ name, value }: IFormField) => { + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + setFormErrors(validateForm(newFormData)); + }, + [formData] + ); + + const onFormSubmit = async (evt: React.MouseEvent) => { + setIsUpdatingSettings(true); + + evt.preventDefault(); + + // Format for API + const formDataToSubmit = + formData.apiKeyJson === "" && formData.domain === "" + ? [] // Send empty array if no keys are set + : [ + { + domain: formData.domain, + api_key_json: + (formData.apiKeyJson && JSON.parse(formData.apiKeyJson)) || + null, + }, + ]; + + // Update integrations.google_calendar only + const destination = { + google_calendar: formDataToSubmit, + }; + + try { + await configAPI.update({ integrations: destination }); + renderFlash( + "success", + "Successfully saved calendar integration settings" + ); + refetchConfig(); + } catch (e) { + renderFlash("error", "Could not save calendar integration settings"); + } finally { + setIsUpdatingSettings(false); + } + }; + + const renderOauthLabel = () => { + const onCopyOauthScopes = (evt: React.MouseEvent) => { + evt.preventDefault(); + + stringToClipboard(OAUTH_SCOPES) + .then(() => setCopyMessage(() => "Copied!")) + .catch(() => setCopyMessage(() => "Copy failed")); + + // Clear message after 1 second + setTimeout(() => setCopyMessage(() => ""), 1000); + + return false; + }; + + return ( + + + {copyMessage && ( + {copyMessage} + )} + + ); + }; + + const renderForm = () => { + return ( + <> + +

+ To create calendar events for end users with failing policies, + you'll need to configure a dedicated Google Workspace service + account. +

+
+

+ 1. Go to the Service Accounts page in Google Cloud Platform.{" "} + +

+

+ 2. Create a new project for your service account. +

    +
  • + Click Create project. +
  • +
  • + Enter "Fleet calendar events" as the project name. +
  • +
  • + For "Organization" and "Location", select + your calendar's organization. +
  • +
+

+ +

+ 3. Create the service account. +

    +
  • + Click Create service account. +
  • +
  • + Set the service account name to "Fleet calendar + events". +
  • +
  • + Set the service account ID to "fleet-calendar-events". +
  • +
  • + Click Create and continue. +
  • +
  • + Click Done at the bottom of the form. (No need to + complete the optional steps.) +
  • +
+

+

+ 4. Create an API key.{" "} +

    +
  • + Click the Actions menu for your new service account. +
  • +
  • + Select Manage keys. +
  • +
  • + Click Add key > Create new key. +
  • +
  • Select the JSON key type.
  • +
  • + 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. Authorize the service account via domain-wide delegation. +

    +
  • + In Google Workspace, go to{" "} + + Security > Access and data control > API controls > + Manage Domain Wide Delegation + + .{" "} + +
  • +
  • + Under API clients, click Add new. +
  • +
  • + Enter the client ID for the service account. You can find this + in your downloaded API key JSON file ( + client_id + ), or under Advanced Settings when viewing the service + account. +
  • +
  • + For the OAuth scopes, paste the following value: + +
  • +
  • + Click Authorize. +
  • +
+

+

+ 6. Enable the Google Calendar API. +

    +
  • + In the Google Cloud console API library, go to the Google + Calendar API.{" "} + +
  • +
  • + Make sure the "Fleet calendar events" project is + selected at the top of the page. +
  • +
  • + Click Enable. +
  • +
+

+

+ You're ready to automatically schedule calendar events for end + users. +

+
+ + ); + }; + + if (!isPremiumTier) return ; + + if (isLoadingAppConfig) { +
+ +
; + } + + if (errorAppConfig) { + return ; + } + + return
{renderForm()}
; +}; + +export default Calendars; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss b/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss new file mode 100644 index 0000000000..01db771e00 --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/_styles.scss @@ -0,0 +1,62 @@ +.calendars-integration { + &__page-description { + font-size: $x-small; + color: $core-fleet-black; + } + + p { + margin: $pad-large 0; + } + + ui { + margin-block-start: $pad-small; + } + + li { + margin: $pad-small 0; + } + + form { + margin-top: $pad-large; + } + + &__configuration { + button { + align-self: flex-end; + } + } + + &__api-key-json { + min-width: 100%; // resize vertically only + height: 294px; + font-size: $x-small; + } + + #oauth-scopes { + font-family: "SourceCodePro", $monospace; + color: $core-fleet-black; + min-height: 80px; + padding: $pad-medium; + padding-right: $pad-xxlarge; + resize: none; + } + + &__oauth-scopes-copy-icon-wrapper { + display: flex; + flex-direction: row-reverse; + align-items: center; + position: relative; + top: 36px; + right: 16px; + height: 0; + gap: 0.5rem; + } + + &__copy-message { + @include copy-message; + } + + &__code { + font-family: "SourceCodePro", $monospace; + } +} diff --git a/frontend/pages/admin/IntegrationsPage/cards/Calendars/index.ts b/frontend/pages/admin/IntegrationsPage/cards/Calendars/index.ts new file mode 100644 index 0000000000..99dcd737cf --- /dev/null +++ b/frontend/pages/admin/IntegrationsPage/cards/Calendars/index.ts @@ -0,0 +1 @@ +export { default } from "./Calendars"; diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx index 75f95f836b..abe8aef4d6 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/Integrations.tsx @@ -8,7 +8,7 @@ import { IZendeskIntegration, IIntegration, IIntegrationTableData, - IIntegrations, + IGlobalIntegrations, } from "interfaces/integration"; import { IApiError } from "interfaces/errors"; @@ -69,7 +69,7 @@ const Integrations = (): JSX.Element => { isLoading: isLoadingIntegrations, error: loadingIntegrationsError, refetch: refetchIntegrations, - } = useQuery( + } = useQuery( ["integrations"], () => configAPI.loadAll(), { @@ -133,9 +133,15 @@ const Integrations = (): JSX.Element => { // Updates either integrations.jira or integrations.zendesk const destination = () => { if (integrationDestination === "jira") { - return { jira: integrationSubmitData, zendesk: zendeskIntegrations }; + return { + jira: integrationSubmitData, + zendesk: zendeskIntegrations, + }; } - return { zendesk: integrationSubmitData, jira: jiraIntegrations }; + return { + zendesk: integrationSubmitData, + jira: jiraIntegrations, + }; }; setTestingConnection(true); diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/AddIntegrationModal/AddIntegrationModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/AddIntegrationModal/AddIntegrationModal.tsx index 0dc4dc630a..ef3a693220 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/AddIntegrationModal/AddIntegrationModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/AddIntegrationModal/AddIntegrationModal.tsx @@ -4,7 +4,7 @@ import Modal from "components/Modal"; // @ts-ignore import Dropdown from "components/forms/fields/Dropdown"; import CustomLink from "components/CustomLink"; -import { IIntegration, IIntegrations } from "interfaces/integration"; +import { IIntegration, IZendeskJiraIntegrations } from "interfaces/integration"; import IntegrationForm from "../IntegrationForm"; const baseClass = "add-integration-modal"; @@ -17,7 +17,7 @@ interface IAddIntegrationModalProps { ) => void; serverErrors?: { base: string; email: string }; backendValidators: { [key: string]: string }; - integrations: IIntegrations; + integrations: IZendeskJiraIntegrations; testingConnection: boolean; } diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/EditIntegrationModal/EditIntegrationModal.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/EditIntegrationModal/EditIntegrationModal.tsx index 83d99a14fb..e5219f2708 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/EditIntegrationModal/EditIntegrationModal.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/EditIntegrationModal/EditIntegrationModal.tsx @@ -4,7 +4,7 @@ import Modal from "components/Modal"; import Spinner from "components/Spinner"; import { IIntegration, - IIntegrations, + IZendeskJiraIntegrations, IIntegrationTableData, } from "interfaces/integration"; import IntegrationForm from "../IntegrationForm"; @@ -15,7 +15,7 @@ interface IEditIntegrationModalProps { onCancel: () => void; onSubmit: (jiraIntegrationSubmitData: IIntegration[]) => void; backendValidators: { [key: string]: string }; - integrations: IIntegrations; + integrations: IZendeskJiraIntegrations; integrationEditing?: IIntegrationTableData; testingConnection: boolean; } diff --git a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx index 0cce5bb5a1..1d4bad9950 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/Integrations/components/IntegrationForm/IntegrationForm.tsx @@ -5,7 +5,7 @@ import { IIntegrationFormData, IIntegrationTableData, IIntegration, - IIntegrations, + IZendeskJiraIntegrations, IIntegrationType, } from "interfaces/integration"; @@ -26,7 +26,7 @@ interface IIntegrationFormProps { integrationDestination: string ) => void; integrationEditing?: IIntegrationTableData; - integrations: IIntegrations; + integrations: IZendeskJiraIntegrations; integrationEditingUrl?: string; integrationEditingUsername?: string; integrationEditingEmail?: string; diff --git a/frontend/pages/admin/components/SideNav/SideNav.tsx b/frontend/pages/admin/components/SideNav/SideNav.tsx index 1242cdcba9..333f3f3f93 100644 --- a/frontend/pages/admin/components/SideNav/SideNav.tsx +++ b/frontend/pages/admin/components/SideNav/SideNav.tsx @@ -12,7 +12,6 @@ export interface ISideNavItem { urlSection: string; path: string; Card: (props: T) => JSX.Element; - exclude?: boolean; } interface ISideNavProps { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss index 1152dcfb5f..04d394a3b5 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/_styles.scss @@ -1,104 +1,10 @@ .host-actions-dropdown { - .form-field { - margin: 0; + @include button-dropdown; + color: $core-fleet-black; + .Select-multi-value-wrapper { + width: 55px; } - - .Select { - position: relative; - border: 0; - height: auto; - - &.is-focused, - &:hover { - border: 0; - } - - &.is-focused:not(.is-open) { - .Select-control { - background-color: initial; - } - } - - .Select-control { - display: flex; - background-color: initial; - height: auto; - justify-content: space-between; - border: 0; - cursor: pointer; - - &:hover { - box-shadow: none; - } - - &:hover .Select-placeholder { - color: $core-vibrant-blue; - } - - .Select-placeholder { - color: $core-fleet-black; - font-size: 14px; - line-height: normal; - padding-left: 0; - margin-top: 1px; - } - - .Select-input { - height: auto; - } - - .Select-arrow-zone { - display: flex; - } - } - - .Select-multi-value-wrapper { - width: 55px; - } - - .Select-placeholder { - display: flex; - align-items: center; - } - - .Select-menu-outer { - margin-top: $pad-xsmall; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); - border-radius: $border-radius; - z-index: 6; - overflow: hidden; - border: 0; - width: 188px; - left: unset; - top: unset; - max-height: none; - padding: $pad-small; - position: absolute; - left: -120px; - - .Select-menu { - max-height: none; - } - } - - .Select-arrow { - transition: transform 0.25s ease; - } - - &:not(.is-open) { - .Select-control:hover .Select-arrow { - content: url("../assets/images/icon-chevron-blue-16x16@2x.png"); - } - } - - &.is-open { - .Select-control .Select-placeholder { - color: $core-vibrant-blue; - } - - .Select-arrow { - transform: rotate(180deg); - } - } + .Select > .Select-menu-outer { + left: -120px; } } diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 99faef93dd..a117974fa2 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -13,7 +13,6 @@ import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; import activitiesAPI, { - IActivitiesResponse, IPastActivitiesResponse, IUpcomingActivitiesResponse, } from "services/entities/activities"; diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index be95e222d7..a49d276128 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -2,7 +2,9 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import { useQuery } from "react-query"; import { InjectedRouter } from "react-router/lib/Router"; import PATHS from "router/paths"; -import { noop, isEqual } from "lodash"; +import { noop, isEqual, uniqueId } from "lodash"; + +import { Tooltip as ReactTooltip5 } from "react-tooltip-5"; import { getNextLocationPath } from "utilities/helpers"; @@ -12,7 +14,7 @@ import { TableContext } from "context/table"; import { NotificationContext } from "context/notification"; import useTeamIdParam from "hooks/useTeamIdParam"; import { IConfig, IWebhookSettings } from "interfaces/config"; -import { IIntegrations } from "interfaces/integration"; +import { IZendeskJiraIntegrations } from "interfaces/integration"; import { IPolicyStats, ILoadAllPoliciesResponse, @@ -34,6 +36,8 @@ import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; import { ITableQueryData } from "components/TableContainer/TableContainer"; import Button from "components/buttons/Button"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; import RevealButton from "components/buttons/RevealButton"; import Spinner from "components/Spinner"; import TeamsDropdown from "components/TeamsDropdown"; @@ -41,9 +45,11 @@ import TableDataError from "components/DataError"; import MainContent from "components/MainContent"; import PoliciesTable from "./components/PoliciesTable"; -import ManagePolicyAutomationsModal from "./components/ManagePolicyAutomationsModal"; +import OtherWorkflowsModal from "./components/OtherWorkflowsModal"; import AddPolicyModal from "./components/AddPolicyModal"; import DeletePolicyModal from "./components/DeletePolicyModal"; +import CalendarEventsModal from "./components/CalendarEventsModal"; +import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal"; interface IManagePoliciesPageProps { router: InjectedRouter; @@ -125,13 +131,15 @@ const ManagePolicyPage = ({ const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false); const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false); + const [ + updatingPolicyEnabledCalendarEvents, + setUpdatingPolicyEnabledCalendarEvents, + ] = useState(false); const [selectedPolicyIds, setSelectedPolicyIds] = useState([]); - const [showManageAutomationsModal, setShowManageAutomationsModal] = useState( - false - ); - const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false); + const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false); const [showAddPolicyModal, setShowAddPolicyModal] = useState(false); const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false); + const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false); const [teamPolicies, setTeamPolicies] = useState(); const [inheritedPolicies, setInheritedPolicies] = useState(); @@ -474,18 +482,30 @@ const ManagePolicyPage = ({ ] // Other dependencies can cause infinite re-renders as URL is source of truth ); - const toggleManageAutomationsModal = () => - setShowManageAutomationsModal(!showManageAutomationsModal); - - const togglePreviewPayloadModal = useCallback(() => { - setShowPreviewPayloadModal(!showPreviewPayloadModal); - }, [setShowPreviewPayloadModal, showPreviewPayloadModal]); + const toggleOtherWorkflowsModal = () => + setShowOtherWorkflowsModal(!showOtherWorkflowsModal); const toggleAddPolicyModal = () => setShowAddPolicyModal(!showAddPolicyModal); const toggleDeletePolicyModal = () => setShowDeletePolicyModal(!showDeletePolicyModal); + const toggleCalendarEventsModal = () => { + setShowCalendarEventsModal(!showCalendarEventsModal); + }; + + const onSelectAutomationOption = (option: string) => { + switch (option) { + case "calendar_events": + toggleCalendarEventsModal(); + break; + case "other_workflows": + toggleOtherWorkflowsModal(); + break; + default: + } + }; + const toggleShowInheritedPolicies = () => { // URL source of truth const locationPath = getNextLocationPath({ @@ -499,9 +519,9 @@ const ManagePolicyPage = ({ router?.replace(locationPath); }; - const handleUpdateAutomations = async (requestBody: { + const handleUpdateOtherWorkflows = async (requestBody: { webhook_settings: Pick; - integrations: IIntegrations; + integrations: IZendeskJiraIntegrations; }) => { setIsUpdatingAutomations(true); try { @@ -515,13 +535,79 @@ const ManagePolicyPage = ({ "Could not update policy automations. Please try again." ); } finally { - toggleManageAutomationsModal(); + toggleOtherWorkflowsModal(); setIsUpdatingAutomations(false); refetchConfig(); isAnyTeamSelected && refetchTeamConfig(); } }; + const updatePolicyEnabledCalendarEvents = async ( + formData: ICalendarEventsFormData + ) => { + setUpdatingPolicyEnabledCalendarEvents(true); + + try { + // update team config if either field has been changed + const responses: Promise[] = []; + if ( + formData.enabled !== + teamConfig?.integrations.google_calendar?.enable_calendar_events || + formData.url !== teamConfig?.integrations.google_calendar?.webhook_url + ) { + responses.push( + teamsAPI.update( + { + integrations: { + google_calendar: { + enable_calendar_events: formData.enabled, + webhook_url: formData.url, + }, + // These fields will never actually be changed here. See comment above + // IGlobalIntegrations definition. + zendesk: teamConfig?.integrations.zendesk || [], + jira: teamConfig?.integrations.jira || [], + }, + }, + teamIdForApi + ) + ); + } + + // update changed policies calendar events enabled + const changedPolicies = formData.policies.filter((formPolicy) => { + const prevPolicyState = teamPolicies?.find( + (policy) => policy.id === formPolicy.id + ); + return ( + formPolicy.isChecked !== prevPolicyState?.calendar_events_enabled + ); + }); + + responses.concat( + changedPolicies.map((changedPolicy) => { + return teamPoliciesAPI.update(changedPolicy.id, { + calendar_events_enabled: changedPolicy.isChecked, + team_id: teamIdForApi, + }); + }) + ); + + await Promise.all(responses); + renderFlash("success", "Successfully updated policy automations."); + } catch { + renderFlash( + "error", + "Could not update policy automations. Please try again." + ); + } finally { + toggleCalendarEventsModal(); + setUpdatingPolicyEnabledCalendarEvents(false); + refetchTeamPolicies(); + refetchTeamConfig(); + } + }; + const onAddPolicyClick = () => { setLastEditedQueryName(""); setLastEditedQueryDescription(""); @@ -687,6 +773,70 @@ const ManagePolicyPage = ({ ); }; + const getAutomationsDropdownOptions = () => { + const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1; + let calEventsLabel: React.ReactNode = "Calendar events"; + if (!isPremiumTier) { + const tipId = uniqueId(); + calEventsLabel = ( + +
+ Calendar events +
+ + Available in Fleet Premium + +
+ ); + } else if (isAllTeams) { + const tipId = uniqueId(); + calEventsLabel = ( + +
+ Calendar events +
+ + Select a team to manage +
+ calendar events. +
+
+ ); + } + + return [ + { + label: calEventsLabel, + value: "calendar_events", + disabled: !isPremiumTier || isAllTeams, + helpText: "Automatically reserve time to resolve failing policies.", + }, + { + label: "Other workflows", + value: "other_workflows", + disabled: false, + helpText: "Create tickets or fire webhooks for failing policies.", + }, + ]; + }; + + const isCalEventsConfigured = + (config?.integrations.google_calendar && + config?.integrations.google_calendar.length > 0) ?? + false; + return (
@@ -714,18 +864,15 @@ const ManagePolicyPage = ({ {showCtaButtons && (
{canManageAutomations && automationsConfig && ( - +
+ +
)} {canAddOrDeletePolicy && (
@@ -795,16 +942,14 @@ const ManagePolicyPage = ({ )}
)} - {config && automationsConfig && showManageAutomationsModal && ( - )} {showAddPolicyModal && ( @@ -822,6 +967,22 @@ const ManagePolicyPage = ({ onSubmit={onDeletePolicySubmit} /> )} + {showCalendarEventsModal && ( + + )}
); diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index 62c29c1d5b..3c88a2db1c 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -8,13 +8,57 @@ .button-wrap { display: flex; justify-content: flex-end; - min-width: 266px; + align-items: center; + gap: 8px; } } - &__manage-automations { - padding: $pad-small; - margin-right: $pad-small; + &__manage-automations-wrapper { + @include button-dropdown; + .Select-multi-value-wrapper { + width: 146px; + } + .Select > .Select-menu-outer { + left: -186px; + width: 360px; + .dropdown__help-text { + color: $ui-fleet-black-50; + } + .is-disabled * { + color: $ui-fleet-black-25; + .label-text { + font-style: normal; + // increase height to allow for broader tooltip activation area + position: absolute; + height: 34px; + width: 100%; + } + .dropdown__help-text { + // compensate for absolute label-text height + margin-top: 20px; + } + .react-tooltip { + @include tooltip-text; + font-style: normal; + text-align: center; + } + } + } + .Select-control { + margin-top: 0; + gap: 6px; + .Select-placeholder { + color: $core-vibrant-blue; + font-weight: $bold; + } + .dropdown__custom-arrow .dropdown__icon { + svg { + path { + stroke: $core-vibrant-blue-over; + } + } + } + } } &__header { diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx new file mode 100644 index 0000000000..93847411eb --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/CalendarEventsModal.tsx @@ -0,0 +1,314 @@ +import React, { useCallback, useState } from "react"; + +import { IPolicy } from "interfaces/policy"; + +import validURL from "components/forms/validators/valid_url"; + +import Button from "components/buttons/Button"; +import RevealButton from "components/buttons/RevealButton"; +import CustomLink from "components/CustomLink"; +import Slider from "components/forms/fields/Slider"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import Graphic from "components/Graphic"; +import Modal from "components/Modal"; +import Checkbox from "components/forms/fields/Checkbox"; +import { syntaxHighlight } from "utilities/helpers"; + +const baseClass = "calendar-events-modal"; + +interface IFormPolicy { + name: string; + id: number; + isChecked: boolean; +} +export interface ICalendarEventsFormData { + enabled: boolean; + url: string; + policies: IFormPolicy[]; +} + +interface ICalendarEventsModal { + onExit: () => void; + updatePolicyEnabledCalendarEvents: ( + formData: ICalendarEventsFormData + ) => void; + isUpdating: boolean; + configured: boolean; + enabled: boolean; + url: string; + policies: IPolicy[]; +} + +// allows any policy name to be the name of a form field, one of the checkboxes +type FormNames = string; + +const CalendarEventsModal = ({ + onExit, + updatePolicyEnabledCalendarEvents, + isUpdating, + configured, + enabled, + url, + policies, +}: ICalendarEventsModal) => { + const [formData, setFormData] = useState({ + enabled, + url, + policies: policies.map((policy) => ({ + name: policy.name, + id: policy.id, + isChecked: policy.calendar_events_enabled || false, + })), + }); + const [formErrors, setFormErrors] = useState>( + {} + ); + const [showPreviewCalendarEvent, setShowPreviewCalendarEvent] = useState( + false + ); + const [showExamplePayload, setShowExamplePayload] = useState(false); + + const validateCalendarEventsFormData = ( + curFormData: ICalendarEventsFormData + ) => { + const errors: Record = {}; + if (curFormData.enabled) { + const { url: curUrl } = curFormData; + if (!validURL({ url: curUrl })) { + const errorPrefix = curUrl ? `${curUrl} is not` : "Please enter"; + errors.url = `${errorPrefix} a valid resolution webhook URL`; + } + } + return errors; + }; + + // two onChange handlers to handle different levels of nesting in the form data + const onFeatureEnabledOrUrlChange = useCallback( + (newVal: { name: "enabled" | "url"; value: string | boolean }) => { + const { name, value } = newVal; + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + setFormErrors(validateCalendarEventsFormData(newFormData)); + }, + [formData] + ); + const onPolicyEnabledChange = useCallback( + (newVal: { name: FormNames; value: boolean }) => { + const { name, value } = newVal; + const newFormPolicies = formData.policies.map((formPolicy) => { + if (formPolicy.name === name) { + return { ...formPolicy, isChecked: value }; + } + return formPolicy; + }); + const newFormData = { ...formData, policies: newFormPolicies }; + setFormData(newFormData); + setFormErrors(validateCalendarEventsFormData(newFormData)); + }, + [formData] + ); + + const togglePreviewCalendarEvent = () => { + setShowPreviewCalendarEvent(!showPreviewCalendarEvent); + }; + + const renderExamplePayload = () => { + return ( + <> +
POST https://server.com/example
+
+      
+    );
+  };
+
+  const renderPolicies = () => {
+    return (
+      
+
Policies:
+ {formData.policies.map((policy) => { + const { isChecked, name, id } = policy; + return ( +
+ { + onPolicyEnabledChange({ name, value: !isChecked }); + }} + > + {name} + +
+ ); + })} + + A calendar event will be created for end users if one of their hosts + fail any of these policies.{" "} + + +
+ ); + }; + const renderPreviewCalendarEventModal = () => { + return ( + + <> +

A similar event will appear in the end user's calendar:

+ +
+ +
+ +
+ ); + }; + + const renderPlaceholderModal = () => { + return ( +
+ + + +
+ To create calendar events for end users if their hosts fail policies, + you must first connect Fleet to your Google Workspace service account. +
+
+ This can be configured in{" "} + Settings > Integrations > Calendars. +
+ +
+ +
+
+ ); + }; + + const renderConfiguredModal = () => ( +
+
+ { + onFeatureEnabledOrUrlChange({ + name: "enabled", + value: !formData.enabled, + }); + }} + inactiveText="Disabled" + activeText="Enabled" + /> + +
+
+ + { + setShowExamplePayload(!showExamplePayload); + }} + /> + {showExamplePayload && renderExamplePayload()} + {renderPolicies()} +
+
+ + +
+
+ ); + + if (showPreviewCalendarEvent) { + return renderPreviewCalendarEventModal(); + } + return ( + { + updatePolicyEnabledCalendarEvents(formData); + } + : onExit + } + className={baseClass} + width="large" + > + {configured ? renderConfiguredModal() : renderPlaceholderModal()} + + ); +}; + +export default CalendarEventsModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/_styles.scss new file mode 100644 index 0000000000..3b1952a8c3 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/_styles.scss @@ -0,0 +1,35 @@ +.calendar-events-modal { + .placeholder { + display: flex; + flex-direction: column; + gap: 24px; + line-height: 150%; + .modal-cta-wrap { + margin-top: 0; + } + } + .form-header { + display: flex; + justify-content: space-between; + .button--text-link { + white-space: nowrap; + } + } + + .form-fields { + &--disabled { + @include disabled; + } + } + + pre { + box-sizing: border-box; + margin: 0; + } +} + +.calendar-event-preview { + p { + margin: 24px 0; + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/index.ts new file mode 100644 index 0000000000..b08ecf1063 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventsModal/index.ts @@ -0,0 +1 @@ +export { default } from "./CalendarEventsModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/ExamplePayload.tsx b/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/ExamplePayload.tsx new file mode 100644 index 0000000000..4dc1c2462c --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/ExamplePayload.tsx @@ -0,0 +1,64 @@ +import React, { useContext } from "react"; +import { syntaxHighlight } from "utilities/helpers"; + +import { AppContext } from "context/app"; +import { IPolicyWebhookPreviewPayload } from "interfaces/policy"; + +const baseClass = "example-payload"; + +interface IHostPreview { + id: number; + display_name: string; + url: string; +} + +interface IExamplePayload { + timestamp: string; + policy: IPolicyWebhookPreviewPayload; + hosts: IHostPreview[]; +} + +const ExamplePayload = (): JSX.Element => { + const { isFreeTier } = useContext(AppContext); + + const json: IExamplePayload = { + timestamp: "0000-00-00T00:00:00Z", + policy: { + id: 1, + name: "Is Gatekeeper enabled?", + query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;", + description: "Checks if gatekeeper is enabled on macOS devices.", + author_id: 1, + author_name: "John", + author_email: "john@example.com", + resolution: "Turn on Gatekeeper feature in System Preferences.", + passing_host_count: 2000, + failing_host_count: 300, + critical: false, + }, + hosts: [ + { + id: 1, + display_name: "macbook-1", + url: "https://fleet.example.com/hosts/1", + }, + { + id: 2, + display_name: "macbbook-2", + url: "https://fleet.example.com/hosts/2", + }, + ], + }; + if (isFreeTier) { + delete json.policy.critical; + } + + return ( +
+
POST https://server.com/example
+
+    
+ ); +}; + +export default ExamplePayload; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/_styles.scss new file mode 100644 index 0000000000..2297445b92 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/_styles.scss @@ -0,0 +1,9 @@ +.example-payload { + display: flex; + flex-direction: column; + gap: $pad-large; + + pre { + margin: 0; + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/index.ts new file mode 100644 index 0000000000..a9ab7d050d --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ExamplePayload/index.ts @@ -0,0 +1 @@ +export { default } from "./ExamplePayload"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/PreviewTicketModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/ExampleTicket.tsx similarity index 52% rename from frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/PreviewTicketModal.tsx rename to frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/ExampleTicket.tsx index c46d2e4e54..1ff58279f6 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/PreviewTicketModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/ExampleTicket.tsx @@ -1,28 +1,24 @@ import React, { useContext } from "react"; import { AppContext } from "context/app"; -import Modal from "components/Modal"; -import Button from "components/buttons/Button"; -import CustomLink from "components/CustomLink"; import { IIntegrationType } from "interfaces/integration"; +import Card from "components/Card"; import JiraPreview from "../../../../../../assets/images/jira-policy-automation-preview-400x419@2x.png"; import ZendeskPreview from "../../../../../../assets/images/zendesk-policy-automation-preview-400x515@2x.png"; import JiraPreviewPremium from "../../../../../../assets/images/jira-policy-automation-preview-premium-400x316@2x.png"; import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-policy-automation-preview-premium-400x483@2x.png"; -const baseClass = "preview-ticket-modal"; +const baseClass = "example-ticket"; -interface IPreviewTicketModalProps { +interface IExampleTicketProps { integrationType?: IIntegrationType; - onCancel: () => void; } -const PreviewTicketModal = ({ +const ExampleTicket = ({ integrationType, - onCancel, -}: IPreviewTicketModalProps): JSX.Element => { +}: IExampleTicketProps): JSX.Element => { const { isPremiumTier } = useContext(AppContext); const screenshot = @@ -41,30 +37,10 @@ const PreviewTicketModal = ({ ); return ( - -
-

- Want to learn more about how automations in Fleet work?{" "} - -

-
{screenshot}
-
- -
-
-
+ + {screenshot} + ); }; -export default PreviewTicketModal; +export default ExampleTicket; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/_styles.scss new file mode 100644 index 0000000000..4212f33fa5 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/_styles.scss @@ -0,0 +1,10 @@ +.example-ticket { + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + + &__screenshot { + max-width: 400px; + } +} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/index.ts new file mode 100644 index 0000000000..3557097088 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/components/ExampleTicket/index.ts @@ -0,0 +1 @@ +export { default } from "./ExampleTicket"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManagePolicyAutomationsModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/ManagePolicyAutomationsModal/index.ts deleted file mode 100644 index d8e2cefbc9..0000000000 --- a/frontend/pages/policies/ManagePoliciesPage/components/ManagePolicyAutomationsModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./ManagePolicyAutomationsModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/ManagePolicyAutomationsModal/ManagePolicyAutomationsModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx similarity index 80% rename from frontend/pages/policies/ManagePoliciesPage/components/ManagePolicyAutomationsModal/ManagePolicyAutomationsModal.tsx rename to frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx index ae71c22cfe..00c9a72d8b 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/ManagePolicyAutomationsModal/ManagePolicyAutomationsModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/OtherWorkflowsModal/OtherWorkflowsModal.tsx @@ -3,7 +3,12 @@ import { Link } from "react-router"; import { isEmpty, noop, omit } from "lodash"; import { IAutomationsConfig, IWebhookSettings } from "interfaces/config"; -import { IIntegration, IIntegrations } from "interfaces/integration"; +import { + IGlobalIntegrations, + IIntegration, + IZendeskJiraIntegrations, + ITeamIntegrations, +} from "interfaces/integration"; import { IPolicy } from "interfaces/policy"; import { ITeamAutomationsConfig } from "interfaces/team"; import PATHS from "router/paths"; @@ -19,22 +24,21 @@ import Dropdown from "components/forms/fields/Dropdown"; import InputField from "components/forms/fields/InputField"; import Radio from "components/forms/fields/Radio"; import validUrl from "components/forms/validators/valid_url"; +import RevealButton from "components/buttons/RevealButton"; +import CustomLink from "components/CustomLink"; +import ExampleTicket from "../ExampleTicket"; +import ExamplePayload from "../ExamplePayload"; -import PreviewPayloadModal from "../PreviewPayloadModal"; -import PreviewTicketModal from "../PreviewTicketModal"; - -interface IManagePolicyAutomationsModalProps { +interface IOtherWorkflowsModalProps { automationsConfig: IAutomationsConfig | ITeamAutomationsConfig; - availableIntegrations: IIntegrations; + availableIntegrations: IGlobalIntegrations | ITeamIntegrations; availablePolicies: IPolicy[]; isUpdatingAutomations: boolean; - showPreviewPayloadModal: boolean; onExit: () => void; handleSubmit: (formData: { webhook_settings: Pick; - integrations: IIntegrations; + integrations: IGlobalIntegrations | ITeamIntegrations; }) => void; - togglePreviewPayloadModal: () => void; } interface ICheckedPolicy { @@ -43,7 +47,10 @@ interface ICheckedPolicy { isChecked: boolean; } -const findEnabledIntegration = ({ jira, zendesk }: IIntegrations) => { +const findEnabledIntegration = ({ + jira, + zendesk, +}: IZendeskJiraIntegrations) => { return ( jira?.find((j) => j.enable_failing_policies) || zendesk?.find((z) => z.enable_failing_policies) @@ -83,18 +90,16 @@ const useCheckboxListStateManagement = ( return { policyItems, updatePolicyItems }; }; -const baseClass = "manage-policy-automations-modal"; +const baseClass = "other-workflows-modal"; -const ManagePolicyAutomationsModal = ({ +const OtherWorkflowsModal = ({ automationsConfig, availableIntegrations, availablePolicies, isUpdatingAutomations, - showPreviewPayloadModal, onExit, handleSubmit, - togglePreviewPayloadModal: togglePreviewModal, -}: IManagePolicyAutomationsModalProps): JSX.Element => { +}: IOtherWorkflowsModalProps): JSX.Element => { const { webhook_settings: { failing_policies_webhook: webhook }, } = automationsConfig; @@ -131,6 +136,9 @@ const ManagePolicyAutomationsModal = ({ IIntegration | undefined >(serverEnabledIntegration); + const [showExamplePayload, setShowExamplePayload] = useState(false); + const [showExampleTicket, setShowExampleTicket] = useState(false); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); const { policyItems, updatePolicyItems } = useCheckboxListStateManagement( @@ -218,13 +226,6 @@ const ManagePolicyAutomationsModal = ({ z.group_id === selectedIntegration?.group_id, })) || null; - // if ( - // !isPolicyAutomationsEnabled || - // (!isWebhookEnabled && !selectedIntegration) - // ) { - // newPolicyIds = []; - // } - const updatedEnabledPoliciesAcrossPages = () => { if (webhook.policy_ids) { // Array of policy ids on the page @@ -263,6 +264,7 @@ const ManagePolicyAutomationsModal = ({ integrations: { jira: newJira, zendesk: newZendesk, + google_calendar: null, // When null, the backend does not update google_calendar }, }); @@ -297,34 +299,52 @@ const ManagePolicyAutomationsModal = ({ placeholder="https://server.com/example" tooltip="Provide a URL to deliver a webhook request to." /> - + setShowExamplePayload(!showExamplePayload)} + /> + {showExamplePayload && } ); }; const renderIntegrations = () => { return jira?.length || zendesk?.length ? ( -
- +
+ +
+ setShowExampleTicket(!showExampleTicket)} /> - -
+ {showExampleTicket && ( + + )} + ) : (
You have no integrations.
@@ -338,22 +358,10 @@ const ManagePolicyAutomationsModal = ({ ); }; - const renderPreview = () => - !isWebhookEnabled ? ( - - ) : ( - - ); - - return showPreviewPayloadModal ? ( - renderPreview() - ) : ( + return ( @@ -372,12 +380,32 @@ const ManagePolicyAutomationsModal = ({ isPolicyAutomationsEnabled ? "enabled" : "disabled" }`} > +
+
Workflow
+ + +
+ {isWebhookEnabled ? renderWebhook() : renderIntegrations()}
{availablePolicies?.length ? ( <> -
- Choose which policies you would like to listen to: -
+
Policies:
{policyItems && policyItems.map((policyItem) => { const { isChecked, name, id } = policyItem; @@ -405,28 +433,14 @@ const ManagePolicyAutomationsModal = ({ )}
-
-
Workflow
- + The workflow will be triggered when hosts fail these policies.{" "} + - -
- {isWebhookEnabled ? renderWebhook() : renderIntegrations()} +

-
-
- - ); -}; - -export default PreviewPayloadModal; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PreviewPayloadModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/PreviewPayloadModal/_styles.scss deleted file mode 100644 index 0ff66f034f..0000000000 --- a/frontend/pages/policies/ManagePoliciesPage/components/PreviewPayloadModal/_styles.scss +++ /dev/null @@ -1,54 +0,0 @@ -.preview-payload-modal { - &__sandbox-info { - margin-top: $pad-medium; - - p { - margin: 0; - margin-bottom: $pad-medium; - } - - p:last-child { - margin-bottom: 0; - } - } - - &__info-header { - font-weight: $bold; - } - - &__advanced-options-button { - margin: $pad-medium 0; - color: $core-vibrant-blue; - font-weight: $bold; - font-size: $x-small; - } - - .downcaret { - &::after { - content: url("../assets/images/icon-chevron-blue-16x16@2x.png"); - transform: scale(0.5); - width: 16px; - border-radius: 0px; - padding: 0px; - padding-left: 2px; - margin-bottom: 2px; - } - } - - .upcaret { - &::after { - content: url("../assets/images/icon-chevron-blue-16x16@2x.png"); - transform: scale(0.5) rotate(180deg); - width: 16px; - border-radius: 0px; - padding: 0px; - padding-left: 2px; - margin-bottom: 4px; - margin-left: 14px; - } - } - - .Select-value-label { - font-size: $small; - } -} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PreviewPayloadModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/PreviewPayloadModal/index.ts deleted file mode 100644 index bc08b4723d..0000000000 --- a/frontend/pages/policies/ManagePoliciesPage/components/PreviewPayloadModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./PreviewPayloadModal"; diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/_styles.scss deleted file mode 100644 index 9024a73203..0000000000 --- a/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/_styles.scss +++ /dev/null @@ -1,13 +0,0 @@ -.preview-ticket-modal { - &__example { - display: flex; - justify-content: center; - } - - &__screenshot { - width: 400px; - height: auto; - border-radius: 8px; - filter: drop-shadow(0px 4px 16px rgba(0, 0, 0, 0.1)); - } -} diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/index.ts b/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/index.ts deleted file mode 100644 index 4d8716d447..0000000000 --- a/frontend/pages/policies/ManagePoliciesPage/components/PreviewTicketModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./PreviewTicketModal"; diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index 114e42dd8e..4a1752c652 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -32,6 +32,7 @@ export default { ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`, + ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`, ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`, ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`, ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`, diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index d7a03f1f1b..2858938dc7 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -87,6 +87,7 @@ export default { resolution, platform, critical, + calendar_events_enabled, } = data; const { TEAMS } = endpoints; const path = `${TEAMS}/${team_id}/policies/${id}`; @@ -98,6 +99,7 @@ export default { resolution, platform, critical, + calendar_events_enabled, }); }, destroy: (teamId: number | undefined, ids: number[]) => { diff --git a/frontend/services/entities/teams.ts b/frontend/services/entities/teams.ts index 3c7e1a2619..1495445827 100644 --- a/frontend/services/entities/teams.ts +++ b/frontend/services/entities/teams.ts @@ -5,7 +5,7 @@ import { pick } from "lodash"; import { buildQueryStringFromParams } from "utilities/url"; import { IEnrollSecret } from "interfaces/enroll_secret"; -import { IIntegrations } from "interfaces/integration"; +import { ITeamIntegrations } from "interfaces/integration"; import { API_NO_TEAM_ID, INewTeamUsersBody, @@ -39,7 +39,7 @@ export interface ITeamFormData { export interface IUpdateTeamFormData { name: string; webhook_settings: Partial; - integrations: IIntegrations; + integrations: ITeamIntegrations; mdm: { macos_updates?: { minimum_version: string; @@ -118,7 +118,7 @@ export default { requestBody.webhook_settings = webhook_settings; } if (integrations) { - const { jira, zendesk } = integrations; + const { jira, zendesk, google_calendar } = integrations; const teamIntegrationProps = [ "enable_failing_policies", "group_id", @@ -128,6 +128,7 @@ export default { requestBody.integrations = { jira: jira?.map((j) => pick(j, teamIntegrationProps)), zendesk: zendesk?.map((z) => pick(z, teamIntegrationProps)), + google_calendar, }; } if (mdm) { diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index e786ab7696..fab5ee9ec2 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -182,6 +182,15 @@ $max-width: 2560px; } } +@mixin copy-message { + font-weight: $regular; + vertical-align: top; + background-color: $ui-light-grey; + border: solid 1px #e2e4ea; + border-radius: 10px; + padding: 2px 6px; +} + @mixin color-contrasted-sections { background-color: $ui-off-white; .section { @@ -227,3 +236,102 @@ $max-width: 2560px; // compensate in layout for extra clickable area button height margin: -8px 0; } + +@mixin button-dropdown { + .form-field { + margin: 0; + } + + .Select { + position: relative; + border: 0; + height: auto; + + &.is-focused, + &:hover { + border: 0; + } + + &.is-focused:not(.is-open) { + .Select-control { + background-color: initial; + } + } + + .Select-control { + display: flex; + background-color: initial; + height: auto; + justify-content: space-between; + border: 0; + cursor: pointer; + + &:hover { + box-shadow: none; + } + + &:hover .Select-placeholder { + color: $core-vibrant-blue; + } + + .Select-placeholder { + font-size: 14px; + line-height: normal; + padding-left: 0; + margin-top: 1px; + } + + .Select-input { + height: auto; + } + + .Select-arrow-zone { + display: flex; + } + } + + .Select-placeholder { + display: flex; + align-items: center; + } + + .Select-menu-outer { + margin-top: $pad-xsmall; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + border-radius: $border-radius; + z-index: 6; + overflow: hidden; + border: 0; + width: 188px; + left: unset; + top: unset; + max-height: none; + padding: $pad-small; + position: absolute; + + .Select-menu { + max-height: none; + } + } + + .Select-arrow { + transition: transform 0.25s ease; + } + + &:not(.is-open) { + .Select-control:hover .Select-arrow { + content: url("../assets/images/icon-chevron-blue-16x16@2x.png"); + } + } + + &.is-open { + .Select-control .Select-placeholder { + color: $core-vibrant-blue; + } + + .Select-arrow { + transform: rotate(180deg); + } + } + } +} diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go new file mode 100644 index 0000000000..45d8d88331 --- /dev/null +++ b/server/datastore/mysql/calendar_events.go @@ -0,0 +1,238 @@ +package mysql + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/jmoiron/sqlx" +) + +func (ds *Datastore) CreateOrUpdateCalendarEvent( + ctx context.Context, + email string, + startTime time.Time, + endTime time.Time, + data []byte, + hostID uint, + webhookStatus fleet.CalendarWebhookStatus, +) (*fleet.CalendarEvent, error) { + var id int64 + if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + const calendarEventsQuery = ` + INSERT INTO calendar_events ( + email, + start_time, + end_time, + event + ) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + start_time = VALUES(start_time), + end_time = VALUES(end_time), + event = VALUES(event), + updated_at = CURRENT_TIMESTAMP; + ` + result, err := tx.ExecContext( + ctx, + calendarEventsQuery, + email, + startTime, + endTime, + data, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert calendar event") + } + + if insertOnDuplicateDidInsert(result) { + id, _ = result.LastInsertId() + } else { + stmt := `SELECT id FROM calendar_events WHERE email = ?` + if err := sqlx.GetContext(ctx, tx, &id, stmt, email); err != nil { + return ctxerr.Wrap(ctx, err, "query mdm solution id") + } + } + + const hostCalendarEventsQuery = ` + INSERT INTO host_calendar_events ( + host_id, + calendar_event_id, + webhook_status + ) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + webhook_status = VALUES(webhook_status), + calendar_event_id = VALUES(calendar_event_id); + ` + result, err = tx.ExecContext( + ctx, + hostCalendarEventsQuery, + hostID, + id, + webhookStatus, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "insert host calendar event") + } + return nil + }); err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + calendarEvent, err := getCalendarEventByID(ctx, ds.writer(ctx), uint(id)) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get created calendar event by id") + } + return calendarEvent, nil +} + +func getCalendarEventByID(ctx context.Context, q sqlx.QueryerContext, id uint) (*fleet.CalendarEvent, error) { + const calendarEventsQuery = ` + SELECT * FROM calendar_events WHERE id = ?; + ` + var calendarEvent fleet.CalendarEvent + err := sqlx.GetContext(ctx, q, &calendarEvent, calendarEventsQuery, id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithID(id)) + } + return nil, ctxerr.Wrap(ctx, err, "get calendar event") + } + return &calendarEvent, nil +} + +func (ds *Datastore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) { + const calendarEventsQuery = ` + SELECT * FROM calendar_events WHERE email = ?; + ` + var calendarEvent fleet.CalendarEvent + err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, email) + if err != nil { + if err == sql.ErrNoRows { + return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("email: %s", email))) + } + return nil, ctxerr.Wrap(ctx, err, "get calendar event") + } + return &calendarEvent, nil +} + +func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error { + const calendarEventsQuery = ` + UPDATE calendar_events SET + start_time = ?, + end_time = ?, + event = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?; + ` + if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, calendarEventID); err != nil { + return ctxerr.Wrap(ctx, err, "update calendar event") + } + return nil +} + +func (ds *Datastore) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error { + const calendarEventsQuery = ` + DELETE FROM calendar_events WHERE id = ?; + ` + if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, calendarEventID); err != nil { + return ctxerr.Wrap(ctx, err, "delete calendar event") + } + return nil +} + +func (ds *Datastore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + const hostCalendarEventsQuery = ` + SELECT * FROM host_calendar_events WHERE host_id = ? + ` + var hostCalendarEvent fleet.HostCalendarEvent + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostCalendarEvent, hostCalendarEventsQuery, hostID); err != nil { + if err == sql.ErrNoRows { + return nil, nil, ctxerr.Wrap(ctx, notFound("HostCalendarEvent").WithMessage(fmt.Sprintf("host_id: %d", hostID))) + } + return nil, nil, ctxerr.Wrap(ctx, err, "get host calendar event") + } + const calendarEventsQuery = ` + SELECT * FROM calendar_events WHERE id = ? + ` + var calendarEvent fleet.CalendarEvent + if err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, hostCalendarEvent.CalendarEventID); err != nil { + if err == sql.ErrNoRows { + return nil, nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithID(hostCalendarEvent.CalendarEventID)) + } + return nil, nil, ctxerr.Wrap(ctx, err, "get calendar event") + } + return &hostCalendarEvent, &calendarEvent, nil +} + +func (ds *Datastore) GetHostCalendarEventByEmail(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + const calendarEventsQuery = ` + SELECT * FROM calendar_events WHERE email = ? + ` + var calendarEvent fleet.CalendarEvent + if err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, email); err != nil { + if err == sql.ErrNoRows { + return nil, nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("email: %s", email))) + } + return nil, nil, ctxerr.Wrap(ctx, err, "get calendar event") + } + const hostCalendarEventsQuery = ` + SELECT * FROM host_calendar_events WHERE calendar_event_id = ? + ` + var hostCalendarEvent fleet.HostCalendarEvent + if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostCalendarEvent, hostCalendarEventsQuery, calendarEvent.ID); err != nil { + if err == sql.ErrNoRows { + return nil, nil, ctxerr.Wrap(ctx, notFound("HostCalendarEvent").WithID(calendarEvent.ID)) + } + return nil, nil, ctxerr.Wrap(ctx, err, "get host calendar event") + } + return &hostCalendarEvent, &calendarEvent, nil +} + +func (ds *Datastore) UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status fleet.CalendarWebhookStatus) error { + const calendarEventsQuery = ` + UPDATE host_calendar_events SET + webhook_status = ? + WHERE host_id = ?; + ` + if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, status, hostID); err != nil { + return ctxerr.Wrap(ctx, err, "update host calendar event webhook status") + } + return nil +} + +func (ds *Datastore) ListCalendarEvents(ctx context.Context, teamID *uint) ([]*fleet.CalendarEvent, error) { + calendarEventsQuery := ` + SELECT ce.* FROM calendar_events ce + ` + + 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) + } + + var calendarEvents []*fleet.CalendarEvent + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &calendarEvents, calendarEventsQuery, args...); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "get all calendar events") + } + return calendarEvents, nil +} + +func (ds *Datastore) ListOutOfDateCalendarEvents(ctx context.Context, t time.Time) ([]*fleet.CalendarEvent, error) { + calendarEventsQuery := ` + SELECT ce.* FROM calendar_events ce WHERE updated_at < ? + ` + var calendarEvents []*fleet.CalendarEvent + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &calendarEvents, calendarEventsQuery, t); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get all calendar events") + } + return calendarEvents, nil +} diff --git a/server/datastore/mysql/calendar_events_test.go b/server/datastore/mysql/calendar_events_test.go new file mode 100644 index 0000000000..3c6030adfa --- /dev/null +++ b/server/datastore/mysql/calendar_events_test.go @@ -0,0 +1,128 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/require" +) + +func TestCalendarEvents(t *testing.T) { + ds := CreateMySQLDS(t) + + cases := []struct { + name string + fn func(t *testing.T, ds *Datastore) + }{ + {"UpdateCalendarEvent", testUpdateCalendarEvent}, + {"CreateOrUpdateCalendarEvent", testCreateOrUpdateCalendarEvent}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + defer TruncateTables(t, ds) + + c.fn(t, ds) + }) + } +} + +func testUpdateCalendarEvent(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + }) + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{ + { + HostID: host.ID, + Email: "foo@example.com", + Source: "google_chrome_profiles", + }, + }, "google_chrome_profiles") + require.NoError(t, err) + + startTime1 := time.Now() + endTime1 := startTime1.Add(30 * time.Minute) + calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone) + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`)) + require.NoError(t, err) + + calendarEvent2, err := ds.GetCalendarEvent(ctx, "foo@example.com") + require.NoError(t, err) + require.NotEqual(t, *calendarEvent, *calendarEvent2) + calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt + require.Equal(t, *calendarEvent, *calendarEvent2) + + // TODO(lucas): Add more tests here. +} + +func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) { + ctx := context.Background() + + host, err := ds.NewHost(context.Background(), &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now(), + NodeKey: ptr.String("1"), + UUID: "1", + Hostname: "foo.local", + PrimaryIP: "192.168.1.1", + PrimaryMac: "30-65-EC-6F-C4-58", + }) + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{ + { + HostID: host.ID, + Email: "foo@example.com", + Source: "google_chrome_profiles", + }, + }, "google_chrome_profiles") + require.NoError(t, err) + + startTime1 := time.Now() + endTime1 := startTime1.Add(30 * time.Minute) + calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone) + require.NoError(t, err) + + time.Sleep(1 * time.Second) + + calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone) + require.NoError(t, err) + require.Greater(t, calendarEvent2.UpdatedAt, calendarEvent.UpdatedAt) + calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt + require.Equal(t, *calendarEvent, *calendarEvent2) + + time.Sleep(1 * time.Second) + + startTime2 := startTime1.Add(1 * time.Hour) + endTime2 := startTime1.Add(30 * time.Minute) + calendarEvent3, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime2, endTime2, []byte(`{"foo": "bar"}`), host.ID, fleet.CalendarWebhookStatusPending) + require.NoError(t, err) + require.Greater(t, calendarEvent3.UpdatedAt, calendarEvent2.UpdatedAt) + require.WithinDuration(t, startTime2, calendarEvent3.StartTime, 1*time.Second) + require.WithinDuration(t, endTime2, calendarEvent3.EndTime, 1*time.Second) + require.Equal(t, string(calendarEvent3.Data), `{"foo": "bar"}`) + + calendarEvent3b, err := ds.GetCalendarEvent(ctx, "foo@example.com") + require.NoError(t, err) + require.Equal(t, calendarEvent3, calendarEvent3b) + + // TODO(lucas): Add more tests here. +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index df2f4395bc..ca8986e2e5 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -502,6 +502,7 @@ var hostRefs = []string{ "query_results", "host_activities", "host_mdm_actions", + "host_calendar_events", } // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index d1dedf0617..b57784b1b3 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -2554,7 +2554,6 @@ func testHostLiteByIdentifierAndID(t *testing.T, ds *Datastore) { h, err = ds.HostLiteByID(context.Background(), 0) assert.ErrorIs(t, err, sql.ErrNoRows) assert.Nil(t, h) - } func testHostsAddToTeam(t *testing.T, ds *Datastore) { @@ -2795,7 +2794,6 @@ func testHostsTotalAndUnseenSince(t *testing.T, ds *Datastore) { assert.Equal(t, 2, total) require.Len(t, unseen, 1) assert.Equal(t, host3.ID, unseen[0]) - } func testHostsListByPolicy(t *testing.T, ds *Datastore) { @@ -6577,6 +6575,23 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { `, host.ID) require.NoError(t, err) + // Add a calendar event for the host. + _, err = ds.writer(context.Background()).Exec(` + INSERT INTO calendar_events (email, start_time, end_time, event) + VALUES ('foobar@example.com', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, '{}'); + `) + require.NoError(t, err) + var calendarEventID int + err = ds.writer(context.Background()).Get(&calendarEventID, ` + SELECT id FROM calendar_events WHERE email = 'foobar@example.com'; + `) + require.NoError(t, err) + _, err = ds.writer(context.Background()).Exec(` + INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status) + VALUES (?, ?, 1); + `, host.ID, calendarEventID) + require.NoError(t, err) + // Check there's an entry for the host in all the associated tables. for _, hostRef := range hostRefs { var ok bool diff --git a/server/datastore/mysql/migrations/tables/20240314085226_AddCalendarEventTables.go b/server/datastore/mysql/migrations/tables/20240314085226_AddCalendarEventTables.go new file mode 100644 index 0000000000..a385a1da88 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240314085226_AddCalendarEventTables.go @@ -0,0 +1,54 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240314085226, Down_20240314085226) +} + +func Up_20240314085226(tx *sql.Tx) error { + // TODO(lucas): Check if we need more indexes. + + if _, err := tx.Exec(` + CREATE TABLE IF NOT EXISTS calendar_events ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + event JSON NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY idx_one_calendar_event_per_email (email) + ); +`); err != nil { + return fmt.Errorf("create calendar_events table: %w", err) + } + + if _, err := tx.Exec(` + CREATE TABLE IF NOT EXISTS host_calendar_events ( + id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + host_id INT(10) UNSIGNED NOT NULL, + calendar_event_id INT(10) UNSIGNED NOT NULL, + webhook_status TINYINT NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY idx_one_calendar_event_per_host (host_id), + FOREIGN KEY (calendar_event_id) REFERENCES calendar_events(id) ON DELETE CASCADE + ); +`); err != nil { + return fmt.Errorf("create host_calendar_events table: %w", err) + } + + return nil +} + +func Down_20240314085226(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240314085226_AddCalendarEventTables_test.go b/server/datastore/mysql/migrations/tables/20240314085226_AddCalendarEventTables_test.go new file mode 100644 index 0000000000..85f61b342b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240314085226_AddCalendarEventTables_test.go @@ -0,0 +1,53 @@ +package tables + +import ( + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/require" +) + +func TestUp_20240314085226(t *testing.T) { + db := applyUpToPrev(t) + applyNext(t, db) + + sampleEvent := fleet.CalendarEvent{ + Email: "foo@example.com", + StartTime: time.Now().UTC(), + EndTime: time.Now().UTC().Add(30 * time.Minute), + Data: []byte("{\"foo\": \"bar\"}"), + } + sampleEvent.ID = uint(execNoErrLastID(t, db, + `INSERT INTO calendar_events (email, start_time, end_time, event) VALUES (?, ?, ?, ?);`, + sampleEvent.Email, sampleEvent.StartTime, sampleEvent.EndTime, sampleEvent.Data, + )) + + sampleHostEvent := fleet.HostCalendarEvent{ + HostID: 1, + CalendarEventID: sampleEvent.ID, + WebhookStatus: fleet.CalendarWebhookStatusPending, + } + sampleHostEvent.ID = uint(execNoErrLastID(t, db, + `INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status) VALUES (?, ?, ?);`, + sampleHostEvent.HostID, sampleHostEvent.CalendarEventID, sampleHostEvent.WebhookStatus, + )) + + var event fleet.CalendarEvent + err := db.Get(&event, `SELECT * FROM calendar_events WHERE id = ?;`, sampleEvent.ID) + require.NoError(t, err) + sampleEvent.CreatedAt = event.CreatedAt // sampleEvent doesn't have this set. + sampleEvent.UpdatedAt = event.UpdatedAt // sampleEvent doesn't have this set. + sampleEvent.StartTime = sampleEvent.StartTime.Round(time.Second) + sampleEvent.EndTime = sampleEvent.EndTime.Round(time.Second) + event.StartTime = event.StartTime.Round(time.Second) + event.EndTime = event.EndTime.Round(time.Second) + require.Equal(t, sampleEvent, event) + + var hostEvent fleet.HostCalendarEvent + err = db.Get(&hostEvent, `SELECT * FROM host_calendar_events WHERE id = ?;`, sampleHostEvent.ID) + require.NoError(t, err) + sampleHostEvent.CreatedAt = hostEvent.CreatedAt // sampleHostEvent doesn't have this set. + sampleHostEvent.UpdatedAt = hostEvent.UpdatedAt // sampleHostEvent doesn't have this set. + require.Equal(t, sampleHostEvent, hostEvent) +} diff --git a/server/datastore/mysql/migrations/tables/20240314151747_AddCalendarEventsToPolicies.go b/server/datastore/mysql/migrations/tables/20240314151747_AddCalendarEventsToPolicies.go new file mode 100644 index 0000000000..485993bf75 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240314151747_AddCalendarEventsToPolicies.go @@ -0,0 +1,22 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240314151747, Down_20240314151747) +} + +func Up_20240314151747(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE policies ADD COLUMN calendar_events_enabled TINYINT(1) UNSIGNED NOT NULL DEFAULT '0'`) + if err != nil { + return fmt.Errorf("failed to add calendar_events_enabled to policies: %w", err) + } + return nil +} + +func Down_20240314151747(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240314151747_AddCalendarEventsToPolicies_test.go b/server/datastore/mysql/migrations/tables/20240314151747_AddCalendarEventsToPolicies_test.go new file mode 100644 index 0000000000..2deb81a9f1 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240314151747_AddCalendarEventsToPolicies_test.go @@ -0,0 +1,42 @@ +package tables + +import ( + "context" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestUp_20240314151747(t *testing.T) { + db := applyUpToPrev(t) + + policy1 := execNoErrLastID( + t, db, "INSERT INTO policies (name, query, description, checksum) VALUES (?,?,?,?)", "policy", "", "", "checksum", + ) + + // Apply current migration. + applyNext(t, db) + + var policyCheck []struct { + ID int64 `db:"id"` + CalEnabled bool `db:"calendar_events_enabled"` + } + err := db.SelectContext(context.Background(), &policyCheck, `SELECT id, calendar_events_enabled FROM policies ORDER BY id`) + require.NoError(t, err) + require.Len(t, policyCheck, 1) + assert.Equal(t, policy1, policyCheck[0].ID) + assert.Equal(t, false, policyCheck[0].CalEnabled) + + policy2 := execNoErrLastID( + t, db, "INSERT INTO policies (name, query, description, checksum, calendar_events_enabled) VALUES (?,?,?,?,?)", "policy2", "", "", + "checksum2", 1, + ) + + policyCheck = nil + err = db.SelectContext(context.Background(), &policyCheck, `SELECT id, calendar_events_enabled FROM policies WHERE id = ?`, policy2) + require.NoError(t, err) + require.Len(t, policyCheck, 1) + assert.Equal(t, policy2, policyCheck[0].ID) + assert.Equal(t, true, policyCheck[0].CalEnabled) + +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 69567ec8c5..71530961de 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -5,11 +5,12 @@ import ( "database/sql" "encoding/json" "fmt" - "golang.org/x/text/unicode/norm" "sort" "strings" "time" + "golang.org/x/text/unicode/norm" + "github.com/doug-martin/goqu/v9" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -19,7 +20,7 @@ import ( const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, - p.author_id, p.platforms, p.created_at, p.updated_at, p.critical + p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled ` var policySearchColumns = []string{"p.name"} @@ -115,10 +116,12 @@ func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemo p.Name = norm.NFC.String(p.Name) sql := ` UPDATE policies - SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, checksum = ` + policiesChecksumComputedColumn() + ` + SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + ` WHERE id = ? ` - result, err := ds.writer(ctx).ExecContext(ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.ID) + result, err := ds.writer(ctx).ExecContext( + ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.ID, + ) if err != nil { return ctxerr.Wrap(ctx, err, "updating policy") } @@ -525,10 +528,11 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u nameUnicode := norm.NFC.String(args.Name) res, err := ds.writer(ctx).ExecContext(ctx, fmt.Sprintf( - `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, %s)`, + `INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`, policiesChecksumComputedColumn(), ), nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical, + args.CalendarEventsEnabled, ) switch { case err == nil: @@ -586,15 +590,17 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs team_id, platforms, critical, + calendar_events_enabled, checksum - ) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, %s) + ) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, ?, %s) ON DUPLICATE KEY UPDATE query = VALUES(query), description = VALUES(description), author_id = VALUES(author_id), resolution = VALUES(resolution), platforms = VALUES(platforms), - critical = VALUES(critical) + critical = VALUES(critical), + calendar_events_enabled = VALUES(calendar_events_enabled) `, policiesChecksumComputedColumn(), ) for _, spec := range specs { @@ -603,6 +609,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs spec.Name = norm.NFC.String(spec.Name) res, err := tx.ExecContext(ctx, query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical, + spec.CalendarEventsEnabled, ) if err != nil { return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert") @@ -1153,3 +1160,56 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error { return nil } + +func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { + query := `SELECT id, name FROM policies WHERE team_id = ? AND calendar_events_enabled;` + var policies []fleet.PolicyCalendarData + err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, teamID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get calendar policies") + } + return policies, nil +} + +// TODO(lucas): Must be tested at scale. +func (ds *Datastore) GetTeamHostsPolicyMemberships( + ctx context.Context, + domain string, + teamID uint, + policyIDs []uint, +) ([]fleet.HostPolicyMembershipData, error) { + query := ` + SELECT + COALESCE(sh.email, '') AS email, + pm.passing AS passing, + h.id AS host_id, + hdn.display_name AS host_display_name, + h.hardware_serial AS host_hardware_serial + FROM ( + SELECT host_id, BIT_AND(COALESCE(passes, 0)) AS passing + FROM policy_membership + WHERE policy_id IN (?) + GROUP BY host_id + ) pm + 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; +` + + query, args, err := sqlx.In(query, policyIDs, domain, teamID) + if err != nil { + return nil, ctxerr.Wrapf(ctx, err, "build select get team hosts policy memberships query") + } + var hosts []fleet.HostPolicyMembershipData + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing policies") + } + + return hosts, nil +} diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index 5e16c9a402..15ebeee171 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -59,6 +59,8 @@ func TestPolicies(t *testing.T) { {"TestPoliciesNameUnicode", testPoliciesNameUnicode}, {"TestPoliciesNameEmoji", testPoliciesNameEmoji}, {"TestPoliciesNameSort", testPoliciesNameSort}, + {"TestGetCalendarPolicies", testGetCalendarPolicies}, + {"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -582,10 +584,11 @@ func testTeamPolicyProprietary(t *testing.T, ds *Datastore) { require.Error(t, err) p, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{ - Name: "query1", - Query: "select 1;", - Description: "query1 desc", - Resolution: "query1 resolution", + Name: "query1", + Query: "select 1;", + Description: "query1 desc", + Resolution: "query1 resolution", + CalendarEventsEnabled: true, }) require.NoError(t, err) @@ -615,6 +618,7 @@ func testTeamPolicyProprietary(t *testing.T, ds *Datastore) { assert.Equal(t, "query1 resolution", *p.Resolution) require.NotNil(t, p.AuthorID) assert.Equal(t, user1.ID, *p.AuthorID) + assert.True(t, p.CalendarEventsEnabled) globalPolicies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) require.NoError(t, err) @@ -1244,12 +1248,13 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Platform: "", }, { - Name: "query2", - Query: "select 2;", - Description: "query2 desc", - Resolution: "some other resolution", - Team: "team1", - Platform: "darwin", + Name: "query2", + Query: "select 2;", + Description: "query2 desc", + Resolution: "some other resolution", + Team: "team1", + Platform: "darwin", + CalendarEventsEnabled: true, }, { Name: "query3", @@ -1284,6 +1289,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { require.NotNil(t, teamPolicies[0].Resolution) assert.Equal(t, "some other resolution", *teamPolicies[0].Resolution) assert.Equal(t, "darwin", teamPolicies[0].Platform) + assert.True(t, teamPolicies[0].CalendarEventsEnabled) assert.Equal(t, "query3", teamPolicies[1].Name) assert.Equal(t, "select 3;", teamPolicies[1].Query) @@ -1293,6 +1299,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { require.NotNil(t, teamPolicies[1].Resolution) assert.Equal(t, "some other good resolution", *teamPolicies[1].Resolution) assert.Equal(t, "windows,linux", teamPolicies[1].Platform) + assert.False(t, teamPolicies[1].CalendarEventsEnabled) // Make sure apply is idempotent require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{ @@ -1305,12 +1312,13 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Platform: "", }, { - Name: "query2", - Query: "select 2;", - Description: "query2 desc", - Resolution: "some other resolution", - Team: "team1", - Platform: "darwin", + Name: "query2", + Query: "select 2;", + Description: "query2 desc", + Resolution: "some other resolution", + Team: "team1", + Platform: "darwin", + CalendarEventsEnabled: true, }, { Name: "query3", @@ -1340,12 +1348,13 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { Platform: "", }, { - Name: "query2", - Query: "select 2 from updated;", - Description: "query2 desc updated", - Resolution: "some other resolution updated", - Team: "team1", // No error, team did not change - Platform: "windows", + Name: "query2", + Query: "select 2 from updated;", + Description: "query2 desc updated", + Resolution: "some other resolution updated", + Team: "team1", // No error, team did not change + Platform: "windows", + CalendarEventsEnabled: false, }, })) policies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{}) @@ -1360,6 +1369,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) { require.NotNil(t, policies[0].Resolution) assert.Equal(t, "some resolution updated", *policies[0].Resolution) assert.Equal(t, "", policies[0].Platform) + assert.False(t, policies[0].CalendarEventsEnabled) teamPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{}) require.NoError(t, err) @@ -1439,11 +1449,12 @@ func testPoliciesSave(t *testing.T, ds *Datastore) { assert.Equal(t, computeChecksum(*gp), hex.EncodeToString(globalChecksum)) payload = fleet.PolicyPayload{ - Name: "team1 query", - Query: "select 2;", - Description: "team1 query desc", - Resolution: "team1 query resolution", - Critical: true, + Name: "team1 query", + Query: "select 2;", + Description: "team1 query desc", + Resolution: "team1 query resolution", + Critical: true, + CalendarEventsEnabled: true, } tp1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, payload) require.NoError(t, err) @@ -1452,6 +1463,7 @@ func testPoliciesSave(t *testing.T, ds *Datastore) { require.Equal(t, tp1.Description, payload.Description) require.Equal(t, *tp1.Resolution, payload.Resolution) require.Equal(t, tp1.Critical, payload.Critical) + assert.Equal(t, tp1.CalendarEventsEnabled, payload.CalendarEventsEnabled) var teamChecksum []uint8 err = ds.writer(context.Background()).Get(&teamChecksum, `SELECT checksum FROM policies WHERE id = ?`, tp1.ID) require.NoError(t, err) @@ -1480,6 +1492,7 @@ func testPoliciesSave(t *testing.T, ds *Datastore) { tp2.Description = "team1 query desc updated" tp2.Resolution = ptr.String("team1 query resolution updated") tp2.Critical = false + tp2.CalendarEventsEnabled = false err = ds.SavePolicy(ctx, &tp2, true) require.NoError(t, err) tp1, err = ds.Policy(ctx, tp1.ID) @@ -2773,7 +2786,6 @@ func testPoliciesNameEmoji(t *testing.T, ds *Datastore) { assert.NoError(t, err) require.Len(t, policies, 1) assert.Equal(t, emoji1, policies[0].Name) - } // Ensure case-insensitive sort order for policy names @@ -2795,3 +2807,256 @@ func testPoliciesNameSort(t *testing.T, ds *Datastore) { assert.Equal(t, policy.Name, policiesResult[i].Name) } } + +func testGetCalendarPolicies(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Test with non-existent team. + _, err := ds.GetCalendarPolicies(ctx, 999) + require.NoError(t, err) + + team, err := ds.NewTeam(ctx, &fleet.Team{ + Name: "Foobar", + }) + require.NoError(t, err) + + // Test when the team has no policies. + _, err = ds.GetCalendarPolicies(ctx, team.ID) + require.NoError(t, err) + + // Create a global query to test that only team policies are returned. + _, err = ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{ + Name: "Global Policy", + Query: "SELECT * FROM time;", + }) + require.NoError(t, err) + + _, err = ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ + Name: "Team Policy 1", + Query: "SELECT * FROM system_info;", + CalendarEventsEnabled: false, + }) + require.NoError(t, err) + + // Test when the team has policies, but none is configured for calendar. + _, err = ds.GetCalendarPolicies(ctx, team.ID) + require.NoError(t, err) + + teamPolicy2, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ + Name: "Team Policy 2", + Query: "SELECT * FROM osquery_info;", + CalendarEventsEnabled: true, + }) + require.NoError(t, err) + teamPolicy3, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{ + Name: "Team Policy 3", + Query: "SELECT * FROM os_version;", + CalendarEventsEnabled: true, + }) + require.NoError(t, err) + + calendarPolicies, err := ds.GetCalendarPolicies(ctx, team.ID) + require.NoError(t, err) + require.Len(t, calendarPolicies, 2) + require.Equal(t, calendarPolicies[0].ID, teamPolicy2.ID) + require.Equal(t, calendarPolicies[1].ID, teamPolicy3.ID) +} + +func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { + ctx := context.Background() + + team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) + require.NoError(t, err) + + team1Policy1, err := ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "Team 1 Policy 1", + Query: "SELECT * FROM osquery_info;", + CalendarEventsEnabled: true, + }) + require.NoError(t, err) + team1Policy2, err := ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "Team 1 Policy 2", + Query: "SELECT * FROM system_info;", + CalendarEventsEnabled: false, + }) + require.NoError(t, err) + team2Policy1, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "Team 2 Policy 1", + Query: "SELECT * FROM os_version;", + CalendarEventsEnabled: true, + }) + require.NoError(t, err) + team2Policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{ + Name: "Team 2 Policy 2", + Query: "SELECT * FROM processes;", + CalendarEventsEnabled: true, + }) + 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) + + host1, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("host1"), + NodeKey: ptr.String("host1"), + HardwareSerial: "serial1", + ComputerName: "display_name1", + TeamID: &team1.ID, + }) + require.NoError(t, err) + host2, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("host2"), + NodeKey: ptr.String("host2"), + HardwareSerial: "serial2", + ComputerName: "display_name2", + TeamID: &team2.ID, + }) + require.NoError(t, err) + host3, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("host3"), + NodeKey: ptr.String("host3"), + HardwareSerial: "serial3", + ComputerName: "display_name3", + TeamID: &team2.ID, + }) + require.NoError(t, err) + host4, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("host4"), + NodeKey: ptr.String("host4"), + HardwareSerial: "serial4", + ComputerName: "display_name4", + }) + require.NoError(t, err) + host5, err := ds.NewHost(ctx, &fleet.Host{ + OsqueryHostID: ptr.String("host5"), + NodeKey: ptr.String("host5"), + HardwareSerial: "serial5", + ComputerName: "display_name5", + TeamID: &team1.ID, + }) + require.NoError(t, err) + + // No policy results yet. + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) + require.NoError(t, err) + require.Len(t, hostsTeam1, 0) + + err = ds.ReplaceHostDeviceMapping(ctx, host1.ID, []*fleet.HostDeviceMapping{ + {HostID: host1.ID, Email: "foo@example.com", Source: "google_chrome_profiles"}, + }, "google_chrome_profiles") + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host1.ID, []*fleet.HostDeviceMapping{ + {HostID: host1.ID, Email: "zoo@example.com", Source: "custom"}, + }, "custom") + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host2.ID, []*fleet.HostDeviceMapping{ + {HostID: host2.ID, Email: "foo@example.com", Source: "custom"}, + }, "custom") + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host2.ID, []*fleet.HostDeviceMapping{ + {HostID: host2.ID, Email: "foo@other.com", Source: "google_chrome_profiles"}, + }, "google_chrome_profiles") + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host3.ID, []*fleet.HostDeviceMapping{ + {HostID: host3.ID, Email: "zoo@example.com", Source: "google_chrome_profiles"}, + }, "google_chrome_profiles") + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host4.ID, []*fleet.HostDeviceMapping{ + {HostID: host4.ID, Email: "foo@example.com", Source: "google_chrome_profiles"}, + }, "google_chrome_profiles") + require.NoError(t, err) + err = ds.ReplaceHostDeviceMapping(ctx, host5.ID, []*fleet.HostDeviceMapping{ + {HostID: host5.ID, Email: "foo@other.com", Source: "google_chrome_profiles"}, + }, "google_chrome_profiles") + require.NoError(t, err) + + err = ds.RecordPolicyQueryExecutions(ctx, host1, map[uint]*bool{ + team1Policy1.ID: ptr.Bool(true), + team1Policy2.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + err = ds.RecordPolicyQueryExecutions(ctx, host2, map[uint]*bool{ + team2Policy1.ID: ptr.Bool(false), + team2Policy2.ID: ptr.Bool(true), + }, time.Now(), false) + require.NoError(t, err) + + err = ds.RecordPolicyQueryExecutions(ctx, host3, map[uint]*bool{ + team2Policy1.ID: ptr.Bool(true), + team2Policy2.ID: ptr.Bool(true), + }, time.Now(), false) + require.NoError(t, err) + + err = ds.RecordPolicyQueryExecutions(ctx, host5, map[uint]*bool{ + team1Policy1.ID: ptr.Bool(false), + team1Policy2.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + team1Policies, err := ds.GetCalendarPolicies(ctx, team1.ID) + require.NoError(t, err) + require.Len(t, team1Policies, 1) + team2Policies, err := ds.GetCalendarPolicies(ctx, team2.ID) + require.NoError(t, err) + require.Len(t, team2Policies, 2) + + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) + require.NoError(t, err) + require.Len(t, hostsTeam1, 2) + require.Equal(t, host1.ID, hostsTeam1[0].HostID) + require.Equal(t, "foo@example.com", hostsTeam1[0].Email) + require.True(t, hostsTeam1[0].Passing) + require.Equal(t, "serial1", hostsTeam1[0].HostHardwareSerial) + require.Equal(t, "display_name1", hostsTeam1[0].HostDisplayName) + require.Equal(t, host5.ID, hostsTeam1[1].HostID) + require.Empty(t, hostsTeam1[1].Email) + require.False(t, hostsTeam1[1].Passing) + require.Equal(t, "serial5", hostsTeam1[1].HostHardwareSerial) + require.Equal(t, "display_name5", hostsTeam1[1].HostDisplayName) + + err = ds.AddHostsToTeam(ctx, &team1.ID, []uint{host4.ID}) + require.NoError(t, err) + err = ds.RecordPolicyQueryExecutions(ctx, host4, map[uint]*bool{ + team1Policy1.ID: ptr.Bool(false), + team1Policy2.ID: ptr.Bool(false), + }, time.Now(), false) + require.NoError(t, err) + + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) + require.NoError(t, err) + 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) + require.Equal(t, "serial1", hostsTeam1[0].HostHardwareSerial) + require.Equal(t, "display_name1", hostsTeam1[0].HostDisplayName) + require.Equal(t, host4.ID, hostsTeam1[1].HostID) + require.Equal(t, "foo@example.com", hostsTeam1[1].Email) + require.False(t, hostsTeam1[1].Passing) + require.Equal(t, "serial4", hostsTeam1[1].HostHardwareSerial) + require.Equal(t, "display_name4", hostsTeam1[1].HostDisplayName) + require.Equal(t, host5.ID, hostsTeam1[2].HostID) + require.Empty(t, hostsTeam1[2].Email) + require.False(t, hostsTeam1[2].Passing) + require.Equal(t, "serial5", hostsTeam1[2].HostHardwareSerial) + require.Equal(t, "display_name5", hostsTeam1[2].HostDisplayName) + + 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) + 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) + 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) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index a35608e669..cc3c88c7ed 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,21 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `calendar_events` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `start_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `event` json NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_one_calendar_event_per_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `carve_blocks` ( @@ -192,6 +206,21 @@ CREATE TABLE `host_batteries` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; +CREATE TABLE `host_calendar_events` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `host_id` int(10) unsigned NOT NULL, + `calendar_event_id` int(10) unsigned NOT NULL, + `webhook_status` tinyint(4) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_one_calendar_event_per_host` (`host_id`), + KEY `calendar_event_id` (`calendar_event_id`), + CONSTRAINT `host_calendar_events_ibfk_1` FOREIGN KEY (`calendar_event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; CREATE TABLE `host_dep_assignments` ( `host_id` int(10) unsigned NOT NULL, `added_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -779,9 +808,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=257 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=259 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1098,6 +1127,7 @@ CREATE TABLE `policies` ( `platforms` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `critical` tinyint(1) NOT NULL DEFAULT '0', `checksum` binary(16) NOT NULL, + `calendar_events_enabled` tinyint(1) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_policies_checksum` (`checksum`), KEY `idx_policies_author_id` (`author_id`), diff --git a/server/fleet/app.go b/server/fleet/app.go index 778f6fe7eb..e1720e5963 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "net/url" "reflect" "regexp" @@ -568,6 +569,15 @@ func (c *AppConfig) Copy() *AppConfig { clone.Integrations.Zendesk[i] = &zd } } + if len(c.Integrations.GoogleCalendar) > 0 { + clone.Integrations.GoogleCalendar = make([]*GoogleCalendarIntegration, len(c.Integrations.GoogleCalendar)) + for i, g := range c.Integrations.GoogleCalendar { + gCal := *g + clone.Integrations.GoogleCalendar[i] = &gCal + clone.Integrations.GoogleCalendar[i].ApiKey = make(map[string]string, len(g.ApiKey)) + maps.Copy(clone.Integrations.GoogleCalendar[i].ApiKey, g.ApiKey) + } + } if c.MDM.MacOSSettings.CustomSettings != nil { clone.MDM.MacOSSettings.CustomSettings = make([]MDMProfileSpec, len(c.MDM.MacOSSettings.CustomSettings)) diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go new file mode 100644 index 0000000000..5eb4597f44 --- /dev/null +++ b/server/fleet/calendar.go @@ -0,0 +1,62 @@ +package fleet + +import ( + "context" + "fmt" + "time" + _ "time/tzdata" // embed timezone information in the program + + "github.com/fleetdm/fleet/v4/server" +) + +type DayEndedError struct { + Msg string +} + +func (e DayEndedError) Error() string { + return e.Msg +} + +type UserCalendar interface { + // Configure configures the connection to a user's calendar. Once configured, + // CreateEvent, GetAndUpdateEvent and DeleteEvent reference the user's calendar. + Configure(userEmail string) error + // CreateEvent creates a new event on the calendar on the given date. DayEndedError is returned if there is no time left on the given date to schedule event. + CreateEvent(dateOfEvent time.Time, genBodyFn func(conflict bool) string) (event *CalendarEvent, err error) + // GetAndUpdateEvent retrieves the event from the calendar. + // If the event has been modified, it returns the updated event. + // If the event has been deleted, it schedules a new event with given body callback and returns the new event. + GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) string) (updatedEvent *CalendarEvent, updated bool, err error) + // DeleteEvent deletes the event with the given ID. + DeleteEvent(event *CalendarEvent) error +} + +type CalendarWebhookPayload struct { + Timestamp time.Time `json:"timestamp"` + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + HostSerialNumber string `json:"host_serial_number"` + FailingPolicies []PolicyCalendarData `json:"failing_policies,omitempty"` + Error string `json:"error,omitempty"` +} + +func FireCalendarWebhook( + webhookURL string, + hostID uint, + hostHardwareSerial string, + hostDisplayName string, + failingCalendarPolicies []PolicyCalendarData, + err string, +) error { + if err := server.PostJSONWithTimeout(context.Background(), webhookURL, &CalendarWebhookPayload{ + Timestamp: time.Now(), + HostID: hostID, + HostDisplayName: hostDisplayName, + HostSerialNumber: hostHardwareSerial, + FailingPolicies: failingCalendarPolicies, + Error: err, + }); err != nil { + return fmt.Errorf("POST to %q: %w", server.MaskSecretURLParams(webhookURL), server.MaskURLError(err)) + } + return nil +} diff --git a/server/fleet/calendar_events.go b/server/fleet/calendar_events.go new file mode 100644 index 0000000000..3152ee65bb --- /dev/null +++ b/server/fleet/calendar_events.go @@ -0,0 +1,39 @@ +package fleet + +import "time" + +type CalendarEvent struct { + ID uint `db:"id"` + Email string `db:"email"` + StartTime time.Time `db:"start_time"` + EndTime time.Time `db:"end_time"` + Data []byte `db:"event"` + + UpdateCreateTimestamps +} + +type CalendarWebhookStatus int + +const ( + CalendarWebhookStatusNone CalendarWebhookStatus = iota + CalendarWebhookStatusPending + CalendarWebhookStatusSent +) + +type HostCalendarEvent struct { + ID uint `db:"id"` + HostID uint `db:"host_id"` + CalendarEventID uint `db:"calendar_event_id"` + WebhookStatus CalendarWebhookStatus `db:"webhook_status"` + + UpdateCreateTimestamps +} + +type HostPolicyMembershipData struct { + Email string `db:"email"` + Passing bool `db:"passing"` + + HostID uint `db:"host_id"` + HostDisplayName string `db:"host_display_name"` + HostHardwareSerial string `db:"host_hardware_serial"` +} diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index 607f15f85c..6b16734fd4 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -21,6 +21,7 @@ const ( CronWorkerIntegrations CronScheduleName = "integrations" CronActivitiesStreaming CronScheduleName = "activities_streaming" CronMDMAppleProfileManager CronScheduleName = "mdm_apple_profile_manager" + CronCalendar CronScheduleName = "calendar" ) type CronSchedulesService interface { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 4081db8af0..a2f8bf6cdd 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -594,6 +594,9 @@ type Datastore interface { PolicyQueriesForHost(ctx context.Context, host *Host) (map[string]string, error) + GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]HostPolicyMembershipData, error) + GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) + // Methods used for async processing of host policy query results. AsyncBatchInsertPolicyMembership(ctx context.Context, batch []PolicyMembershipResult) error AsyncBatchUpdatePolicyTimestamp(ctx context.Context, ids []uint, ts time.Time) error @@ -613,6 +616,19 @@ type Datastore interface { // the updated_at timestamp is older than the provided duration DeleteOutOfDateVulnerabilities(ctx context.Context, source VulnerabilitySource, duration time.Duration) error + /////////////////////////////////////////////////////////////////////////////// + // Calendar events + + CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error) + GetCalendarEvent(ctx context.Context, email string) (*CalendarEvent, error) + DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error + UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error + GetHostCalendarEvent(ctx context.Context, hostID uint) (*HostCalendarEvent, *CalendarEvent, error) + GetHostCalendarEventByEmail(ctx context.Context, email string) (*HostCalendarEvent, *CalendarEvent, error) + UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status CalendarWebhookStatus) error + ListCalendarEvents(ctx context.Context, teamID *uint) ([]*CalendarEvent, error) + ListOutOfDateCalendarEvents(ctx context.Context, t time.Time) ([]*CalendarEvent, error) + /////////////////////////////////////////////////////////////////////////////// // Team Policies diff --git a/server/fleet/integrations.go b/server/fleet/integrations.go index 131af88806..18cce71825 100644 --- a/server/fleet/integrations.go +++ b/server/fleet/integrations.go @@ -13,8 +13,9 @@ import ( // TeamIntegrations contains the configuration for external services' // integrations for a specific team. type TeamIntegrations struct { - Jira []*TeamJiraIntegration `json:"jira"` - Zendesk []*TeamZendeskIntegration `json:"zendesk"` + Jira []*TeamJiraIntegration `json:"jira"` + Zendesk []*TeamZendeskIntegration `json:"zendesk"` + GoogleCalendar *TeamGoogleCalendarIntegration `json:"google_calendar"` } // MatchWithIntegrations matches the team integrations to their corresponding @@ -110,6 +111,11 @@ func (z TeamZendeskIntegration) UniqueKey() string { return z.URL + "\n" + strconv.FormatInt(z.GroupID, 10) } +type TeamGoogleCalendarIntegration struct { + Enable bool `json:"enable_calendar_events"` + WebhookURL string `json:"webhook_url"` +} + // JiraIntegration configures an instance of an integration with the Jira // system. type JiraIntegration struct { @@ -335,10 +341,21 @@ func makeTestZendeskRequest(ctx context.Context, intg *ZendeskIntegration) error return nil } +const ( + GoogleCalendarEmail = "client_email" + GoogleCalendarPrivateKey = "private_key" +) + +type GoogleCalendarIntegration struct { + Domain string `json:"domain"` + ApiKey map[string]string `json:"api_key_json"` +} + // Integrations configures the integrations with external systems. type Integrations struct { - Jira []*JiraIntegration `json:"jira"` - Zendesk []*ZendeskIntegration `json:"zendesk"` + Jira []*JiraIntegration `json:"jira"` + Zendesk []*ZendeskIntegration `json:"zendesk"` + GoogleCalendar []*GoogleCalendarIntegration `json:"google_calendar"` } // ValidateEnabledHostStatusIntegrations checks that the host status integrations @@ -359,6 +376,48 @@ func ValidateEnabledHostStatusIntegrations(webhook HostStatusWebhookSettings, in } } +func ValidateGoogleCalendarIntegrations(intgs []*GoogleCalendarIntegration, invalid *InvalidArgumentError) { + if len(intgs) > 1 { + invalid.Append("integrations.google_calendar", "integrating with >1 Google Workspace service account is not yet supported.") + } + for _, intg := range intgs { + if email, ok := intg.ApiKey[GoogleCalendarEmail]; !ok { + invalid.Append( + fmt.Sprintf("integrations.google_calendar.api_key_json.%s", GoogleCalendarEmail), + fmt.Sprintf("%s is required", GoogleCalendarEmail), + ) + } else { + email = strings.TrimSpace(email) + intg.ApiKey[GoogleCalendarEmail] = email + if email == "" { + invalid.Append( + fmt.Sprintf("integrations.google_calendar.api_key_json.%s", GoogleCalendarEmail), + fmt.Sprintf("%s cannot be blank", GoogleCalendarEmail), + ) + } + } + if privateKey, ok := intg.ApiKey["private_key"]; !ok { + invalid.Append( + fmt.Sprintf("integrations.google_calendar.api_key_json.%s", GoogleCalendarPrivateKey), + fmt.Sprintf("%s is required", GoogleCalendarPrivateKey), + ) + } else { + privateKey = strings.TrimSpace(privateKey) + intg.ApiKey[GoogleCalendarPrivateKey] = privateKey + if privateKey == "" { + invalid.Append( + fmt.Sprintf("integrations.google_calendar.api_key_json.%s", GoogleCalendarPrivateKey), + fmt.Sprintf("%s cannot be blank", GoogleCalendarPrivateKey), + ) + } + } + intg.Domain = strings.TrimSpace(intg.Domain) + if intg.Domain == "" { + invalid.Append("integrations.google_calendar.domain", "domain is required") + } + } +} + // ValidateEnabledVulnerabilitiesIntegrations checks that a single integration // is enabled for vulnerabilities. It adds any error it finds to the invalid // argument error, that can then be checked after the call for errors using diff --git a/server/fleet/policies.go b/server/fleet/policies.go index 78d57f86c4..dda2ec047d 100644 --- a/server/fleet/policies.go +++ b/server/fleet/policies.go @@ -30,6 +30,8 @@ type PolicyPayload struct { // // Empty string targets all platforms. Platform string + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + CalendarEventsEnabled bool } var ( @@ -107,6 +109,8 @@ type ModifyPolicyPayload struct { Platform *string `json:"platform"` // Critical marks the policy as high impact. Critical *bool `json:"critical" premium:"true"` + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + CalendarEventsEnabled *bool `json:"calendar_events_enabled" premium:"true"` } // Verify verifies the policy payload is valid. @@ -159,6 +163,8 @@ type PolicyData struct { // Empty string targets all platforms. Platform string `json:"platform" db:"platforms"` + CalendarEventsEnabled bool `json:"calendar_events_enabled" db:"calendar_events_enabled"` + UpdateCreateTimestamps } @@ -173,6 +179,11 @@ type Policy struct { HostCountUpdatedAt *time.Time `json:"host_count_updated_at" db:"host_count_updated_at"` } +type PolicyCalendarData struct { + ID uint `db:"id" json:"id"` + Name string `db:"name" json:"name"` +} + func (p Policy) AuthzType() string { return "policy" } @@ -212,6 +223,8 @@ type PolicySpec struct { // // Empty string targets all platforms. Platform string `json:"platform,omitempty"` + // CalendarEventsEnabled indicates whether calendar events are enabled for the policy. Only applies to team policies. + CalendarEventsEnabled bool `json:"calendar_events_enabled"` } // Verify verifies the policy data is valid. diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d1e3a86e98..9c9acbf4c0 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -404,12 +404,20 @@ type TeamSpec struct { MDM TeamSpecMDM `json:"mdm"` Scripts optjson.Slice[string] `json:"scripts"` WebhookSettings TeamSpecWebhookSettings `json:"webhook_settings"` + Integrations TeamSpecIntegrations `json:"integrations"` } type TeamSpecWebhookSettings struct { HostStatusWebhook *HostStatusWebhookSettings `json:"host_status_webhook"` } +// TeamSpecIntegrations contains the configuration for external services' +// integrations for a specific team. +type TeamSpecIntegrations struct { + // If value is nil, we don't want to change the existing value. + GoogleCalendar *TeamGoogleCalendarIntegration `json:"google_calendar"` +} + // TeamSpecFromTeam returns a TeamSpec constructed from the given Team. func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { features, err := json.Marshal(t.Config.Features) @@ -443,6 +451,11 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { webhookSettings.HostStatusWebhook = t.Config.WebhookSettings.HostStatusWebhook } + var integrations TeamSpecIntegrations + if t.Config.Integrations.GoogleCalendar != nil { + integrations.GoogleCalendar = t.Config.Integrations.GoogleCalendar + } + return &TeamSpec{ Name: t.Name, AgentOptions: agentOptions, @@ -451,5 +464,6 @@ func TeamSpecFromTeam(t *Team) (*TeamSpec, error) { MDM: mdmSpec, HostExpirySettings: &t.Config.HostExpirySettings, WebhookSettings: webhookSettings, + Integrations: integrations, }, nil } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 1469826979..425e0945e7 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -440,6 +440,10 @@ type UpdateHostPolicyCountsFunc func(ctx context.Context) error type PolicyQueriesForHostFunc func(ctx context.Context, host *fleet.Host) (map[string]string, error) +type GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]fleet.HostPolicyMembershipData, error) + +type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) + type AsyncBatchInsertPolicyMembershipFunc func(ctx context.Context, batch []fleet.PolicyMembershipResult) error type AsyncBatchUpdatePolicyTimestampFunc func(ctx context.Context, ids []uint, ts time.Time) error @@ -458,6 +462,24 @@ type DeleteSoftwareVulnerabilitiesFunc func(ctx context.Context, vulnerabilities type DeleteOutOfDateVulnerabilitiesFunc func(ctx context.Context, source fleet.VulnerabilitySource, duration time.Duration) error +type CreateOrUpdateCalendarEventFunc func(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) + +type GetCalendarEventFunc func(ctx context.Context, email string) (*fleet.CalendarEvent, error) + +type DeleteCalendarEventFunc func(ctx context.Context, calendarEventID uint) error + +type UpdateCalendarEventFunc func(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error + +type GetHostCalendarEventFunc func(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) + +type GetHostCalendarEventByEmailFunc func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) + +type UpdateHostCalendarWebhookStatusFunc func(ctx context.Context, hostID uint, status fleet.CalendarWebhookStatus) error + +type ListCalendarEventsFunc func(ctx context.Context, teamID *uint) ([]*fleet.CalendarEvent, error) + +type ListOutOfDateCalendarEventsFunc func(ctx context.Context, t time.Time) ([]*fleet.CalendarEvent, error) + type NewTeamPolicyFunc func(ctx context.Context, teamID uint, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) type ListTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) @@ -1492,6 +1514,12 @@ type DataStore struct { PolicyQueriesForHostFunc PolicyQueriesForHostFunc PolicyQueriesForHostFuncInvoked bool + GetTeamHostsPolicyMembershipsFunc GetTeamHostsPolicyMembershipsFunc + GetTeamHostsPolicyMembershipsFuncInvoked bool + + GetCalendarPoliciesFunc GetCalendarPoliciesFunc + GetCalendarPoliciesFuncInvoked bool + AsyncBatchInsertPolicyMembershipFunc AsyncBatchInsertPolicyMembershipFunc AsyncBatchInsertPolicyMembershipFuncInvoked bool @@ -1519,6 +1547,33 @@ type DataStore struct { DeleteOutOfDateVulnerabilitiesFunc DeleteOutOfDateVulnerabilitiesFunc DeleteOutOfDateVulnerabilitiesFuncInvoked bool + CreateOrUpdateCalendarEventFunc CreateOrUpdateCalendarEventFunc + CreateOrUpdateCalendarEventFuncInvoked bool + + GetCalendarEventFunc GetCalendarEventFunc + GetCalendarEventFuncInvoked bool + + DeleteCalendarEventFunc DeleteCalendarEventFunc + DeleteCalendarEventFuncInvoked bool + + UpdateCalendarEventFunc UpdateCalendarEventFunc + UpdateCalendarEventFuncInvoked bool + + GetHostCalendarEventFunc GetHostCalendarEventFunc + GetHostCalendarEventFuncInvoked bool + + GetHostCalendarEventByEmailFunc GetHostCalendarEventByEmailFunc + GetHostCalendarEventByEmailFuncInvoked bool + + UpdateHostCalendarWebhookStatusFunc UpdateHostCalendarWebhookStatusFunc + UpdateHostCalendarWebhookStatusFuncInvoked bool + + ListCalendarEventsFunc ListCalendarEventsFunc + ListCalendarEventsFuncInvoked bool + + ListOutOfDateCalendarEventsFunc ListOutOfDateCalendarEventsFunc + ListOutOfDateCalendarEventsFuncInvoked bool + NewTeamPolicyFunc NewTeamPolicyFunc NewTeamPolicyFuncInvoked bool @@ -3599,6 +3654,20 @@ func (s *DataStore) PolicyQueriesForHost(ctx context.Context, host *fleet.Host) return s.PolicyQueriesForHostFunc(ctx, host) } +func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]fleet.HostPolicyMembershipData, error) { + s.mu.Lock() + s.GetTeamHostsPolicyMembershipsFuncInvoked = true + s.mu.Unlock() + return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs) +} + +func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { + s.mu.Lock() + s.GetCalendarPoliciesFuncInvoked = true + s.mu.Unlock() + return s.GetCalendarPoliciesFunc(ctx, teamID) +} + func (s *DataStore) AsyncBatchInsertPolicyMembership(ctx context.Context, batch []fleet.PolicyMembershipResult) error { s.mu.Lock() s.AsyncBatchInsertPolicyMembershipFuncInvoked = true @@ -3662,6 +3731,69 @@ func (s *DataStore) DeleteOutOfDateVulnerabilities(ctx context.Context, source f return s.DeleteOutOfDateVulnerabilitiesFunc(ctx, source, duration) } +func (s *DataStore) CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) { + s.mu.Lock() + s.CreateOrUpdateCalendarEventFuncInvoked = true + s.mu.Unlock() + return s.CreateOrUpdateCalendarEventFunc(ctx, email, startTime, endTime, data, hostID, webhookStatus) +} + +func (s *DataStore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) { + s.mu.Lock() + s.GetCalendarEventFuncInvoked = true + s.mu.Unlock() + return s.GetCalendarEventFunc(ctx, email) +} + +func (s *DataStore) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error { + s.mu.Lock() + s.DeleteCalendarEventFuncInvoked = true + s.mu.Unlock() + return s.DeleteCalendarEventFunc(ctx, calendarEventID) +} + +func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error { + s.mu.Lock() + s.UpdateCalendarEventFuncInvoked = true + s.mu.Unlock() + return s.UpdateCalendarEventFunc(ctx, calendarEventID, startTime, endTime, data) +} + +func (s *DataStore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + s.mu.Lock() + s.GetHostCalendarEventFuncInvoked = true + s.mu.Unlock() + return s.GetHostCalendarEventFunc(ctx, hostID) +} + +func (s *DataStore) GetHostCalendarEventByEmail(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { + s.mu.Lock() + s.GetHostCalendarEventByEmailFuncInvoked = true + s.mu.Unlock() + return s.GetHostCalendarEventByEmailFunc(ctx, email) +} + +func (s *DataStore) UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status fleet.CalendarWebhookStatus) error { + s.mu.Lock() + s.UpdateHostCalendarWebhookStatusFuncInvoked = true + s.mu.Unlock() + return s.UpdateHostCalendarWebhookStatusFunc(ctx, hostID, status) +} + +func (s *DataStore) ListCalendarEvents(ctx context.Context, teamID *uint) ([]*fleet.CalendarEvent, error) { + s.mu.Lock() + s.ListCalendarEventsFuncInvoked = true + s.mu.Unlock() + return s.ListCalendarEventsFunc(ctx, teamID) +} + +func (s *DataStore) ListOutOfDateCalendarEvents(ctx context.Context, t time.Time) ([]*fleet.CalendarEvent, error) { + s.mu.Lock() + s.ListOutOfDateCalendarEventsFuncInvoked = true + s.mu.Unlock() + return s.ListOutOfDateCalendarEventsFunc(ctx, t) +} + func (s *DataStore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) { s.mu.Lock() s.NewTeamPolicyFuncInvoked = true diff --git a/server/service/appconfig.go b/server/service/appconfig.go index c92cd344cd..8a346183de 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -385,6 +385,7 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle appConfig.ServerSettings.EnableAnalytics = true } + fleet.ValidateGoogleCalendarIntegrations(appConfig.Integrations.GoogleCalendar, invalid) fleet.ValidateEnabledVulnerabilitiesIntegrations(appConfig.WebhookSettings.VulnerabilitiesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledFailingPoliciesIntegrations(appConfig.WebhookSettings.FailingPoliciesWebhook, appConfig.Integrations, invalid) fleet.ValidateEnabledHostStatusIntegrations(appConfig.WebhookSettings.HostStatusWebhook, invalid) @@ -477,6 +478,10 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } } + // If google_calendar is null, we keep the existing setting. If it's not null, we update. + if newAppConfig.Integrations.GoogleCalendar == nil { + appConfig.Integrations.GoogleCalendar = oldAppConfig.Integrations.GoogleCalendar + } if !license.IsPremium() { // reset transparency url to empty for downgraded licenses diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 884700bd7e..8881b19651 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -489,6 +489,9 @@ func TestAppConfigSecretsObfuscated(t *testing.T) { Zendesk: []*fleet.ZendeskIntegration{ {APIToken: "zendesktoken"}, }, + GoogleCalendar: []*fleet.GoogleCalendarIntegration{ + {ApiKey: map[string]string{fleet.GoogleCalendarPrivateKey: "google-calendar-private-key"}}, + }, }, }, nil } @@ -566,6 +569,8 @@ func TestAppConfigSecretsObfuscated(t *testing.T) { require.Equal(t, ac.SMTPSettings.SMTPPassword, fleet.MaskedPassword) require.Equal(t, ac.Integrations.Jira[0].APIToken, fleet.MaskedPassword) require.Equal(t, ac.Integrations.Zendesk[0].APIToken, fleet.MaskedPassword) + // Google Calendar private key is not obfuscated + require.Equal(t, ac.Integrations.GoogleCalendar[0].ApiKey[fleet.GoogleCalendarPrivateKey], "google-calendar-private-key") } }) } diff --git a/server/service/client.go b/server/service/client.go index f26bfcc051..8384e3e54d 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -885,9 +885,24 @@ func (c *Client) DoGitOps( group.EnrollSecret = &fleet.EnrollSecretSpec{Secrets: config.OrgSettings["secrets"].([]*fleet.EnrollSecret)} group.AppConfig.(map[string]interface{})["agent_options"] = config.AgentOptions delete(config.OrgSettings, "secrets") // secrets are applied separately in Client.ApplyGroup - if _, ok := group.AppConfig.(map[string]interface{})["mdm"]; !ok { - group.AppConfig.(map[string]interface{})["mdm"] = map[string]interface{}{} + + // Integrations + var integrations interface{} + var ok bool + if integrations, ok = group.AppConfig.(map[string]interface{})["integrations"]; !ok || integrations == nil { + integrations = map[string]interface{}{} + group.AppConfig.(map[string]interface{})["integrations"] = integrations } + if jira, ok := integrations.(map[string]interface{})["jira"]; !ok || jira == nil { + integrations.(map[string]interface{})["jira"] = []interface{}{} + } + if zendesk, ok := integrations.(map[string]interface{})["zendesk"]; !ok || zendesk == nil { + integrations.(map[string]interface{})["zendesk"] = []interface{}{} + } + if googleCal, ok := integrations.(map[string]interface{})["google_calendar"]; !ok || googleCal == nil { + integrations.(map[string]interface{})["google_calendar"] = []interface{}{} + } + // Ensure mdm config exists mdmConfig, ok := group.AppConfig.(map[string]interface{})["mdm"] if !ok || mdmConfig == nil { @@ -941,6 +956,26 @@ func (c *Client) DoGitOps( // Clear out any existing host_status_webhook settings team["webhook_settings"].(map[string]interface{})["host_status_webhook"] = map[string]interface{}{} } + // Integrations + var integrations interface{} + var ok bool + if integrations, ok = config.TeamSettings["integrations"]; !ok || integrations == nil { + integrations = map[string]interface{}{} + } + team["integrations"] = integrations + _, ok = integrations.(map[string]interface{}) + if !ok { + return errors.New("team_settings.integrations config is not a map") + } + if googleCal, ok := integrations.(map[string]interface{})["google_calendar"]; !ok || googleCal == nil { + integrations.(map[string]interface{})["google_calendar"] = map[string]interface{}{} + } else { + _, ok = googleCal.(map[string]interface{}) + if !ok { + return errors.New("team_settings.integrations.google_calendar config is not a map") + } + } + team["mdm"] = map[string]interface{}{} mdmAppConfig = team["mdm"].(map[string]interface{}) } @@ -1044,6 +1079,7 @@ func (c *Client) DoGitOps( if err != nil { return err } + err = c.doGitOpsQueries(config, logFn, dryRun) if err != nil { return err diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 2367b7529e..b531fd90bb 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5171,6 +5171,205 @@ func (s *integrationTestSuite) TestExternalIntegrationsConfig() { require.Len(t, config.Integrations.Zendesk, 0) } +func (s *integrationTestSuite) TestGoogleCalendarIntegrations() { + t := s.T() + email := "service-account@example.com" + privateKey := "-----BEGIN PRIVATE KEY-----\nXXXXX\n-----END" + domain := "example.com" + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q, + "private_key": %q + }, + "domain": %q + }] + } + }`, email, privateKey, domain, + )), http.StatusOK, + ) + + appConfig := s.getConfig() + require.Len(t, appConfig.Integrations.GoogleCalendar, 1) + assert.Equal(t, email, appConfig.Integrations.GoogleCalendar[0].ApiKey[fleet.GoogleCalendarEmail]) + assert.Equal(t, privateKey, appConfig.Integrations.GoogleCalendar[0].ApiKey[fleet.GoogleCalendarPrivateKey]) + assert.Equal(t, domain, appConfig.Integrations.GoogleCalendar[0].Domain) + + // Add 2nd config -- not allowed at this time + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q, + "private_key": %q + }, + "domain": %q + }, + { + "api_key_json": { + "client_email": "bozo@example.com", + "private_key": "abc" + }, + "domain": "example.com" + }] + } + }`, email, privateKey, domain, + )), http.StatusUnprocessableEntity, + ) + + // Make an unrelated config change, should not remove the integrations + var appCfgResp appConfigResponse + s.DoJSON( + "PATCH", "/api/v1/fleet/config", json.RawMessage( + `{ + "org_info": { + "org_name": "test-google-calendar-integrations" + } + }`, + ), http.StatusOK, &appCfgResp, + ) + require.Equal(t, "test-google-calendar-integrations", appCfgResp.OrgInfo.OrgName) + require.Len(t, appCfgResp.Integrations.GoogleCalendar, 1) + + // Update calendar config + domain = "new.com" + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q, + "private_key": %q + }, + "domain": %q + }] + } + }`, email, privateKey, domain, + )), http.StatusOK, + ) + appConfig = s.getConfig() + require.Len(t, appConfig.Integrations.GoogleCalendar, 1) + assert.Equal(t, email, appConfig.Integrations.GoogleCalendar[0].ApiKey[fleet.GoogleCalendarEmail]) + assert.Equal(t, privateKey, appConfig.Integrations.GoogleCalendar[0].ApiKey[fleet.GoogleCalendarPrivateKey]) + assert.Equal(t, domain, appConfig.Integrations.GoogleCalendar[0].Domain) + + // Clearing other integrations does not clear Google Calendar integration + appCfgResp = appConfigResponse{} + s.DoJSON( + "PATCH", "/api/v1/fleet/config", json.RawMessage( + `{ + "integrations": { + "jira": [], + "zendesk": [] + } + }`, + ), http.StatusOK, &appCfgResp, + ) + require.Len(t, appCfgResp.Integrations.GoogleCalendar, 1) + + // Clearing Google Calendar integration + appCfgResp = appConfigResponse{} + s.DoJSON( + "PATCH", "/api/v1/fleet/config", json.RawMessage( + `{ + "integrations": { + "google_calendar": [] + } + }`, + ), http.StatusOK, &appCfgResp, + ) + assert.Empty(t, appCfgResp.Integrations.GoogleCalendar) + + // Try adding Google Calendar integration without sending private key -- not allowed + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q + }, + "domain": %q + }] + } + }`, email, domain, + )), http.StatusUnprocessableEntity, + ) + + // Empty email -- not allowed + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": " ", + "private_key": %q + }, + "domain": %q + }] + } + }`, privateKey, domain, + )), http.StatusUnprocessableEntity, + ) + + // Empty domain -- not allowed + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q, + "private_key": %q + }, + "domain": "" + }] + } + }`, email, privateKey, + )), http.StatusUnprocessableEntity, + ) + + // Unknown fields fails as bad request + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q, + "private_key": %q + }, + "domain": %q, + "foo": "bar" + }] + } + }`, email, privateKey, domain, + )), http.StatusBadRequest, + ) + + // Null api_key_json -- fails validation + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": null, + "domain": %q + }] + } + }`, domain, + )), http.StatusUnprocessableEntity, + ) + +} + func (s *integrationTestSuite) TestQueriesBadRequests() { t := s.T() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 787207fb23..7466d78d14 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -97,6 +97,25 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { s.Do("POST", "/api/latest/fleet/teams", team, http.StatusOK) + // Create global calendar integration + calendarEmail := "service@example.com" + calendarWebhookUrl := "https://example.com/webhook" + s.DoRaw( + "PATCH", "/api/v1/fleet/config", []byte(fmt.Sprintf( + `{ + "integrations": { + "google_calendar": [{ + "api_key_json": { + "client_email": %q, + "private_key": "testKey" + }, + "domain": "example.com" + }] + } + }`, calendarEmail, + )), http.StatusOK, + ) + // updates a team, no secret is provided so it will keep the one generated // automatically when the team was created. agentOpts := json.RawMessage(`{"config": {"views": {"foo": "bar"}}, "overrides": {"platforms": {"darwin": {"views": {"bar": "qux"}}}}}`) @@ -163,6 +182,38 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { // an activity was created for team spec applied s.lastActivityMatches(fleet.ActivityTypeAppliedSpecTeam{}.ActivityName(), fmt.Sprintf(`{"teams": [{"id": %d, "name": %q}]}`, team.ID, team.Name), 0) + // Create team policy + teamPolicy, err := s.ds.NewTeamPolicy( + context.Background(), team.ID, nil, fleet.PolicyPayload{Name: "TestSpecTeamPolicy", Query: "SELECT 1"}, + ) + require.NoError(t, err) + defer func() { + _, err = s.ds.DeleteTeamPolicies(context.Background(), team.ID, []uint{teamPolicy.ID}) + require.NoError(t, err) + }() + + // Apply calendar integration + teamSpecs = map[string]any{ + "specs": []any{ + map[string]any{ + "name": teamName, + "integrations": map[string]any{ + "google_calendar": map[string]any{ + "enable_calendar_events": true, + "webhook_url": calendarWebhookUrl, + }, + }, + }, + }, + } + s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, &applyResp) + require.Len(t, applyResp.TeamIDsByName, 1) + + team, err = s.ds.TeamByName(context.Background(), teamName) + require.NotNil(t, team.Config.Integrations.GoogleCalendar) + assert.Equal(t, calendarWebhookUrl, team.Config.Integrations.GoogleCalendar.WebhookURL) + assert.True(t, team.Config.Integrations.GoogleCalendar.Enable) + // dry-run with invalid windows updates teamSpecs = map[string]any{ "specs": []any{ @@ -971,6 +1022,21 @@ func (s *integrationEnterpriseTestSuite) TestTeamEndpoints() { modifyExpiry.HostExpirySettings.HostExpiryWindow = 0 s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyExpiry, http.StatusUnprocessableEntity, &tmResp) + // Modify team's calendar config + modifyCalendar := fleet.TeamPayload{ + Integrations: &fleet.TeamIntegrations{ + GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{ + WebhookURL: "https://example.com/modified", + }, + }, + } + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyCalendar, http.StatusOK, &tmResp) + assert.Equal(t, modifyCalendar.Integrations.GoogleCalendar, tmResp.Team.Config.Integrations.GoogleCalendar) + + // Illegal team calendar config + modifyCalendar.Integrations.GoogleCalendar.Enable = true + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1ID), modifyCalendar, http.StatusUnprocessableEntity, &tmResp) + // list team users var usersResp listUsersResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d/users", tm1ID), nil, http.StatusOK, &usersResp) @@ -3613,7 +3679,7 @@ func (s *integrationEnterpriseTestSuite) TestGlobalPolicyCreateReadPatch() { } func (s *integrationEnterpriseTestSuite) TestTeamPolicyCreateReadPatch() { - fields := []string{"Query", "Name", "Description", "Resolution", "Platform", "Critical"} + fields := []string{"Query", "Name", "Description", "Resolution", "Platform", "Critical", "CalendarEventsEnabled"} team1, err := s.ds.NewTeam(context.Background(), &fleet.Team{ ID: 42, @@ -3624,24 +3690,26 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicyCreateReadPatch() { createPol1 := &teamPolicyResponse{} createPol1Req := &teamPolicyRequest{ - Query: "query", - Name: "name1", - Description: "description", - Resolution: "resolution", - Platform: "linux", - Critical: true, + Query: "query", + Name: "name1", + Description: "description", + Resolution: "resolution", + Platform: "linux", + Critical: true, + CalendarEventsEnabled: true, } s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol1Req, http.StatusOK, &createPol1) allEqual(s.T(), createPol1Req, createPol1.Policy, fields...) createPol2 := &teamPolicyResponse{} createPol2Req := &teamPolicyRequest{ - Query: "query", - Name: "name2", - Description: "description", - Resolution: "resolution", - Platform: "linux", - Critical: false, + Query: "query", + Name: "name2", + Description: "description", + Resolution: "resolution", + Platform: "linux", + Critical: false, + CalendarEventsEnabled: false, } s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/teams/%d/policies", team1.ID), createPol2Req, http.StatusOK, &createPol2) allEqual(s.T(), createPol2Req, createPol2.Policy, fields...) @@ -3657,12 +3725,13 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicyCreateReadPatch() { patchPol1Req := &modifyTeamPolicyRequest{ ModifyPolicyPayload: fleet.ModifyPolicyPayload{ - Name: ptr.String("newName1"), - Query: ptr.String("newQuery"), - Description: ptr.String("newDescription"), - Resolution: ptr.String("newResolution"), - Platform: ptr.String("windows"), - Critical: ptr.Bool(false), + Name: ptr.String("newName1"), + Query: ptr.String("newQuery"), + Description: ptr.String("newDescription"), + Resolution: ptr.String("newResolution"), + Platform: ptr.String("windows"), + Critical: ptr.Bool(false), + CalendarEventsEnabled: ptr.Bool(false), }, } patchPol1 := &modifyTeamPolicyResponse{} @@ -3671,12 +3740,13 @@ func (s *integrationEnterpriseTestSuite) TestTeamPolicyCreateReadPatch() { patchPol2Req := &modifyTeamPolicyRequest{ ModifyPolicyPayload: fleet.ModifyPolicyPayload{ - Name: ptr.String("newName2"), - Query: ptr.String("newQuery"), - Description: ptr.String("newDescription"), - Resolution: ptr.String("newResolution"), - Platform: ptr.String("windows"), - Critical: ptr.Bool(true), + Name: ptr.String("newName2"), + Query: ptr.String("newQuery"), + Description: ptr.String("newDescription"), + Resolution: ptr.String("newResolution"), + Platform: ptr.String("windows"), + Critical: ptr.Bool(true), + CalendarEventsEnabled: ptr.Bool(true), }, } patchPol2 := &modifyTeamPolicyResponse{} diff --git a/server/service/osquery.go b/server/service/osquery.go index 8a77903a88..379afafd6a 100644 --- a/server/service/osquery.go +++ b/server/service/osquery.go @@ -1001,6 +1001,10 @@ func (svc *Service) SubmitDistributedQueryResults( if len(policyResults) > 0 { + if err := processCalendarPolicies(ctx, svc.ds, ac, host, policyResults, svc.logger); err != nil { + logging.WithErr(ctx, err) + } + // filter policy results for webhooks var policyIDs []uint if globalPolicyAutomationsEnabled(ac.WebhookSettings, ac.Integrations) { @@ -1093,6 +1097,99 @@ func (svc *Service) SubmitDistributedQueryResults( return nil } +func processCalendarPolicies( + ctx context.Context, + ds fleet.Datastore, + appConfig *fleet.AppConfig, + host *fleet.Host, + policyResults map[uint]*bool, + logger log.Logger, +) error { + if len(appConfig.Integrations.GoogleCalendar) == 0 || host.TeamID == nil { + return nil + } + + team, err := ds.Team(ctx, *host.TeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "load host team") + } + + if team.Config.Integrations.GoogleCalendar == nil || !team.Config.Integrations.GoogleCalendar.Enable { + return nil + } + + hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEvent(ctx, host.ID) + switch { + case err == nil: + if hostCalendarEvent.WebhookStatus != fleet.CalendarWebhookStatusPending { + return nil + } + case fleet.IsNotFound(err): + return nil + default: + return ctxerr.Wrap(ctx, err, "get host calendar event") + } + + now := time.Now() + if now.Before(calendarEvent.StartTime) { + level.Warn(logger).Log("msg", "results came too early", "now", now, "start_time", calendarEvent.StartTime) + return nil + } + + // + // TODO(lucas): Discuss. + // + const allowedTimeBeforeEndTime = 5 * time.Minute // up to 5 minutes before the end_time + + if now.After(calendarEvent.EndTime.Add(-allowedTimeBeforeEndTime)) { + level.Warn(logger).Log("msg", "results came too late", "now", now, "end_time", calendarEvent.EndTime) + return nil + } + + calendarPolicies, err := ds.GetCalendarPolicies(ctx, *host.TeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get calendar policy ids") + } + if len(calendarPolicies) == 0 { + return nil + } + + failingCalendarPolicies := getFailingCalendarPolicies(policyResults, calendarPolicies) + if len(failingCalendarPolicies) == 0 { + return nil + } + + go func() { + if err := fleet.FireCalendarWebhook( + team.Config.Integrations.GoogleCalendar.WebhookURL, + host.ID, host.HardwareSerial, host.DisplayName(), failingCalendarPolicies, "", + ); err != nil { + level.Error(logger).Log("msg", "fire webhook", "err", err) + return + } + if err := ds.UpdateHostCalendarWebhookStatus(context.Background(), host.ID, fleet.CalendarWebhookStatusSent); err != nil { + level.Error(logger).Log("msg", "mark fired webhook as sent", "err", err) + } + }() + + return nil +} + +func getFailingCalendarPolicies(policyResults map[uint]*bool, calendarPolicies []fleet.PolicyCalendarData) []fleet.PolicyCalendarData { + var failingPolicies []fleet.PolicyCalendarData + for _, calendarPolicy := range calendarPolicies { + result, ok := policyResults[calendarPolicy.ID] + if !ok || // ignore result of a policy that's not configured for calendar. + result == nil { // ignore policies that failed to execute. + continue + } + if !*result { + failingPolicies = append(failingPolicies, calendarPolicy) + } + } + return failingPolicies +} + // preProcessSoftwareResults will run pre-processing on the responses of the software queries. // It will move the results from the software extra queries (e.g. software_vscode_extensions) // into the main software query results (software_{macos|linux|windows}). diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 40187769ba..15a7a90acd 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -20,14 +20,15 @@ import ( ///////////////////////////////////////////////////////////////////////////////// type teamPolicyRequest struct { - TeamID uint `url:"team_id"` - QueryID *uint `json:"query_id"` - Query string `json:"query"` - Name string `json:"name"` - Description string `json:"description"` - Resolution string `json:"resolution"` - Platform string `json:"platform"` - Critical bool `json:"critical" premium:"true"` + TeamID uint `url:"team_id"` + QueryID *uint `json:"query_id"` + Query string `json:"query"` + Name string `json:"name"` + Description string `json:"description"` + Resolution string `json:"resolution"` + Platform string `json:"platform"` + Critical bool `json:"critical" premium:"true"` + CalendarEventsEnabled bool `json:"calendar_events_enabled"` } type teamPolicyResponse struct { @@ -40,13 +41,14 @@ func (r teamPolicyResponse) error() error { return r.Err } func teamPolicyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*teamPolicyRequest) resp, err := svc.NewTeamPolicy(ctx, req.TeamID, fleet.PolicyPayload{ - QueryID: req.QueryID, - Name: req.Name, - Query: req.Query, - Description: req.Description, - Resolution: req.Resolution, - Platform: req.Platform, - Critical: req.Critical, + QueryID: req.QueryID, + Name: req.Name, + Query: req.Query, + Description: req.Description, + Resolution: req.Resolution, + Platform: req.Platform, + Critical: req.Critical, + CalendarEventsEnabled: req.CalendarEventsEnabled, }) if err != nil { return teamPolicyResponse{Err: err}, nil @@ -390,6 +392,9 @@ func (svc *Service) modifyPolicy(ctx context.Context, teamID *uint, id uint, p f if p.Critical != nil { policy.Critical = *p.Critical } + if p.CalendarEventsEnabled != nil { + policy.CalendarEventsEnabled = *p.CalendarEventsEnabled + } logging.WithExtras(ctx, "name", policy.Name, "sql", policy.Query) err = svc.ds.SavePolicy(ctx, policy, shouldRemoveAll) diff --git a/server/webhooks/failing_policies_test.go b/server/webhooks/failing_policies_test.go index 71b890cfe9..1c2edda535 100644 --- a/server/webhooks/failing_policies_test.go +++ b/server/webhooks/failing_policies_test.go @@ -124,7 +124,8 @@ func TestTriggerFailingPoliciesWebhookBasic(t *testing.T) { "passing_host_count": 0, "failing_host_count": 0, "host_count_updated_at": null, - "critical": true + "critical": true, + "calendar_events_enabled": false }, "hosts": [ { @@ -183,16 +184,17 @@ func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) { policiesByID := map[uint]*fleet.Policy{ 1: { PolicyData: fleet.PolicyData{ - ID: 1, - Name: "policy1", - Query: "select 1", - Description: "policy1 description", - AuthorID: ptr.Uint(1), - AuthorName: "Alice", - AuthorEmail: "alice@example.com", - TeamID: &teamID, - Resolution: ptr.String("policy1 resolution"), - Platform: "darwin", + ID: 1, + Name: "policy1", + Query: "select 1", + Description: "policy1 description", + AuthorID: ptr.Uint(1), + AuthorName: "Alice", + AuthorEmail: "alice@example.com", + TeamID: &teamID, + Resolution: ptr.String("policy1 resolution"), + Platform: "darwin", + CalendarEventsEnabled: true, }, }, 2: { @@ -309,7 +311,8 @@ func TestTriggerFailingPoliciesWebhookTeam(t *testing.T) { "passing_host_count": 0, "failing_host_count": 0, "host_count_updated_at": null, - "critical": false + "critical": false, + "calendar_events_enabled": true }, "hosts": [ { diff --git a/tools/calendar/README.md b/tools/calendar/README.md new file mode 100644 index 0000000000..bab2f481b4 --- /dev/null +++ b/tools/calendar/README.md @@ -0,0 +1,32 @@ +# Helper methods for Google calendar + +To delete all downtime events from a Google Calendar, use `delete-events/delete-events.go` + +To move all downtime events from multiple Google Calendars to a specific time, use `move-events/move-events.go` + +# Calendar server for load testing + +Test calendar server that provides a REST API for managing events. +Since we may not have access to a real calendar server (such as Google Calendar API), this server will be used to test the calendar feature during load testing. + +Start the server like: +```shell +go run calendar.go --port 8083 --db ./calendar.db +``` + +The server uses a SQLite database to store events. This database can be modified during testing. + +On the fleet server, configure Google Calendar API key where `client_email` is the specified value and the `private_key` is the base URL of the calendar server: +```json +{ + "client_email": "calendar-load@example.com", + "private_key": "http://localhost:8083" +} +``` + +## Useful tricks + +To update all the events in SQLite database to start at the current time, do SQL query: +```sql +UPDATE events SET start = unixepoch('now'), end = unixepoch('now', '+30 minutes'); +``` diff --git a/tools/calendar/calendar.go b/tools/calendar/calendar.go new file mode 100644 index 0000000000..64b70c7a94 --- /dev/null +++ b/tools/calendar/calendar.go @@ -0,0 +1,39 @@ +package main + +import ( + "flag" + "fmt" + calendartest "github.com/fleetdm/fleet/v4/ee/server/calendar/load_test" + _ "github.com/mattn/go-sqlite3" + "log" + "net/http" + "os" + "time" +) + +func main() { + port := flag.Uint("port", 8083, "Port to listen on") + dbFileName := flag.String("db", "./calendar.db", "SQLite db file name") + flag.Parse() + + handler, err := calendartest.Configure(*dbFileName) + if err != nil { + log.Fatal(err) + } + defer calendartest.Close() + + listenAddr := fmt.Sprintf(":%d", *port) + errLogger := log.New(os.Stderr, "", log.LstdFlags) + + server := &http.Server{ + Addr: listenAddr, + Handler: handler, + ErrorLog: errLogger, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + + // Start the HTTP server + log.Fatal(server.ListenAndServe()) +} diff --git a/tools/calendar/delete-events/delete-events.go b/tools/calendar/delete-events/delete-events.go new file mode 100644 index 0000000000..cfa0b2ce07 --- /dev/null +++ b/tools/calendar/delete-events/delete-events.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "flag" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/jwt" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" + "log" + "os" +) + +// Delete all events with eventTitle from the primary calendar of the user. + +var ( + serviceEmail = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL") + privateKey = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY") +) + +const ( + eventTitle = "💻🚫Downtime" +) + +func main() { + if serviceEmail == "" || privateKey == "" { + log.Fatal("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL and FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY must be set") + } + userEmail := flag.String("user", "", "User email to impersonate") + flag.Parse() + if *userEmail == "" { + log.Fatal("--user is required") + } + + ctx := context.Background() + conf := &jwt.Config{ + Email: serviceEmail, + Scopes: []string{ + "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.settings.readonly", + }, + PrivateKey: []byte(privateKey), + TokenURL: google.JWTTokenURL, + Subject: *userEmail, + } + client := conf.Client(ctx) + // Create a new calendar service + service, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to create Calendar service: %v", err) + } + numberDeleted := 0 + for { + list, err := service.Events.List("primary").EventTypes("default").MaxResults(1000).OrderBy("startTime").SingleEvents(true).ShowDeleted(false).Q(eventTitle).Do() + if err != nil { + log.Fatalf("Unable to retrieve list of events: %v", err) + } + if len(list.Items) == 0 { + break + } + for _, item := range list.Items { + if item.Summary == eventTitle { + err = service.Events.Delete("primary", item.Id).Do() + if err != nil { + log.Fatalf("Unable to delete event: %v", err) + } + numberDeleted++ + if numberDeleted%10 == 0 { + log.Printf("Deleted %d events", numberDeleted) + } + } + } + } + log.Printf("DONE. Deleted %d events total", numberDeleted) +} diff --git a/tools/calendar/move-events/move-events.go b/tools/calendar/move-events/move-events.go new file mode 100644 index 0000000000..2e66b56e64 --- /dev/null +++ b/tools/calendar/move-events/move-events.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "flag" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/jwt" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" + "log" + "os" + "strings" + "sync" + "time" +) + +// Move all events with eventTitle from the primary calendar of the user to the new time. +// Only events in the future relative to the new event time are moved. In other words, if the current event time is in the past, it is not moved. + +var ( + serviceEmail = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL") + privateKey = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY") +) + +const ( + eventTitle = "💻🚫Downtime" +) + +func main() { + if serviceEmail == "" || privateKey == "" { + log.Fatal("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL and FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY must be set") + } + userEmails := flag.String("users", "", "Comma-separated list of user emails to impersonate") + dateTimeStr := flag.String("datetime", "", "Event time in "+time.RFC3339+" format") + flag.Parse() + if *userEmails == "" { + log.Fatal("--users are required") + } + if *dateTimeStr == "" { + log.Fatal("--datetime is required") + } + dateTime, err := time.Parse(time.RFC3339, *dateTimeStr) + if err != nil { + log.Fatalf("Unable to parse datetime: %v", err) + } + dateTimeEndStr := dateTime.Add(30 * time.Minute).Format(time.RFC3339) + userEmailList := strings.Split(*userEmails, ",") + if len(userEmailList) == 0 { + log.Fatal("No user emails provided") + } + + ctx := context.Background() + + var wg sync.WaitGroup + + for _, userEmail := range userEmailList { + wg.Add(1) + go func(userEmail string) { + defer wg.Done() + conf := &jwt.Config{ + Email: serviceEmail, + Scopes: []string{ + "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.settings.readonly", + }, + PrivateKey: []byte(privateKey), + TokenURL: google.JWTTokenURL, + Subject: userEmail, + } + client := conf.Client(ctx) + // Create a new calendar service + service, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to create Calendar service: %v", err) + } + + numberMoved := 0 + for { + list, err := service.Events.List("primary").EventTypes("default"). + MaxResults(1000). + OrderBy("startTime"). + SingleEvents(true). + ShowDeleted(false). + TimeMin(dateTimeEndStr). + Q(eventTitle). + Do() + if err != nil { + log.Fatalf("Unable to retrieve list of events: %v", err) + } + if len(list.Items) == 0 { + break + } + for _, item := range list.Items { + if item.Summary == eventTitle { + item.Start.DateTime = dateTime.Format(time.RFC3339) + item.End.DateTime = dateTime.Add(30 * time.Minute).Format(time.RFC3339) + _, err := service.Events.Update("primary", item.Id, item).Do() + if err != nil { + log.Fatalf("Unable to update event: %v", err) + } + numberMoved++ + } + } + } + log.Printf("Moved %d events for %s", numberMoved, userEmail) + }(userEmail) + } + + // Wait for all goroutines to finish + wg.Wait() + +} diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 5480e2432f..9a03bf15b6 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -84,6 +84,9 @@ github.com/fleetdm/fleet/v4/server/fleet/ZendeskIntegration APIToken string github.com/fleetdm/fleet/v4/server/fleet/ZendeskIntegration GroupID int64 github.com/fleetdm/fleet/v4/server/fleet/ZendeskIntegration EnableFailingPolicies bool github.com/fleetdm/fleet/v4/server/fleet/ZendeskIntegration EnableSoftwareVulnerabilities bool +github.com/fleetdm/fleet/v4/server/fleet/Integrations GoogleCalendar []*fleet.GoogleCalendarIntegration +github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration Domain string +github.com/fleetdm/fleet/v4/server/fleet/GoogleCalendarIntegration ApiKey map[string]string github.com/fleetdm/fleet/v4/server/fleet/AppConfig MDM fleet.MDM github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMDefaultTeam string github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMEnabledAndConfigured bool diff --git a/tools/webhook/README.md b/tools/webhook/README.md new file mode 100644 index 0000000000..6bdb2416a7 --- /dev/null +++ b/tools/webhook/README.md @@ -0,0 +1,16 @@ +# webhook + +Test tool for Fleet features that use webhook URLs. +It reads and parses the request a JSON body and prints the JSON to standard output (with indentation). + +```sh +go run ./tools/webhook 8082 +2024/03/20 09:10:00 { + "error": "No fleetdm.com Google account associated with this host.", + "host_display_name": "dChYnk.uxURT", + "host_id": 2, + "host_serial_number": "", + "timestamp": "2024-03-20T09:10:00.129982-03:00" +} +... +``` diff --git a/tools/webhook/main.go b/tools/webhook/main.go new file mode 100644 index 0000000000..452e0a15ca --- /dev/null +++ b/tools/webhook/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" +) + +func main() { + log.SetFlags(log.LstdFlags) + + http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("failed to read body: %s", err) + return + } + + var v interface{} + if err := json.Unmarshal(body, &v); err != nil { + log.Printf("failed to parse JSON body: %s", err) + return + } + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + panic(err) + } + log.Printf("%s", b) + + w.WriteHeader(http.StatusOK) + })) + //nolint:gosec // G114: file server used for testing purposes only. + err := http.ListenAndServe("0.0.0.0:"+os.Args[1], nil) + if err != nil { + panic(err) + } +}