From df141cdfa42d51b7dfe7d6078332e0e7d512dffc Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Mon, 8 Jul 2024 10:20:03 -0500 Subject: [PATCH] Check for calendar updates after callbacks from Google (#20156) #19352 Video explaining code changes: https://www.loom.com/share/370200a276b84aa388effd6ebd762e01?sid=038508c4-f3c2-40c0-baf6-6b6df682d1f0 In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past. - Added new endpoint for Google Calendar: `/api/_version_/fleet/calendar/webhook/{event_uuid}` - Added UUID to `calendar_events` table to make webhook lookup more efficient - webhook endpoint will only recreate event if needed -- it will not fire webhook. Webhook is still done by the cron job. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) - [x] Added/updated tests - [x] If database migrations are included, checked table schema to confirm autoupdate - For database migrations: - [x] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [x] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [x] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). - [x] Manual QA for all new/changed functionality - For Orbit and Fleet Desktop changes: --- changes/19352-calendar-real-time | 1 + ee/server/calendar/google_calendar.go | 134 +++++++- .../google_calendar_integration_test.go | 8 +- ee/server/calendar/google_calendar_load.go | 13 +- ee/server/calendar/google_calendar_mock.go | 52 ++- ee/server/calendar/google_calendar_test.go | 83 ++++- ee/server/service/calendar.go | 133 ++++++++ server/cron/calendar_cron.go | 156 +++------ server/cron/calendar_cron_test.go | 25 +- server/datastore/mysql/calendar_events.go | 32 +- .../datastore/mysql/calendar_events_test.go | 31 +- server/datastore/mysql/hosts_test.go | 3 +- ...40626195531_AddTimezoneToCalendarEvents.go | 2 +- .../20240707134035_AddUUIDToCalendarEvents.go | 34 ++ ...0707134035_AddUUIDToCalendarEvents_test.go | 42 +++ server/datastore/mysql/policies.go | 7 +- server/datastore/mysql/policies_test.go | 50 ++- server/datastore/mysql/schema.sql | 8 +- server/datastore/mysql/teams.go | 32 +- server/fleet/calendar.go | 14 +- server/fleet/calendar_events.go | 7 + server/fleet/datastore.go | 16 +- server/fleet/service.go | 6 + server/mock/datastore_mock.go | 42 ++- server/service/calendar.go | 59 ++++ server/service/calendar/calendar.go | 94 ++++++ server/service/handler.go | 3 + server/service/integration_core_test.go | 7 +- server/service/integration_enterprise_test.go | 315 +++++++++++++++++- tools/calendar/README.md | 2 + tools/calendar/delete-events/delete-events.go | 2 + tools/calendar/move-events/move-events.go | 2 + tools/calendar/stop-channel/stop-channel.go | 123 +++++++ 33 files changed, 1303 insertions(+), 235 deletions(-) create mode 100644 changes/19352-calendar-real-time create mode 100644 ee/server/service/calendar.go create mode 100644 server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go create mode 100644 server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go create mode 100644 server/service/calendar.go create mode 100644 server/service/calendar/calendar.go create mode 100644 tools/calendar/stop-channel/stop-channel.go diff --git a/changes/19352-calendar-real-time b/changes/19352-calendar-real-time new file mode 100644 index 0000000000..dc72e98899 --- /dev/null +++ b/changes/19352-calendar-real-time @@ -0,0 +1 @@ +- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past. diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go index 938f7d65a6..db7f98cc79 100644 --- a/ee/server/calendar/google_calendar.go +++ b/ee/server/calendar/google_calendar.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "regexp" + "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" + "github.com/google/uuid" "golang.org/x/oauth2/google" "golang.org/x/oauth2/jwt" "google.golang.org/api/calendar/v3" @@ -35,7 +37,7 @@ const ( endHour = 17 eventLength = 30 * time.Minute calendarID = "primary" - mockEmail = "calendar-mock@example.com" + MockEmail = "calendar-mock@example.com" loadEmail = "calendar-load@example.com" ) @@ -52,6 +54,7 @@ type GoogleCalendarConfig struct { Context context.Context IntegrationConfig *fleet.GoogleCalendarIntegration Logger kitlog.Logger + ServerURL string // Should be nil for production API GoogleCalendarAPI } @@ -71,7 +74,7 @@ func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar { // Use the provided API. case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == loadEmail: config.API = &GoogleCalendarLoadAPI{Logger: config.Logger} - case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == mockEmail: + case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == MockEmail: config.API = &GoogleCalendarMockAPI{config.Logger} default: config.API = &GoogleCalendarLowLevelAPI{logger: config.Logger} @@ -82,27 +85,33 @@ func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar { } type GoogleCalendarAPI interface { - Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error + Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL 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 + Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) + Stop(channelID string, resourceID string) error } type eventDetails struct { ID string `json:"id"` ETag string `json:"etag"` + // ChannelID and ResourceID are for watching event changes + ChannelID string `json:"channel_id"` + ResourceID string `json:"resource_id"` } type GoogleCalendarLowLevelAPI struct { - service *calendar.Service - logger kitlog.Logger + service *calendar.Service + logger kitlog.Logger + serverURL string } // Configure creates a new Google Calendar service using the provided credentials. func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure( - ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string, + ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string, ) error { // Create a new calendar service conf := &jwt.Config{ @@ -118,6 +127,7 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure( return err } lowLevelAPI.service = service + lowLevelAPI.serverURL = serverURL return nil } @@ -181,6 +191,38 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { return err } +func (lowLevelAPI *GoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { + resp, err := lowLevelAPI.withRetry( + func() (any, error) { + return lowLevelAPI.service.Events.Watch(calendarID, &calendar.Channel{ + Id: channelID, // channelID is also used for authentication -- it should be a random value + Type: "web_hook", + Address: fmt.Sprintf("%s/api/v1/fleet/calendar/webhook/%s", + lowLevelAPI.serverURL, eventUUID), + Params: map[string]string{ + "ttl": strconv.FormatUint(ttl, 10), + }, + }).EventTypes("default").Do() + }, + ) + if err != nil { + return "", err + } + return resp.(*calendar.Channel).ResourceId, nil +} + +func (lowLevelAPI *GoogleCalendarLowLevelAPI) Stop(channelID string, resourceID string) error { + _, err := lowLevelAPI.withRetry( + func() (any, error) { + return nil, lowLevelAPI.service.Channels.Stop(&calendar.Channel{ + Id: channelID, + ResourceId: resourceID, + }).Do() + }, + ) + return err +} + func (lowLevelAPI *GoogleCalendarLowLevelAPI) withRetry(fn func() (any, error)) (any, error) { retryStrategy := backoff.NewExponentialBackOff() retryStrategy.MaxElapsedTime = 10 * time.Minute @@ -207,6 +249,7 @@ func (c *GoogleCalendar) Configure(userEmail string) error { err := c.config.API.Configure( c.config.Context, c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail], c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], adjustedUserEmail, + c.config.ServerURL, ) if err != nil { return ctxerr.Wrap(c.config.Context, err, "creating Google calendar service") @@ -218,7 +261,7 @@ func (c *GoogleCalendar) Configure(userEmail string) error { return nil } -func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) ( +func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) (body string, ok bool, err error)) ( *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. @@ -235,6 +278,11 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn // http.StatusNotFound should be very rare -- Google keeps events for a while after they are deleted case isNotFound(err): deleted = true + // If event was deleted, we need to stop watching it + err = c.config.API.Stop(details.ChannelID, details.ResourceID) + if err != nil { + level.Warn(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", err) + } case err != nil: return nil, false, ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event") } @@ -294,7 +342,7 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn if err != nil { return nil, false, err } - fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent) + fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent, event.UUID, details.ChannelID, details.ResourceID) if err != nil { return nil, false, err } @@ -381,14 +429,15 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet return &details, nil } -func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) { +func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, + genBodyFn func(conflict bool) (body string, ok bool, err error)) (*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, + dayOfEvent time.Time, genBodyFn func(conflict bool) (body string, ok bool, err error), timeNow func() time.Time, ) (*fleet.CalendarEvent, error) { var err error if c.location == nil { @@ -482,14 +531,31 @@ func (c *GoogleCalendar) createEvent( 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) + body, ok, err := genBodyFn(conflict) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "generating Google calendar event body") + } + if !ok { + // We don't need to create this event + return nil, nil + } + event.Description = body event, err = c.config.API.CreateEvent(event) if err != nil { return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event") } + // Watch for event changes + secondsToEventEnd := eventEnd.Sub(now).Milliseconds() / 1000 + eventUUID := uuid.New().String() + channelUUID := uuid.New().String() + resourceID, err := c.config.API.Watch(eventUUID, channelUUID, uint64(secondsToEventEnd)) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "watching Google calendar event") + } + // Convert Google event to Fleet event - fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event) + fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelUUID, resourceID) if err != nil { return nil, err } @@ -539,7 +605,9 @@ func getLocation(tz string, config *GoogleCalendarConfig) *time.Location { return loc } -func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event) ( +func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event, eventUUID string, + channelID string, + resourceID string) ( *fleet.CalendarEvent, error, ) { fleetEvent := &fleet.CalendarEvent{} @@ -547,9 +615,12 @@ func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime ti fleetEvent.EndTime = endTime fleetEvent.Email = c.currentUserEmail fleetEvent.TimeZone = c.location.String() + fleetEvent.UUID = eventUUID details := &eventDetails{ - ID: event.Id, - ETag: event.Etag, + ID: event.Id, + ETag: event.Etag, + ChannelID: channelID, + ResourceID: resourceID, } detailsJson, err := json.Marshal(details) if err != nil { @@ -564,6 +635,14 @@ func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error { if err != nil { return err } + // Stop watching the event before deleting the event so that we don't get a callback for the deletion + if details.ChannelID != "" && details.ResourceID != "" { + stopErr := c.config.API.Stop(details.ChannelID, details.ResourceID) + if stopErr != nil { + level.Warn(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", stopErr) + } + } + // Delete the event err = c.config.API.DeleteEvent(details.ID) switch { case isAlreadyDeleted(err): @@ -573,3 +652,28 @@ func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error { } return nil } + +func (c *GoogleCalendar) StopEventChannel(event *fleet.CalendarEvent) error { + details, err := c.unmarshalDetails(event) + if err != nil { + return err + } + if details.ChannelID != "" && details.ResourceID != "" { + stopErr := c.config.API.Stop(details.ChannelID, details.ResourceID) + if stopErr != nil { + level.Warn(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", stopErr) + } + } + return nil +} + +func (c *GoogleCalendar) Get(event *fleet.CalendarEvent, key string) (interface{}, error) { + if key == "channelID" { + details, err := c.unmarshalDetails(event) + if err != nil { + return nil, err + } + return details.ChannelID, nil + } + return nil, ctxerr.Errorf(c.config.Context, "unknown key: %s", key) +} diff --git a/ee/server/calendar/google_calendar_integration_test.go b/ee/server/calendar/google_calendar_integration_test.go index 7f42b23b22..6e2bce57a2 100644 --- a/ee/server/calendar/google_calendar_integration_test.go +++ b/ee/server/calendar/google_calendar_integration_test.go @@ -66,8 +66,8 @@ func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() { gCal := NewGoogleCalendar(config) err := gCal.Configure(userEmail) require.NoError(t, err) - genBodyFn := func(bool) string { - return "Test event" + genBodyFn := func(bool) (string, bool, error) { + return "Test event", true, nil } eventDate := time.Now().Add(48 * time.Hour) event, err := gCal.CreateEvent(eventDate, genBodyFn) @@ -110,8 +110,8 @@ func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() { gCal := NewGoogleCalendar(config) err := gCal.Configure(userEmail) require.NoError(t, err) - genBodyFn := func(bool) string { - return "Test event" + genBodyFn := func(bool) (string, bool, error) { + return "Test event", true, nil } eventDate := time.Now().Add(48 * time.Hour) event, err := gCal.CreateEvent(eventDate, genBodyFn) diff --git a/ee/server/calendar/google_calendar_load.go b/ee/server/calendar/google_calendar_load.go index 8446af20c5..cb9c1bf917 100644 --- a/ee/server/calendar/google_calendar_load.go +++ b/ee/server/calendar/google_calendar_load.go @@ -22,10 +22,12 @@ type GoogleCalendarLoadAPI struct { userToImpersonate string ctx context.Context client *http.Client + serverURL string } // Configure creates a new Google Calendar service using the provided credentials. -func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string) error { +func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string, + serverURL string) error { if lowLevelAPI.Logger == nil { lowLevelAPI.Logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarLoadAPI", "user", userToImpersonate) } @@ -35,6 +37,7 @@ func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ strin if lowLevelAPI.client == nil { lowLevelAPI.client = fleethttp.NewClient() } + lowLevelAPI.serverURL = serverURL return nil } @@ -232,3 +235,11 @@ func (lowLevelAPI *GoogleCalendarLoadAPI) DeleteEvent(id string) error { } return nil } + +func (lowLevelAPI *GoogleCalendarLoadAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { + return "resourceID", nil +} + +func (lowLevelAPI *GoogleCalendarLoadAPI) Stop(channelID string, resourceID string) error { + return nil +} diff --git a/ee/server/calendar/google_calendar_mock.go b/ee/server/calendar/google_calendar_mock.go index 1dd6f16bb4..cc6ab94c35 100644 --- a/ee/server/calendar/google_calendar_mock.go +++ b/ee/server/calendar/google_calendar_mock.go @@ -3,6 +3,7 @@ package calendar import ( "context" "errors" + "github.com/google/uuid" "net/http" "os" "strconv" @@ -18,16 +19,22 @@ type GoogleCalendarMockAPI struct { logger kitlog.Logger } +type channel struct { + channelID string + resourceID string +} + var ( - mockEvents = make(map[string]*calendar.Event) - mu sync.Mutex - id uint64 + mockEvents = make(map[string]*calendar.Event) + mockChannels = make([]channel, 0) + mu sync.Mutex + id uint64 ) -const latency = 500 * time.Millisecond +const latency = 200 * time.Millisecond // Configure creates a new Google Calendar service using the provided credentials. -func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error { +func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string, _ string) error { if lowLevelAPI.logger == nil { lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate) } @@ -84,6 +91,31 @@ func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error { return nil } +func (lowLevelAPI *GoogleCalendarMockAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { + time.Sleep(latency) + mu.Lock() + defer mu.Unlock() + resourceID = uuid.New().String() + mockChannels = append(mockChannels, channel{ + channelID: channelID, + resourceID: resourceID, + }) + return resourceID, nil +} + +func (lowLevelAPI *GoogleCalendarMockAPI) Stop(channelID string, resourceID string) error { + time.Sleep(latency) + mu.Lock() + defer mu.Unlock() + for i, ch := range mockChannels { + if ch.channelID == channelID && ch.resourceID == resourceID { + mockChannels = append(mockChannels[:i], mockChannels[i+1:]...) + return nil + } + } + return errors.New("channel not found") +} + func ListGoogleMockEvents() map[string]*calendar.Event { return mockEvents } @@ -94,6 +126,16 @@ func ClearMockEvents() { mockEvents = make(map[string]*calendar.Event) } +func MockChannelsCount() int { + return len(mockChannels) +} + +func ClearMockChannels() { + mu.Lock() + defer mu.Unlock() + mockChannels = make([]channel, 0) +} + func SetMockEventsToNow() { mu.Lock() defer mu.Unlock() diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go index 8d35cba69e..848be3693b 100644 --- a/ee/server/calendar/google_calendar_test.go +++ b/ee/server/calendar/google_calendar_test.go @@ -18,6 +18,7 @@ const ( baseServiceEmail = "service@example.com" basePrivateKey = "private-key" baseUserEmail = "user@example.com" + baseServerURL = "https://example.com" ) var ( @@ -26,18 +27,28 @@ var ( ) type MockGoogleCalendarLowLevelAPI struct { - ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error + ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL 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 + WatchFunc func(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) + StopFunc func(channelID string, resourceID string) error +} + +func (m *MockGoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { + return m.WatchFunc(eventUUID, channelID, ttl) +} + +func (m *MockGoogleCalendarLowLevelAPI) Stop(channelID string, resourceID string) error { + return m.StopFunc(channelID, resourceID) } func (m *MockGoogleCalendarLowLevelAPI) Configure( - ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string, + ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string, ) error { - return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail) + return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL) } func (m *MockGoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) { @@ -63,11 +74,12 @@ func (m *MockGoogleCalendarLowLevelAPI) DeleteEvent(id string) error { func TestGoogleCalendar_Configure(t *testing.T) { t.Parallel() mockAPI := &MockGoogleCalendarLowLevelAPI{} - mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error { assert.Equal(t, baseCtx, ctx) assert.Equal(t, baseServiceEmail, serviceAccountEmail) assert.Equal(t, basePrivateKey, privateKey) assert.Equal(t, baseUserEmail, userToImpersonateEmail) + assert.Equal(t, baseServerURL, serverURL) return nil } @@ -77,7 +89,7 @@ func TestGoogleCalendar_Configure(t *testing.T) { assert.NoError(t, err) // Configure error test - mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error { return assert.AnError } err = cal.Configure(baseUserEmail) @@ -94,10 +106,11 @@ func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) { ) email := "user+my_test+email@example.com" mockAPI := &MockGoogleCalendarLowLevelAPI{} - mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error { assert.Equal(t, baseCtx, ctx) assert.Equal(t, baseServiceEmail, serviceAccountEmail) assert.Equal(t, basePrivateKey, privateKey) + assert.Equal(t, baseServerURL, serverURL) assert.Equal(t, "user@example.com", userToImpersonateEmail) return nil } @@ -109,7 +122,7 @@ func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) { func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig { if mockAPI != nil && mockAPI.ConfigureFunc == nil { - mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error { + mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail, serverURL string) error { return nil } } @@ -121,8 +134,9 @@ func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig { fleet.GoogleCalendarPrivateKey: basePrivateKey, }, }, - Logger: logger, - API: mockAPI, + Logger: logger, + API: mockAPI, + ServerURL: baseServerURL, } return config } @@ -187,6 +201,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { mockAPI := &MockGoogleCalendarLowLevelAPI{} const baseETag = "event-eTag" const baseEventID = "event-id" + const baseResourceID = "resource-id" mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { assert.Equal(t, baseEventID, id) assert.Equal(t, baseETag, eTag) @@ -194,9 +209,9 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { Etag: baseETag, // ETag matches -- no modifications to event }, nil } - genBodyFn := func(bool) string { + genBodyFn := func(bool) (string, bool, error) { t.Error("genBodyFn should not be called") - return "event-body" + return "event-body", false, nil } var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) err := cal.Configure(baseUserEmail) @@ -332,14 +347,29 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { return &calendar.Events{}, nil } - genBodyFn = func(conflict bool) string { + mockAPI.StopFunc = func(channelID string, resourceID string) error { + details, err := gCal.unmarshalDetails(event) + require.NoError(t, err) + assert.Equal(t, details.ChannelID, channelID) + assert.Equal(t, details.ResourceID, resourceID) + return nil + } + var uuid, channelUUID string + mockAPI.WatchFunc = func(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { + uuid = eventUUID + channelUUID = channelID + assert.Greater(t, ttl, uint64(60*30-1)) + return baseResourceID, nil + } + genBodyFn = func(conflict bool) (string, bool, error) { assert.False(t, conflict) - return "event-body" + return "event-body", true, nil } eventCreated := false mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { assert.Equal(t, eventTitle, event.Summary) - assert.Equal(t, genBodyFn(false), event.Description) + body, _, _ := genBodyFn(false) + assert.Equal(t, body, event.Description) event.Id = baseEventID event.Etag = baseETag eventCreated = true @@ -350,12 +380,17 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { assert.True(t, updated) assert.NotEqual(t, event, retrievedEvent) require.NotNil(t, retrievedEvent) + assert.Equal(t, uuid, retrievedEvent.UUID) 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) + details, err = gCal.unmarshalDetails(retrievedEvent) + require.NoError(t, err) + assert.Equal(t, channelUUID, details.ChannelID) + assert.Equal(t, baseResourceID, details.ResourceID) // cancelled (deleted) mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { @@ -426,6 +461,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { const baseEventID = "event-id" const baseETag = "event-eTag" const eventBody = "event-body" + const baseResourceID = "resource-id" var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI)) err := cal.Configure(baseUserEmail) assert.NoError(t, err) @@ -444,13 +480,13 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { event.Etag = baseETag return event, nil } - genBodyFn := func(conflict bool) string { + genBodyFn := func(conflict bool) (string, bool, error) { assert.False(t, conflict) - return eventBody + return eventBody, true, nil } - genBodyConflictFn := func(conflict bool) string { + genBodyConflictFn := func(conflict bool) (string, bool, error) { assert.True(t, conflict) - return eventBody + return eventBody, true, nil } // Happy path test -- empty calendar @@ -458,8 +494,16 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { location, _ := time.LoadLocation(tzId) expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location) _, expectedOffset := expectedStartTime.Zone() + var uuid, channelUUID string + mockAPI.WatchFunc = func(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { + uuid = eventUUID + channelUUID = channelID + assert.Greater(t, ttl, uint64(60*30-1)) + return baseResourceID, nil + } event, err := cal.CreateEvent(date, genBodyFn) require.NoError(t, err) + assert.Equal(t, uuid, event.UUID) assert.Equal(t, baseUserEmail, event.Email) assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) @@ -472,6 +516,9 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { require.NoError(t, err) assert.Equal(t, baseETag, details.ETag) assert.Equal(t, baseEventID, details.ID) + assert.Equal(t, channelUUID, details.ChannelID) + assert.Equal(t, baseResourceID, details.ResourceID) + assert.Equal(t, tzId, event.TimeZone) // Workday already ended date = time.Now().Add(-48 * time.Hour) diff --git a/ee/server/service/calendar.go b/ee/server/service/calendar.go new file mode 100644 index 0000000000..eee0e84fa4 --- /dev/null +++ b/ee/server/service/calendar.go @@ -0,0 +1,133 @@ +package service + +import ( + "context" + "fmt" + "github.com/fleetdm/fleet/v4/server/authz" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/service/calendar" + "github.com/go-kit/log/level" + "sync" +) + +func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error { + + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return fmt.Errorf("load app config: %w", err) + } + + if len(appConfig.Integrations.GoogleCalendar) == 0 { + svc.authz.SkipAuthorization(ctx) + level.Warn(svc.logger).Log("msg", "Received calendar callback, but Google Calendar integration is not configured") + return nil + } + googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0] + + if resourceState == "sync" { + // This is a sync notification, not a real event + svc.authz.SkipAuthorization(ctx) + return nil + } + + eventDetails, err := svc.ds.GetCalendarEventDetailsByUUID(ctx, eventUUID) + if err != nil { + svc.authz.SkipAuthorization(ctx) + if fleet.IsNotFound(err) { + // We could try to stop the channel callbacks here, but that may not be secure since we don't know if the request is legitimate + level.Warn(svc.logger).Log("msg", "Received calendar callback, but did not find corresponding event in database", "event_uuid", + eventUUID, "channel_id", channelID) + return err + } + return err + } + if eventDetails.TeamID == nil { + // Should not happen + return fmt.Errorf("calendar event %s has no team ID", eventUUID) + } + + localConfig := &calendar.CalendarConfig{ + GoogleCalendarIntegration: *googleCalendarIntegrationConfig, + ServerURL: appConfig.ServerSettings.ServerURL, + } + userCalendar := calendar.CreateUserCalendarFromConfig(ctx, localConfig, svc.logger) + + // Authenticate request. We will use the channel ID for authentication. + svc.authz.SkipAuthorization(ctx) + savedChannelID, err := userCalendar.Get(&eventDetails.CalendarEvent, "channelID") + if err != nil { + return ctxerr.Wrap(ctx, err, "get channel ID") + } + if savedChannelID != channelID { + return authz.ForbiddenWithInternal(fmt.Sprintf("calendar channel ID mismatch: %s != %s", savedChannelID, channelID), nil, nil, nil) + } + + genBodyFn := func(conflict bool) (body string, ok bool, err error) { + + // This function is called when a new event is being created. + var team *fleet.Team + team, err = svc.ds.TeamWithoutExtras(ctx, *eventDetails.TeamID) + if err != nil { + return "", false, err + } + + if team.Config.Integrations.GoogleCalendar == nil || + !team.Config.Integrations.GoogleCalendar.Enable { + return "", false, nil + } + + var policies []fleet.PolicyCalendarData + policies, err = svc.ds.GetCalendarPolicies(ctx, team.ID) + if err != nil { + return "", false, err + } + + if len(policies) == 0 { + return "", false, nil + } + + policyIDs := make([]uint, 0, len(policies)) + for _, policy := range policies { + policyIDs = append(policyIDs, policy.ID) + } + + var hosts []fleet.HostPolicyMembershipData + hosts, err = svc.ds.GetTeamHostsPolicyMemberships(ctx, googleCalendarIntegrationConfig.Domain, team.ID, policyIDs, + &eventDetails.HostID) + if err != nil { + return "", false, err + } + if len(hosts) != 1 { + return "", false, nil + } + host := hosts[0] + if host.Passing { // host is passing all configured policies + return "", false, nil + } + if host.Email == "" { + err = fmt.Errorf("host %d has no associated email", host.HostID) + return "", false, err + } + + return calendar.GenerateCalendarEventBody(ctx, svc.ds, team.Name, host, &sync.Map{}, conflict, svc.logger), true, nil + } + + err = userCalendar.Configure(eventDetails.Email) + if err != nil { + return ctxerr.Wrap(ctx, err, "configure calendar") + } + event, updated, err := userCalendar.GetAndUpdateEvent(&eventDetails.CalendarEvent, genBodyFn) + if err != nil { + return ctxerr.Wrap(ctx, err, "get and update event") + } + if updated && event != nil { + // Event was updated, so we need to save it + _, err = svc.ds.CreateOrUpdateCalendarEvent(ctx, event.UUID, event.Email, event.StartTime, event.EndTime, event.Data, + event.TimeZone, eventDetails.ID, fleet.CalendarWebhookStatusNone) + if err != nil { + return ctxerr.Wrap(ctx, err, "create or update calendar event") + } + } + return nil +} diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go index 874de3dfbc..c1aaab2217 100644 --- a/server/cron/calendar_cron.go +++ b/server/cron/calendar_cron.go @@ -5,15 +5,13 @@ import ( "errors" "fmt" "slices" - "strconv" - "strings" "sync" "time" - "github.com/fleetdm/fleet/v4/ee/server/calendar" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/service/calendar" "github.com/fleetdm/fleet/v4/server/service/schedule" "github.com/go-kit/log" kitlog "github.com/go-kit/log" @@ -21,16 +19,10 @@ import ( ) const ( - calendarConsumers = 18 - defaultDescription = "needs to make sure your device meets the organization's requirements." - defaultResolution = "During this maintenance window, you can expect updates to be applied automatically. Your device may be unavailable during this time." + calendarConsumers = 18 + reloadFrequency = 12 * time.Hour ) -type calendarConfig struct { - config.CalendarConfig - fleet.GoogleCalendarIntegration -} - func NewCalendarSchedule( ctx context.Context, instanceID string, @@ -86,9 +78,10 @@ func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, serverConfig co return fmt.Errorf("list teams: %w", err) } - localConfig := calendarConfig{ + localConfig := &calendar.CalendarConfig{ CalendarConfig: serverConfig, GoogleCalendarIntegration: *googleCalendarIntegrationConfig, + ServerURL: appConfig.ServerSettings.ServerURL, } for _, team := range teams { if err := cronCalendarEventsForTeam( @@ -101,19 +94,10 @@ func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, serverConfig co 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 calendarConfig, + calendarConfig *calendar.CalendarConfig, team fleet.Team, orgName string, domain string, @@ -151,7 +135,7 @@ func cronCalendarEventsForTeam( for _, policy := range policies { policyIDs = append(policyIDs, policy.ID) } - hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs) + hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs, nil) if err != nil { return fmt.Errorf("get team hosts failing policies: %w", err) } @@ -188,7 +172,7 @@ func cronCalendarEventsForTeam( // 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.GoogleCalendarIntegration, passingHosts, logger) + removeCalendarEventsFromPassingHosts(ctx, ds, calendarConfig, passingHosts, logger) level.Debug(logger).Log( "msg", "passing_hosts", "took", time.Since(start), ) @@ -213,7 +197,7 @@ func cronCalendarEventsForTeam( func processCalendarFailingHosts( ctx context.Context, ds fleet.Datastore, - calendarConfig calendarConfig, + calendarConfig *calendar.CalendarConfig, orgName string, hosts []fleet.HostPolicyMembershipData, logger kitlog.Logger, @@ -260,7 +244,7 @@ func processCalendarFailingHosts( } } - userCalendar := createUserCalendarFromConfig(ctx, &calendarConfig.GoogleCalendarIntegration, logger) + userCalendar := calendar.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 @@ -319,13 +303,13 @@ func filterHostsWithSameEmail(hosts []fleet.HostPolicyMembershipData) []fleet.Ho func processFailingHostExistingCalendarEvent( ctx context.Context, ds fleet.Datastore, - calendar fleet.UserCalendar, + userCalendar fleet.UserCalendar, orgName string, hostCalendarEvent *fleet.HostCalendarEvent, calendarEvent *fleet.CalendarEvent, host fleet.HostPolicyMembershipData, policyIDtoPolicy *sync.Map, - calendarConfig calendarConfig, + calendarConfig *calendar.CalendarConfig, logger kitlog.Logger, ) error { updatedEvent := calendarEvent @@ -334,9 +318,9 @@ func processFailingHostExistingCalendarEvent( if calendarConfig.AlwaysReloadEvent() || shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) { var err error - updatedEvent, _, err = calendar.GetAndUpdateEvent( - calendarEvent, func(conflict bool) string { - return generateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger) + updatedEvent, _, err = userCalendar.GetAndUpdateEvent( + calendarEvent, func(conflict bool) (string, bool, error) { + return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil }, ) if err != nil { @@ -350,6 +334,7 @@ func processFailingHostExistingCalendarEvent( if err := ds.UpdateCalendarEvent( ctx, calendarEvent.ID, + updatedEvent.UUID, updatedEvent.StartTime, updatedEvent.EndTime, updatedEvent.Data, @@ -395,13 +380,18 @@ func processFailingHostExistingCalendarEvent( if err := ds.UpdateHostRefetchRequested(ctx, host.HostID, true); err != nil { return fmt.Errorf("refetch host: %w", err) } + + // We no longer need to watch the event for changes + if err = userCalendar.StopEventChannel(calendarEvent); err != nil { + return fmt.Errorf("delete event channel: %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) + // Check the user calendar regularly (but not every cron run) // to reduce load on both Fleet and the calendar service. - if time.Since(calendarEvent.UpdatedAt) > 30*time.Minute { + if time.Since(calendarEvent.UpdatedAt) > reloadFrequency { return true } // If the event is supposed to be happening now, we want to check if the user moved/deleted the @@ -436,7 +426,8 @@ func processFailingHostCreateCalendarEvent( return fmt.Errorf("create event on user calendar: %w", err) } if _, err := ds.CreateOrUpdateCalendarEvent( - ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, calendarEvent.TimeZone, host.HostID, fleet.CalendarWebhookStatusNone, + ctx, calendarEvent.UUID, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, calendarEvent.TimeZone, + host.HostID, fleet.CalendarWebhookStatusNone, ); err != nil { return fmt.Errorf("create calendar event on db: %w", err) } @@ -456,8 +447,8 @@ func attemptCreatingEventOnUserCalendar( preferredDate := getPreferredCalendarEventDate(year, month, today) for { calendarEvent, err := userCalendar.CreateEvent( - preferredDate, func(conflict bool) string { - return generateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger) + preferredDate, func(conflict bool) (string, bool, error) { + return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil }, ) var dee fleet.DayEndedError @@ -501,7 +492,7 @@ func addBusinessDay(date time.Time) time.Time { func removeCalendarEventsFromPassingHosts( ctx context.Context, ds fleet.Datastore, - calendarConfig *fleet.GoogleCalendarIntegration, + calendarConfig *calendar.CalendarConfig, hosts []fleet.HostPolicyMembershipData, logger kitlog.Logger, ) { @@ -546,7 +537,7 @@ func removeCalendarEventsFromPassingHosts( level.Error(logger).Log("msg", "get calendar event from DB", "err", err) continue } - userCalendar := createUserCalendarFromConfig(ctx, calendarConfig, logger) + userCalendar := calendar.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 @@ -582,74 +573,6 @@ func logHostsWithoutAssociatedEmail( ) } -func generateCalendarEventBody( - ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData, policyIDtoPolicy *sync.Map, conflict bool, - logger kitlog.Logger, -) string { - description, resolution := getCalendarEventDescriptionAndResolution(ctx, ds, orgName, host, policyIDtoPolicy, logger) - - conflictStr := "" - if conflict { - conflictStr = " because there was no remaining availability" - } - return fmt.Sprintf( - `%s reserved this time to make some changes to your work computer%s. - -Please leave your device on and connected to power. - -Why it matters -%s - -What we'll do -%s -`, - orgName, conflictStr, description, resolution, - ) -} - -func getCalendarEventDescriptionAndResolution( - ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData, policyIDtoPolicy *sync.Map, - logger kitlog.Logger, -) (string, string) { - getDefaultDescription := func() string { - return fmt.Sprintf(`%s %s`, orgName, defaultDescription) - } - - var description, resolution string - policyIDs := strings.Split(host.FailingPolicyIDs, ",") - if len(policyIDs) == 1 && policyIDs[0] != "" { - var policy *fleet.PolicyLite - policyAny, ok := policyIDtoPolicy.Load(policyIDs[0]) - if !ok { - id, err := strconv.ParseUint(policyIDs[0], 10, 64) - if err != nil { - level.Error(logger).Log("msg", "parse policy id", "err", err) - return getDefaultDescription(), defaultResolution - } - policy, err = ds.PolicyLite(ctx, uint(id)) - if err != nil { - level.Error(logger).Log("msg", "get policy", "err", err) - return getDefaultDescription(), defaultResolution - } - policyIDtoPolicy.Store(policyIDs[0], policy) - } else { - policy = policyAny.(*fleet.PolicyLite) - } - policyDescription := strings.TrimSpace(policy.Description) - if policyDescription == "" || policy.Resolution == nil || strings.TrimSpace(*policy.Resolution) == "" { - description = getDefaultDescription() - resolution = defaultResolution - } else { - description = policyDescription - resolution = strings.TrimSpace(*policy.Resolution) - } - } else { - description = getDefaultDescription() - resolution = defaultResolution - } - return description, resolution -} - func isHostOnline(ctx context.Context, ds fleet.Datastore, hostID uint) (bool, error) { hostLite, err := ds.HostLiteByID(ctx, hostID) if err != nil { @@ -678,10 +601,13 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k } var userCalendar fleet.UserCalendar - var calendarConfig *fleet.GoogleCalendarIntegration + var calConfig *calendar.CalendarConfig if len(appConfig.Integrations.GoogleCalendar) > 0 { - calendarConfig = appConfig.Integrations.GoogleCalendar[0] - userCalendar = createUserCalendarFromConfig(ctx, calendarConfig, logger) + calConfig = &calendar.CalendarConfig{ + GoogleCalendarIntegration: *appConfig.Integrations.GoogleCalendar[0], + ServerURL: appConfig.ServerSettings.ServerURL, + } + userCalendar = calendar.CreateUserCalendarFromConfig(ctx, calConfig, logger) } // If global setting is disabled, we remove all calendar events from the DB @@ -710,7 +636,7 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k } for _, team := range teams { - if err := cleanupTeamCalendarEvents(ctx, ds, calendarConfig, *team, logger); err != nil { + if err := cleanupTeamCalendarEvents(ctx, ds, calConfig, *team, logger); err != nil { level.Info(logger).Log("msg", "delete team calendar events", "team_id", team.ID, "err", err) } } @@ -724,14 +650,14 @@ func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger k if err != nil { return fmt.Errorf("list out of date calendar events: %w", err) } - deleteCalendarEventsInParallel(ctx, ds, calendarConfig, outOfDateCalendarEvents, logger) + deleteCalendarEventsInParallel(ctx, ds, calConfig, outOfDateCalendarEvents, logger) return nil } func deleteAllCalendarEvents( ctx context.Context, ds fleet.Datastore, - calendarConfig *fleet.GoogleCalendarIntegration, + calendarConfig *calendar.CalendarConfig, teamID *uint, logger kitlog.Logger, ) error { @@ -744,7 +670,7 @@ func deleteAllCalendarEvents( } func deleteCalendarEventsInParallel( - ctx context.Context, ds fleet.Datastore, calendarConfig *fleet.GoogleCalendarIntegration, calendarEvents []*fleet.CalendarEvent, + ctx context.Context, ds fleet.Datastore, calendarConfig *calendar.CalendarConfig, calendarEvents []*fleet.CalendarEvent, logger kitlog.Logger, ) { if len(calendarEvents) > 0 { @@ -757,7 +683,7 @@ func deleteCalendarEventsInParallel( for calEvent := range calendarEventCh { var userCalendar fleet.UserCalendar if calendarConfig != nil { - userCalendar = createUserCalendarFromConfig(ctx, calendarConfig, logger) + userCalendar = calendar.CreateUserCalendarFromConfig(ctx, calendarConfig, logger) } if err := deleteCalendarEvent(ctx, ds, userCalendar, calEvent); err != nil { level.Error(logger).Log("msg", "delete user calendar event", "err", err) @@ -777,7 +703,7 @@ func deleteCalendarEventsInParallel( func cleanupTeamCalendarEvents( ctx context.Context, ds fleet.Datastore, - calendarConfig *fleet.GoogleCalendarIntegration, + calendarConfig *calendar.CalendarConfig, team fleet.Team, logger kitlog.Logger, ) error { diff --git a/server/cron/calendar_cron_test.go b/server/cron/calendar_cron_test.go index e03d633569..85c4761e84 100644 --- a/server/cron/calendar_cron_test.go +++ b/server/cron/calendar_cron_test.go @@ -167,7 +167,7 @@ func TestEventForDifferentHost(t *testing.T) { hostID2 := uint(101) userEmail1 := "user@example.com" ds.GetTeamHostsPolicyMembershipsFunc = func( - ctx context.Context, domain string, teamID uint, policyIDs []uint, + ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint, ) ([]fleet.HostPolicyMembershipData, error) { require.Equal(t, teamID1, teamID) require.Equal(t, []uint{policyID1}, policyIDs) @@ -209,6 +209,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) { logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout)) t.Cleanup(func() { calendar.ClearMockEvents() + calendar.ClearMockChannels() }) // @@ -279,7 +280,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) { hostID4 := uint(103) ds.GetTeamHostsPolicyMembershipsFunc = func( - ctx context.Context, domain string, teamID uint, policyIDs []uint, + ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint, ) ([]fleet.HostPolicyMembershipData, error) { require.Equal(t, "example.com", domain) require.Equal(t, teamID1, teamID) @@ -336,6 +337,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) { hostCalendarEvents := make(map[uint]*fleet.HostCalendarEvent) ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context, + uuid string, email string, startTime, endTime time.Time, data []byte, @@ -343,6 +345,7 @@ func TestCalendarEventsMultipleHosts(t *testing.T) { hostID uint, webhookStatus fleet.CalendarWebhookStatus, ) (*fleet.CalendarEvent, error) { + assert.NotEmpty(t, uuid) require.Equal(t, hostID1, hostID) require.Equal(t, userEmail1, email) require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus) @@ -380,8 +383,8 @@ func TestCalendarEventsMultipleHosts(t *testing.T) { createdCalendarEvents := calendar.ListGoogleMockEvents() require.Len(t, createdCalendarEvents, 1) - strings.Contains(createdCalendarEvents["1"].Description, defaultDescription) - strings.Contains(createdCalendarEvents["1"].Description, defaultResolution) + strings.Contains(createdCalendarEvents["1"].Description, fleet.CalendarDefaultDescription) + strings.Contains(createdCalendarEvents["1"].Description, fleet.CalendarDefaultResolution) } type notFoundErr struct{} @@ -405,6 +408,7 @@ func TestCalendarEvents1KHosts(t *testing.T) { } t.Cleanup(func() { calendar.ClearMockEvents() + calendar.ClearMockChannels() }) ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { @@ -594,7 +598,7 @@ func TestCalendarEvents1KHosts(t *testing.T) { } ds.GetTeamHostsPolicyMembershipsFunc = func( - ctx context.Context, domain string, teamID uint, policyIDs []uint, + ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint, ) ([]fleet.HostPolicyMembershipData, error) { var start, end int switch teamID { @@ -622,6 +626,7 @@ func TestCalendarEvents1KHosts(t *testing.T) { eventPerHost := make(map[uint]*fleet.CalendarEvent) ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context, + uuid string, email string, startTime, endTime time.Time, data []byte, @@ -629,6 +634,7 @@ func TestCalendarEvents1KHosts(t *testing.T) { hostID uint, webhookStatus fleet.CalendarWebhookStatus, ) (*fleet.CalendarEvent, error) { + assert.NotEmpty(t, uuid) require.Equal(t, fmt.Sprintf("user%d@example.com", hostID), email) eventsCreatedMu.Lock() eventsCreated += 1 @@ -708,6 +714,7 @@ func TestEventDescription(t *testing.T) { t.Cleanup( func() { calendar.ClearMockEvents() + calendar.ClearMockChannels() }, ) @@ -807,7 +814,7 @@ func TestEventDescription(t *testing.T) { hostID7, userEmail7 := uint(106), "user7@example.com" ds.GetTeamHostsPolicyMembershipsFunc = func( - ctx context.Context, domain string, teamID uint, policyIDs []uint, + ctx context.Context, domain string, teamID uint, policyIDs []uint, _ *uint, ) ([]fleet.HostPolicyMembershipData, error) { require.Equal(t, "example.com", domain) require.Equal(t, teamID1, teamID) @@ -900,6 +907,7 @@ func TestEventDescription(t *testing.T) { ds.CreateOrUpdateCalendarEventFunc = func( ctx context.Context, + uuid string, email string, startTime, endTime time.Time, data []byte, @@ -907,6 +915,7 @@ func TestEventDescription(t *testing.T) { hostID uint, webhookStatus fleet.CalendarWebhookStatus, ) (*fleet.CalendarEvent, error) { + assert.NotEmpty(t, uuid) require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus) require.NotEmpty(t, data) require.NotZero(t, startTime) @@ -948,14 +957,14 @@ func TestEventDescription(t *testing.T) { err = json.Unmarshal(calendarEvents[hostCalEvent.HostID].Data, &details) require.NoError(t, err) description := createdCalendarEvents[details["id"]].Description - defaultDescriptionWithOrg := fmt.Sprintf("%s %s", orgName, defaultDescription) + defaultDescriptionWithOrg := fmt.Sprintf("%s %s", orgName, fleet.CalendarDefaultDescription) switch hostCalEvent.HostID { case hostID1, hostID6: assert.Contains(t, description, "Description for policy 1") assert.Contains(t, description, "Resolution for policy 1") default: assert.Contains(t, description, defaultDescriptionWithOrg) - assert.Contains(t, description, defaultResolution) + assert.Contains(t, description, fleet.CalendarDefaultResolution) } } } diff --git a/server/datastore/mysql/calendar_events.go b/server/datastore/mysql/calendar_events.go index 29fb263ada..fa58b8f3af 100644 --- a/server/datastore/mysql/calendar_events.go +++ b/server/datastore/mysql/calendar_events.go @@ -3,6 +3,7 @@ package mysql import ( "context" "database/sql" + "errors" "fmt" "time" @@ -13,6 +14,7 @@ import ( func (ds *Datastore) CreateOrUpdateCalendarEvent( ctx context.Context, + uuid string, email string, startTime time.Time, endTime time.Time, @@ -25,13 +27,15 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent( if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { const calendarEventsQuery = ` INSERT INTO calendar_events ( + uuid, email, start_time, end_time, event, timezone - ) VALUES (?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE + uuid = VALUES(uuid), start_time = VALUES(start_time), end_time = VALUES(end_time), event = VALUES(event), @@ -41,6 +45,7 @@ func (ds *Datastore) CreateOrUpdateCalendarEvent( result, err := tx.ExecContext( ctx, calendarEventsQuery, + uuid, email, startTime, endTime, @@ -122,9 +127,29 @@ func (ds *Datastore) GetCalendarEvent(ctx context.Context, email string) (*fleet return &calendarEvent, nil } -func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error { +func (ds *Datastore) GetCalendarEventDetailsByUUID(ctx context.Context, uuid string) (*fleet.CalendarEventDetails, error) { + const calendarEventsByUUIDQuery = ` + SELECT ce.*, h.team_id as team_id, h.id as host_id FROM calendar_events ce + LEFT JOIN host_calendar_events hce ON hce.calendar_event_id = ce.id + LEFT JOIN hosts h ON h.id = hce.host_id + WHERE ce.uuid = ?; + ` + var calendarEvent fleet.CalendarEventDetails + err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsByUUIDQuery, uuid) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("uuid: %s", uuid))) + } + return nil, ctxerr.Wrap(ctx, err, "get calendar event") + } + return &calendarEvent, nil +} + +func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, + data []byte, timeZone string) error { const calendarEventsQuery = ` UPDATE calendar_events SET + uuid = ?, start_time = ?, end_time = ?, event = ?, @@ -132,7 +157,8 @@ func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID ui updated_at = CURRENT_TIMESTAMP WHERE id = ?; ` - if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, timeZone, calendarEventID); err != nil { + if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, uuid, startTime, endTime, data, timeZone, + calendarEventID); err != nil { return ctxerr.Wrap(ctx, err, "update calendar event") } return nil diff --git a/server/datastore/mysql/calendar_events_test.go b/server/datastore/mysql/calendar_events_test.go index 2b37d7f826..b4ec9d510a 100644 --- a/server/datastore/mysql/calendar_events_test.go +++ b/server/datastore/mysql/calendar_events_test.go @@ -2,6 +2,8 @@ package mysql import ( "context" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" "testing" "time" @@ -56,20 +58,32 @@ func testUpdateCalendarEvent(t *testing.T, ds *Datastore) { startTime1 := time.Now() endTime1 := startTime1.Add(30 * time.Minute) timeZone := "America/Argentina/Buenos_Aires" - calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone) + eventUUID := uuid.New().String() + calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, + host.ID, fleet.CalendarWebhookStatusNone) require.NoError(t, err) time.Sleep(1 * time.Second) - err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`), timeZone) + eventUUIDNew := uuid.New().String() + err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, eventUUIDNew, startTime1, endTime1, []byte(`{}`), timeZone) 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 + assert.NotEqual(t, calendarEvent.UUID, calendarEvent2.UUID) + calendarEvent.UUID = calendarEvent2.UUID require.Equal(t, *calendarEvent, *calendarEvent2) + eventDetails, err := ds.GetCalendarEventDetailsByUUID(ctx, eventUUIDNew) + require.NoError(t, err) + assert.Equal(t, eventUUIDNew, eventDetails.UUID) + assert.Equal(t, *calendarEvent, eventDetails.CalendarEvent) + assert.Equal(t, host.ID, eventDetails.HostID) + assert.Nil(t, eventDetails.TeamID) + // TODO(lucas): Add more tests here. } @@ -101,23 +115,30 @@ func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) { startTime1 := time.Now() endTime1 := startTime1.Add(30 * time.Minute) - calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone) + eventUUID := uuid.New().String() + calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, + host.ID, fleet.CalendarWebhookStatusNone) require.NoError(t, err) require.Equal(t, calendarEvent.TimeZone, timeZone) time.Sleep(1 * time.Second) - calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone) + eventUUID2 := uuid.New().String() + calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID2, "foo@example.com", startTime1, endTime1, []byte(`{}`), timeZone, + host.ID, fleet.CalendarWebhookStatusNone) require.NoError(t, err) require.Greater(t, calendarEvent2.UpdatedAt, calendarEvent.UpdatedAt) calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt + assert.NotEqual(t, calendarEvent.UUID, calendarEvent2.UUID) + calendarEvent.UUID = calendarEvent2.UUID 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"}`), timeZone, host.ID, fleet.CalendarWebhookStatusPending) + calendarEvent3, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID2, "foo@example.com", startTime2, endTime2, + []byte(`{"foo": "bar"}`), timeZone, host.ID, fleet.CalendarWebhookStatusPending) require.NoError(t, err) require.Greater(t, calendarEvent3.UpdatedAt, calendarEvent2.UpdatedAt) require.WithinDuration(t, startTime2, calendarEvent3.StartTime, 1*time.Second) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 02ce272c8e..8c552448f7 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -9520,7 +9520,8 @@ func testListUpcomingHostMaintenanceWindows(t *testing.T, ds *Datastore) { startTime := time.Now().UTC().Add(30 * time.Minute) endTime := startTime.Add(30 * time.Minute) - calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime, endTime, []byte(`{}`), timeZone, host.ID, fleet.CalendarWebhookStatusNone) + calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, uuid.New().String(), "foo@example.com", startTime, endTime, []byte(`{}`), + timeZone, host.ID, fleet.CalendarWebhookStatusNone) require.NoError(t, err) require.Equal(t, calendarEvent.TimeZone, timeZone) diff --git a/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go b/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go index 34b88fc724..0e289a8c7c 100644 --- a/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go +++ b/server/datastore/mysql/migrations/tables/20240626195531_AddTimezoneToCalendarEvents.go @@ -10,7 +10,7 @@ func init() { } func Up_20240626195531(tx *sql.Tx) error { - if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN timezone VARCHAR(64) NULL`); err != nil { + if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN timezone VARCHAR(64) COLLATE utf8mb4_unicode_ci NULL`); err != nil { return fmt.Errorf("failed to add `timezone` column to `calendar_events` table: %w", err) } return nil diff --git a/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go new file mode 100644 index 0000000000..11e58a0596 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents.go @@ -0,0 +1,34 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240707134035, Down_20240707134035) +} + +func Up_20240707134035(tx *sql.Tx) error { + // UUID is a 36-character string with the most common 8-4-4-4-12 format, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + // Reference: https://en.wikipedia.org/wiki/Universally_unique_identifier#Textual_representation + if _, err := tx.Exec(`ALTER TABLE calendar_events ADD COLUMN uuid VARCHAR(36) COLLATE utf8mb4_unicode_ci NOT NULL`); err != nil { + return fmt.Errorf("failed to add `uuid` column to `calendar_events` table: %w", err) + } + + // Generate UUIDs for existing calendar events, without changing the updated_at timestamp + if _, err := tx.Exec(`UPDATE calendar_events SET uuid = UUID(), updated_at = updated_at`); err != nil { + return fmt.Errorf("failed to generate UUIDs for existing calendar events: %w", err) + } + + // Add unique constraint to uuid column + if _, err := tx.Exec(`ALTER TABLE calendar_events ADD CONSTRAINT idx_calendar_events_uuid_unique UNIQUE (uuid)`); err != nil { + return fmt.Errorf("failed to add unique constraint to `uuid` column in `calendar_events` table: %w", err) + } + + return nil +} + +func Down_20240707134035(_ *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go new file mode 100644 index 0000000000..851997f251 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240707134035_AddUUIDToCalendarEvents_test.go @@ -0,0 +1,42 @@ +package tables + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUp_20240707134035(t *testing.T) { + db := applyUpToPrev(t) + + startTime := time.Now().UTC() + endTime := time.Now().UTC().Add(30 * time.Minute) + data := []byte("{\"foo\": \"bar\"}") + const insertStmt = `INSERT INTO calendar_events (email, start_time, end_time, event) VALUES (?, ?, ?, ?)` + event1ID := uint(execNoErrLastID(t, db, insertStmt, "foo@example.com", startTime, endTime, data)) + event2ID := uint(execNoErrLastID(t, db, insertStmt, "bar@example.com", startTime, endTime, data)) + + // Apply current migration. + applyNext(t, db) + + // check that it's NULL + const selectUUIDStmt = `SELECT uuid FROM calendar_events WHERE id = ?` + var uuid1, uuid2 string + err := db.Get(&uuid1, selectUUIDStmt, event1ID) + require.NoError(t, err) + assert.NotEmpty(t, uuid1) + err = db.Get(&uuid2, selectUUIDStmt, event2ID) + require.NoError(t, err) + assert.NotEmpty(t, uuid2) + assert.NotEqual(t, uuid1, uuid2) + + const testUUID = "test-uuid" + const insertStmtUUID = `INSERT INTO calendar_events (email, start_time, end_time, event, uuid) VALUES (?, ?, ?, ?, ?)` + _ = execNoErrLastID(t, db, insertStmtUUID, "bob@example.com", startTime, endTime, data, testUUID) + // Try to use the same uuid again + _, err = db.Exec(insertStmt, "alice@example.com", startTime, endTime, data, testUUID) + assert.Error(t, err) + +} diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 52dc8303b4..4df1f9324a 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -1434,6 +1434,7 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( domain string, teamID uint, policyIDs []uint, + hostID *uint, ) ([]fleet.HostPolicyMembershipData, error) { query := ` SELECT @@ -1459,13 +1460,17 @@ func (ds *Datastore) GetTeamHostsPolicyMemberships( ) sh ON h.id = sh.host_id LEFT JOIN host_display_names hdn ON h.id = hdn.host_id LEFT JOIN host_calendar_events hce ON h.id = hce.host_id - WHERE h.team_id = ? AND ((pm.passing IS NOT NULL AND NOT pm.passing) OR (COALESCE(pm.passing, 1) AND hce.host_id IS NOT NULL)); + WHERE h.team_id = ? AND ((pm.passing IS NOT NULL AND NOT pm.passing) OR (COALESCE(pm.passing, 1) AND hce.host_id IS NOT NULL)) ` query, args, err := sqlx.In(query, policyIDs, domain, teamID, teamID) if err != nil { return nil, ctxerr.Wrapf(ctx, err, "build select get team hosts policy memberships query") } + if hostID != nil { + query += ` AND h.id = ?` + args = append(args, *hostID) + } 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") diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index e5437e352c..f505ebc3bb 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -3485,7 +3485,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.NoError(t, err) // Empty teams. - hostsTeam1, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) + hostsTeam1, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}, nil) require.NoError(t, err) require.Empty(t, hostsTeam1) @@ -3538,12 +3538,12 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.NoError(t, err) // Some domain that doesn't exist on any of the hosts - hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "not-exists.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "not-exists.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}, nil) require.NoError(t, err) require.Empty(t, hostsTeam1) // No policy results yet (and no calendar events). - hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}) + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID}, nil) require.NoError(t, err) require.Empty(t, hostsTeam1) @@ -3633,7 +3633,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.Len(t, team2Policies, 2) // Only returns the failing host, because the passing hosts do not have a calendar event. - hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}, nil) require.NoError(t, err) sort.Slice(hostsTeam1, func(i, j int) bool { return hostsTeam1[i].HostID < hostsTeam1[j].HostID @@ -3650,12 +3650,16 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { // tZ := "America/Argentina/Buenos_Aires" now := time.Now() - _, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host1.ID, fleet.CalendarWebhookStatusPending) + eventUUID1 := "event-uuid" + _, err = ds.CreateOrUpdateCalendarEvent(ctx, eventUUID1, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, + host1.ID, fleet.CalendarWebhookStatusPending) require.NoError(t, err) - _, err = ds.CreateOrUpdateCalendarEvent(ctx, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host6.ID, fleet.CalendarWebhookStatusPending) + eventUUID2 := "event-uuid2" + _, err = ds.CreateOrUpdateCalendarEvent(ctx, eventUUID2, "bar@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, + host6.ID, fleet.CalendarWebhookStatusPending) require.NoError(t, err) - hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}, nil) require.NoError(t, err) sort.Slice(hostsTeam1, func(i, j int) bool { return hostsTeam1[i].HostID < hostsTeam1[j].HostID @@ -3689,7 +3693,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { }, time.Now(), false) require.NoError(t, err) - hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}) + hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam1, 4) sort.Slice(hostsTeam1, func(i, j int) bool { @@ -3720,7 +3724,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { // host3 doesn't have a calendar event so it's not returned by GetTeamHostsPolicyMemberships. // - hostsTeam2, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + hostsTeam2, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam2, 1) require.Equal(t, host2.ID, hostsTeam2[0].HostID) @@ -3733,16 +3737,19 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { // Create a calendar event on host2 and host3. // now = time.Now() - _, err = ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host2.ID, fleet.CalendarWebhookStatusPending) + _, err = ds.CreateOrUpdateCalendarEvent(ctx, eventUUID1, "foo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, + host2.ID, fleet.CalendarWebhookStatusPending) require.NoError(t, err) - calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, "zoo@example.com", now, now.Add(30*time.Minute), []byte(`{"foo": "bar"}`), tZ, host3.ID, fleet.CalendarWebhookStatusPending) + eventUUID3 := "event-uuid3" + calendarEventHost3, err := ds.CreateOrUpdateCalendarEvent(ctx, eventUUID3, "zoo@example.com", now, now.Add(30*time.Minute), + []byte(`{"foo": "bar"}`), tZ, host3.ID, fleet.CalendarWebhookStatusPending) require.NoError(t, err) // // Now it should return host3 because it's passing and has a calendar event. // - hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam2, 2) sort.Slice(hostsTeam2, func(i, j int) bool { @@ -3771,7 +3778,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { ) require.NoError(t, err) - hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam2, 2) sort.Slice( @@ -3800,7 +3807,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { }, time.Now(), false) require.NoError(t, err) - hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam2, 2) sort.Slice(hostsTeam2, func(i, j int) bool { @@ -3817,6 +3824,17 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { require.Equal(t, "serial3", hostsTeam2[1].HostHardwareSerial) require.Equal(t, "display_name3", hostsTeam2[1].HostDisplayName) + // Retrieve the data only for host2. + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, + &host2.ID) + require.NoError(t, err) + require.Len(t, hostsTeam2, 1) + require.Equal(t, host2.ID, hostsTeam2[0].HostID) + require.Equal(t, "foo@example.com", hostsTeam2[0].Email) + require.True(t, hostsTeam2[0].Passing) + require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial) + require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName) + // // Delete host3 calendar event // @@ -3824,7 +3842,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { err = ds.DeleteCalendarEvent(ctx, calendarEventHost3.ID) require.NoError(t, err) - hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam2, 1) require.Equal(t, host2.ID, hostsTeam2[0].HostID) @@ -3848,7 +3866,7 @@ func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) { // We should still get host2 as passing because it has an associated calendar event. // - hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}) + hostsTeam2, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID}, nil) require.NoError(t, err) require.Len(t, hostsTeam2, 1) require.Equal(t, host2.ID, hostsTeam2[0].HostID) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 2b5d5bd1b8..391b8ed54b 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -53,8 +53,10 @@ CREATE TABLE `calendar_events` ( `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `timezone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `uuid` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_one_calendar_event_per_email` (`email`) + UNIQUE KEY `idx_one_calendar_event_per_email` (`email`), + UNIQUE KEY `idx_calendar_events_uuid_unique` (`uuid`) ) 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 */; @@ -943,9 +945,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=278 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=279 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'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,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'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,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` ( diff --git a/server/datastore/mysql/teams.go b/server/datastore/mysql/teams.go index 5711835d24..c25b4a791c 100644 --- a/server/datastore/mysql/teams.go +++ b/server/datastore/mysql/teams.go @@ -56,10 +56,14 @@ func (ds *Datastore) NewTeam(ctx context.Context, team *fleet.Team) (*fleet.Team } func (ds *Datastore) Team(ctx context.Context, tid uint) (*fleet.Team, error) { - return teamDB(ctx, ds.reader(ctx), tid) + return teamDB(ctx, ds.reader(ctx), tid, true) } -func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint) (*fleet.Team, error) { +func (ds *Datastore) TeamWithoutExtras(ctx context.Context, tid uint) (*fleet.Team, error) { + return teamDB(ctx, ds.reader(ctx), tid, false) +} + +func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint, withExtras bool) (*fleet.Team, error) { stmt := ` SELECT ` + teamColumns + ` FROM teams WHERE id = ? @@ -73,18 +77,20 @@ func teamDB(ctx context.Context, q sqlx.QueryerContext, tid uint) (*fleet.Team, return nil, ctxerr.Wrap(ctx, err, "select team") } - if err := loadSecretsForTeamsDB(ctx, q, []*fleet.Team{team}); err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting secrets for teams") - } + if withExtras { + if err := loadSecretsForTeamsDB(ctx, q, []*fleet.Team{team}); err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting secrets for teams") + } - if err := loadUsersForTeamDB(ctx, q, team); err != nil { - return nil, err - } - if err := loadHostCountForTeamDB(ctx, q, team); err != nil { - return nil, err - } - if err := loadFeaturesForTeamDB(ctx, q, team); err != nil { - return nil, err + if err := loadUsersForTeamDB(ctx, q, team); err != nil { + return nil, err + } + if err := loadHostCountForTeamDB(ctx, q, team); err != nil { + return nil, err + } + if err := loadFeaturesForTeamDB(ctx, q, team); err != nil { + return nil, err + } } return team, nil diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go index 5eb4597f44..b9184f52eb 100644 --- a/server/fleet/calendar.go +++ b/server/fleet/calendar.go @@ -9,6 +9,11 @@ import ( "github.com/fleetdm/fleet/v4/server" ) +const ( + CalendarDefaultDescription = "needs to make sure your device meets the organization's requirements." + CalendarDefaultResolution = "During this maintenance window, you can expect updates to be applied automatically. Your device may be unavailable during this time." +) + type DayEndedError struct { Msg string } @@ -22,13 +27,18 @@ type UserCalendar interface { // 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) + CreateEvent(dateOfEvent time.Time, genBodyFn func(conflict bool) (body string, ok bool, err error)) (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) + GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) (body string, ok bool, err error)) (updatedEvent *CalendarEvent, + updated bool, err error) // DeleteEvent deletes the event with the given ID. DeleteEvent(event *CalendarEvent) error + // StopEventChannel stops the event's callback channel. + StopEventChannel(event *CalendarEvent) error + // Get retrieves the value of the given key from the event. + Get(event *CalendarEvent, key string) (interface{}, error) } type CalendarWebhookPayload struct { diff --git a/server/fleet/calendar_events.go b/server/fleet/calendar_events.go index 30cfcd11c6..96d5a9a93f 100644 --- a/server/fleet/calendar_events.go +++ b/server/fleet/calendar_events.go @@ -4,6 +4,7 @@ import "time" type CalendarEvent struct { ID uint `db:"id"` + UUID string `db:"uuid"` Email string `db:"email"` StartTime time.Time `db:"start_time"` EndTime time.Time `db:"end_time"` @@ -13,6 +14,12 @@ type CalendarEvent struct { UpdateCreateTimestamps } +type CalendarEventDetails struct { + CalendarEvent + TeamID *uint `db:"team_id"` // Should not be nil, but is nullable in the database + HostID uint `db:"host_id"` +} + type CalendarWebhookStatus int const ( diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index b973317e22..da5d4df785 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -490,7 +490,9 @@ type Datastore interface { SaveTeam(ctx context.Context, team *Team) (*Team, error) // Team retrieves the Team by ID. Team(ctx context.Context, tid uint) (*Team, error) - // Team deletes the Team by ID. + // TeamWithoutExtras retrieves the Team by ID without extra fields. + TeamWithoutExtras(ctx context.Context, tid uint) (*Team, error) + // DeleteTeam deletes the Team by ID. DeleteTeam(ctx context.Context, tid uint) error // TeamByName retrieves the Team by Name. TeamByName(ctx context.Context, name string) (*Team, error) @@ -656,12 +658,13 @@ type Datastore interface { PolicyQueriesForHost(ctx context.Context, host *Host) (map[string]string, error) - // GetTeamHostsPolicyMembmerships returns the hosts that belong to the given team and their pass/fail statuses + // GetTeamHostsPolicyMemberships returns the hosts that belong to the given team and their pass/fail statuses // around the provided policyIDs. // - Returns hosts of the team that are failing one or more of the provided policies. // - Returns hosts of the team that are passing all the policies (or are not running any of the provided policies) // and have a calendar event scheduled. - GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]HostPolicyMembershipData, error) + GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint, + hostID *uint) ([]HostPolicyMembershipData, error) GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error) // Methods used for async processing of host policy query results. @@ -686,10 +689,13 @@ type Datastore interface { /////////////////////////////////////////////////////////////////////////////// // Calendar events - CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error) + CreateOrUpdateCalendarEvent(ctx context.Context, uuid string, email string, startTime time.Time, endTime time.Time, data []byte, + timeZone string, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error) GetCalendarEvent(ctx context.Context, email string) (*CalendarEvent, error) + GetCalendarEventDetailsByUUID(ctx context.Context, uuid string) (*CalendarEventDetails, error) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error - UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error + UpdateCalendarEvent(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, data []byte, + timeZone string) 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 diff --git a/server/fleet/service.go b/server/fleet/service.go index 4ef1619f0f..ac1bbffb9c 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1058,4 +1058,10 @@ type Service interface { GetSoftwareInstallerMetadata(ctx context.Context, titleID uint, teamID *uint) (*SoftwareInstaller, error) DownloadSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) (*DownloadSoftwareInstallerPayload, error) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*DownloadSoftwareInstallerPayload, error) + + // ///////////////////////////////////////////////////////////////////////////// + // Maintenance windows + + // CalendarWebhook handles incoming calendar callback requests. + CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index b230cf2405..d38323714a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -361,6 +361,8 @@ type SaveTeamFunc func(ctx context.Context, team *fleet.Team) (*fleet.Team, erro type TeamFunc func(ctx context.Context, tid uint) (*fleet.Team, error) +type TeamWithoutExtrasFunc func(ctx context.Context, tid uint) (*fleet.Team, error) + type DeleteTeamFunc func(ctx context.Context, tid uint) error type TeamByNameFunc func(ctx context.Context, name string) (*fleet.Team, error) @@ -477,7 +479,7 @@ 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 GetTeamHostsPolicyMembershipsFunc func(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error) type GetCalendarPoliciesFunc func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) @@ -499,13 +501,15 @@ 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, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) +type CreateOrUpdateCalendarEventFunc func(ctx context.Context, uuid string, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) type GetCalendarEventFunc func(ctx context.Context, email string) (*fleet.CalendarEvent, error) +type GetCalendarEventDetailsByUUIDFunc func(ctx context.Context, uuid string) (*fleet.CalendarEventDetails, 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, timeZone string) error +type UpdateCalendarEventFunc func(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, data []byte, timeZone string) error type GetHostCalendarEventFunc func(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) @@ -1495,6 +1499,9 @@ type DataStore struct { TeamFunc TeamFunc TeamFuncInvoked bool + TeamWithoutExtrasFunc TeamWithoutExtrasFunc + TeamWithoutExtrasFuncInvoked bool + DeleteTeamFunc DeleteTeamFunc DeleteTeamFuncInvoked bool @@ -1708,6 +1715,9 @@ type DataStore struct { GetCalendarEventFunc GetCalendarEventFunc GetCalendarEventFuncInvoked bool + GetCalendarEventDetailsByUUIDFunc GetCalendarEventDetailsByUUIDFunc + GetCalendarEventDetailsByUUIDFuncInvoked bool + DeleteCalendarEventFunc DeleteCalendarEventFunc DeleteCalendarEventFuncInvoked bool @@ -3625,6 +3635,13 @@ func (s *DataStore) Team(ctx context.Context, tid uint) (*fleet.Team, error) { return s.TeamFunc(ctx, tid) } +func (s *DataStore) TeamWithoutExtras(ctx context.Context, tid uint) (*fleet.Team, error) { + s.mu.Lock() + s.TeamWithoutExtrasFuncInvoked = true + s.mu.Unlock() + return s.TeamWithoutExtrasFunc(ctx, tid) +} + func (s *DataStore) DeleteTeam(ctx context.Context, tid uint) error { s.mu.Lock() s.DeleteTeamFuncInvoked = true @@ -4031,11 +4048,11 @@ 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) { +func (s *DataStore) GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint, hostID *uint) ([]fleet.HostPolicyMembershipData, error) { s.mu.Lock() s.GetTeamHostsPolicyMembershipsFuncInvoked = true s.mu.Unlock() - return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs) + return s.GetTeamHostsPolicyMembershipsFunc(ctx, domain, teamID, policyIDs, hostID) } func (s *DataStore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) { @@ -4108,11 +4125,11 @@ 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, timeZone string, hostID uint, webhookStatus fleet.CalendarWebhookStatus) (*fleet.CalendarEvent, error) { +func (s *DataStore) CreateOrUpdateCalendarEvent(ctx context.Context, uuid string, email string, startTime time.Time, endTime time.Time, data []byte, timeZone string, 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, timeZone, hostID, webhookStatus) + return s.CreateOrUpdateCalendarEventFunc(ctx, uuid, email, startTime, endTime, data, timeZone, hostID, webhookStatus) } func (s *DataStore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) { @@ -4122,6 +4139,13 @@ func (s *DataStore) GetCalendarEvent(ctx context.Context, email string) (*fleet. return s.GetCalendarEventFunc(ctx, email) } +func (s *DataStore) GetCalendarEventDetailsByUUID(ctx context.Context, uuid string) (*fleet.CalendarEventDetails, error) { + s.mu.Lock() + s.GetCalendarEventDetailsByUUIDFuncInvoked = true + s.mu.Unlock() + return s.GetCalendarEventDetailsByUUIDFunc(ctx, uuid) +} + func (s *DataStore) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error { s.mu.Lock() s.DeleteCalendarEventFuncInvoked = true @@ -4129,11 +4153,11 @@ func (s *DataStore) DeleteCalendarEvent(ctx context.Context, calendarEventID uin return s.DeleteCalendarEventFunc(ctx, calendarEventID) } -func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte, timeZone string) error { +func (s *DataStore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, uuid string, startTime time.Time, endTime time.Time, data []byte, timeZone string) error { s.mu.Lock() s.UpdateCalendarEventFuncInvoked = true s.mu.Unlock() - return s.UpdateCalendarEventFunc(ctx, calendarEventID, startTime, endTime, data, timeZone) + return s.UpdateCalendarEventFunc(ctx, calendarEventID, uuid, startTime, endTime, data, timeZone) } func (s *DataStore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) { diff --git a/server/service/calendar.go b/server/service/calendar.go new file mode 100644 index 0000000000..6f7ff6b26e --- /dev/null +++ b/server/service/calendar.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "net/http" + "net/url" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/gorilla/mux" +) + +type calendarWebhookRequest struct { + eventUUID string + googleChannelID string + googleResourceState string +} + +// DecodeRequest implement requestDecoder interface to take full control of decoding the request +func (calendarWebhookRequest) DecodeRequest(_ context.Context, r *http.Request) (interface{}, error) { + var req calendarWebhookRequest + eventUUID, ok := mux.Vars(r)["event_uuid"] + if !ok { + return nil, errBadRoute + } + unescaped, err := url.PathUnescape(eventUUID) + if err != nil { + return "", ctxerr.Wrap(r.Context(), err, "unescape value in path") + } + req.eventUUID = unescaped + + req.googleChannelID = r.Header.Get("X-Goog-Channel-Id") + req.googleResourceState = r.Header.Get("X-Goog-Resource-State") + + return &req, nil +} + +type calendarWebhookResponse struct { + Err error `json:"error,omitempty"` +} + +func (r calendarWebhookResponse) error() error { return r.Err } + +func calendarWebhookEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*calendarWebhookRequest) + err := svc.CalendarWebhook(ctx, req.eventUUID, req.googleChannelID, req.googleResourceState) + if err != nil { + return calendarWebhookResponse{Err: err}, err + } + + resp := calendarWebhookResponse{} + return resp, nil +} + +func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, channelID string, resourceState string) error { + // skipauth: No authorization check needed due to implementation returning only license error. + svc.authz.SkipAuthorization(ctx) + return fleet.ErrMissingLicense +} diff --git a/server/service/calendar/calendar.go b/server/service/calendar/calendar.go new file mode 100644 index 0000000000..1e7eff60d0 --- /dev/null +++ b/server/service/calendar/calendar.go @@ -0,0 +1,94 @@ +package calendar + +// This package contains common calendar code used by cron and service packages. + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + + "github.com/fleetdm/fleet/v4/ee/server/calendar" + "github.com/fleetdm/fleet/v4/server/config" + "github.com/fleetdm/fleet/v4/server/fleet" + kitlog "github.com/go-kit/log" + "github.com/go-kit/log/level" +) + +type CalendarConfig struct { + config.CalendarConfig + fleet.GoogleCalendarIntegration + ServerURL string +} + +func CreateUserCalendarFromConfig(ctx context.Context, config *CalendarConfig, logger kitlog.Logger) fleet.UserCalendar { + googleCalendarConfig := calendar.GoogleCalendarConfig{ + Context: ctx, + IntegrationConfig: &config.GoogleCalendarIntegration, + ServerURL: config.ServerURL, + Logger: kitlog.With(logger, "component", "google_calendar"), + } + return calendar.NewGoogleCalendar(&googleCalendarConfig) +} + +func GenerateCalendarEventBody(ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData, + policyIDtoPolicy *sync.Map, conflict bool, logger kitlog.Logger) string { + description, resolution := getCalendarEventDescriptionAndResolution(ctx, ds, orgName, host, policyIDtoPolicy, logger) + + conflictStr := "" + if conflict { + conflictStr = " because there was no remaining availability" + } + return fmt.Sprintf(`%s reserved this time to make some changes to your work computer%s. + +Please leave your device on and connected to power. + +Why it matters +%s + +What we'll do +%s +`, orgName, conflictStr, description, resolution) +} + +func getCalendarEventDescriptionAndResolution(ctx context.Context, ds fleet.Datastore, orgName string, host fleet.HostPolicyMembershipData, + policyIDtoPolicy *sync.Map, logger kitlog.Logger) (string, string) { + getDefaultDescription := func() string { + return fmt.Sprintf(`%s %s`, orgName, fleet.CalendarDefaultDescription) + } + + var description, resolution string + policyIDs := strings.Split(host.FailingPolicyIDs, ",") + if len(policyIDs) == 1 && policyIDs[0] != "" { + var policy *fleet.PolicyLite + policyAny, ok := policyIDtoPolicy.Load(policyIDs[0]) + if !ok { + id, err := strconv.ParseUint(policyIDs[0], 10, 64) + if err != nil { + level.Error(logger).Log("msg", "parse policy id", "err", err) + return getDefaultDescription(), fleet.CalendarDefaultResolution + } + policy, err = ds.PolicyLite(ctx, uint(id)) + if err != nil { + level.Error(logger).Log("msg", "get policy", "err", err) + return getDefaultDescription(), fleet.CalendarDefaultResolution + } + policyIDtoPolicy.Store(policyIDs[0], policy) + } else { + policy = policyAny.(*fleet.PolicyLite) + } + policyDescription := strings.TrimSpace(policy.Description) + if policyDescription == "" || policy.Resolution == nil || strings.TrimSpace(*policy.Resolution) == "" { + description = getDefaultDescription() + resolution = fleet.CalendarDefaultResolution + } else { + description = policyDescription + resolution = strings.TrimSpace(*policy.Resolution) + } + } else { + description = getDefaultDescription() + resolution = fleet.CalendarDefaultResolution + } + return description, resolution +} diff --git a/server/service/handler.go b/server/service/handler.go index 2d7ad4ded3..c7892d7306 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -938,6 +938,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ne.HEAD("/api/fleet/orbit/ping", orbitPingEndpoint, orbitPingRequest{}) + // This is a callback endpoint for calendar integration -- it is called to notify an event change in a user calendar + ne.POST("/api/_version_/fleet/calendar/webhook/{event_uuid}", calendarWebhookEndpoint, calendarWebhookRequest{}) + neAppleMDM.WithCustomMiddleware(limiter.Limit("login", throttled.RateQuota{MaxRate: loginRateLimit, MaxBurst: 9})). POST("/api/_version_/fleet/mdm/sso", initiateMDMAppleSSOEndpoint, initiateMDMAppleSSORequest{}) diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index c6f29aea66..fd66f2227c 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -8293,9 +8293,11 @@ func (s *integrationTestSuite) TestGetHostMaintenanceWindow() { Data: []byte(`{}`), // will replace with NULL - db method doesn't allow nil TimeZone: "", + UUID: uuid.New().String(), } - dsEvent, err := s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, testEvent.TimeZone, host.ID, fleet.CalendarWebhookStatusNone) + dsEvent, err := s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.UUID, testEvent.Email, testEvent.StartTime, testEvent.EndTime, + testEvent.Data, testEvent.TimeZone, host.ID, fleet.CalendarWebhookStatusNone) require.NoError(t, err) time.Sleep(1 * time.Second) @@ -8325,7 +8327,8 @@ func (s *integrationTestSuite) TestGetHostMaintenanceWindow() { zonedStartsAt := startTime.In(tZLoc).Round(time.Second) // update the timezone - _, err = s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, timeZone, host.ID, fleet.CalendarWebhookStatusNone) + _, err = s.ds.CreateOrUpdateCalendarEvent(ctx, testEvent.UUID, testEvent.Email, testEvent.StartTime, testEvent.EndTime, testEvent.Data, + timeZone, host.ID, fleet.CalendarWebhookStatusNone) require.NoError(t, err) time.Sleep(1 * time.Second) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 39e632d5b5..f8fa76a59b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -8346,6 +8346,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { t := s.T() t.Cleanup(func() { calendar.ClearMockEvents() + calendar.ClearMockChannels() }) currentAppCfg, err := s.ds.AppConfig(ctx) require.NoError(t, err) @@ -8472,8 +8473,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host2Team1, map[uint]*bool{ - team2Policy1Calendar.ID: ptr.Bool(true), - team2Policy2.ID: ptr.Bool(false), + team1Policy1Calendar.ID: ptr.Bool(true), + team1Policy2.ID: ptr.Bool(false), globalPolicy.ID: nil, }, ), http.StatusOK, &distributedResp) @@ -8611,8 +8612,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host2Team1, map[uint]*bool{ - team2Policy1Calendar.ID: ptr.Bool(true), - team2Policy2.ID: ptr.Bool(false), + team1Policy1Calendar.ID: ptr.Bool(true), + team1Policy2.ID: ptr.Bool(false), globalPolicy.ID: nil, }, ), http.StatusOK, &distributedResp) @@ -8698,9 +8699,9 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { calendar.SetMockEventsToNow() mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error { - // Update updated_at so the event gets updated (the event is updated every 30 minutes) + // Update updated_at so the event gets updated (the event is updated regularly) _, err := db.ExecContext(ctx, - `UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR) WHERE id = ?`, team1CalendarEvents[0].ID) + `UPDATE calendar_events SET updated_at = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 25 HOUR) WHERE id = ?`, team1CalendarEvents[0].ID) if err != nil { return err } @@ -8735,8 +8736,8 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEvents() { s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host2Team1, map[uint]*bool{ - team2Policy1Calendar.ID: ptr.Bool(true), - team2Policy2.ID: ptr.Bool(false), + team1Policy1Calendar.ID: ptr.Bool(true), + team1Policy2.ID: ptr.Bool(false), globalPolicy.ID: nil, }, ), http.StatusOK, &distributedResp) @@ -8785,6 +8786,7 @@ func (s *integrationEnterpriseTestSuite) TestCalendarEventsTransferringHosts() { t := s.T() t.Cleanup(func() { calendar.ClearMockEvents() + calendar.ClearMockChannels() }) currentAppCfg, err := s.ds.AppConfig(ctx) require.NoError(t, err) @@ -10570,3 +10572,300 @@ func (s *integrationEnterpriseTestSuite) TestAutofillPoliciesAuthTeamUser() { ) } } + +func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { + ctx := context.Background() + t := s.T() + t.Cleanup(func() { + calendar.ClearMockEvents() + calendar.ClearMockChannels() + }) + currentAppCfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + t.Cleanup(func() { + err = s.ds.SaveAppConfig(ctx, currentAppCfg) + require.NoError(t, err) + }) + + team1, err := s.ds.NewTeam(ctx, &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + newHost := func(name string, teamID *uint) *fleet.Host { + h, err := s.ds.NewHost(ctx, &fleet.Host{ + DetailUpdatedAt: time.Now(), + LabelUpdatedAt: time.Now(), + PolicyUpdatedAt: time.Now(), + SeenTime: time.Now().Add(-1 * time.Minute), + OsqueryHostID: ptr.String(t.Name() + name), + NodeKey: ptr.String(t.Name() + name), + UUID: uuid.New().String(), + Hostname: fmt.Sprintf("%s.%s.local", name, t.Name()), + Platform: "darwin", + TeamID: teamID, + }) + require.NoError(t, err) + return h + } + + host1Team1 := newHost("host1", &team1.ID) + host2Team1 := newHost("host2", &team1.ID) + _ = newHost("host5", nil) // global host + + team1Policy1Calendar, err := s.ds.NewTeamPolicy( + ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "team1Policy1Calendar", + Query: "SELECT 1;", + CalendarEventsEnabled: true, + }, + ) + require.NoError(t, err) + team1Policy2, err := s.ds.NewTeamPolicy( + ctx, team1.ID, nil, fleet.PolicyPayload{ + Name: "team1Policy2", + Query: "SELECT 2;", + CalendarEventsEnabled: true, + }, + ) + require.NoError(t, err) + globalPolicy, err := s.ds.NewGlobalPolicy( + ctx, nil, fleet.PolicyPayload{ + Name: "globalPolicy", + Query: "SELECT 5;", + CalendarEventsEnabled: false, + }, + ) + require.NoError(t, err) + + genDistributedReqWithPolicyResults := func(host *fleet.Host, policyResults map[uint]*bool) submitDistributedQueryResultsRequestShim { + var ( + results = make(map[string]json.RawMessage) + statuses = make(map[string]interface{}) + messages = make(map[string]string) + ) + for policyID, policyResult := range policyResults { + distributedQueryName := hostPolicyQueryPrefix + fmt.Sprint(policyID) + switch { + case policyResult == nil: + results[distributedQueryName] = json.RawMessage(`[]`) + statuses[distributedQueryName] = 1 + messages[distributedQueryName] = "policy failed execution" + case *policyResult: + results[distributedQueryName] = json.RawMessage(`[{"1": "1"}]`) + statuses[distributedQueryName] = 0 + case !*policyResult: + results[distributedQueryName] = json.RawMessage(`[]`) + statuses[distributedQueryName] = 0 + } + } + return submitDistributedQueryResultsRequestShim{ + NodeKey: *host.NodeKey, + Results: results, + Statuses: statuses, + Messages: messages, + Stats: map[string]*fleet.Stats{}, + } + } + + // host1Team1 is failing a calendar policy and not a non-calendar policy (no results for global). + distributedResp := submitDistributedQueryResultsResponse{} + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + team1Policy1Calendar.ID: ptr.Bool(false), + team1Policy2.ID: ptr.Bool(true), + globalPolicy.ID: nil, + }, + ), http.StatusOK, &distributedResp) + + // host2Team1 is passing the calendar policy but not the non-calendar policy (no results for global). + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host2Team1, + map[uint]*bool{ + team1Policy1Calendar.ID: ptr.Bool(true), + team1Policy2.ID: ptr.Bool(false), + globalPolicy.ID: nil, + }, + ), http.StatusOK, &distributedResp) + + // Set global configuration for the calendar feature. + appCfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + appCfg.Integrations.GoogleCalendar = []*fleet.GoogleCalendarIntegration{ + { + Domain: "example.com", + ApiKey: map[string]string{ + fleet.GoogleCalendarEmail: calendar.MockEmail, + }, + }, + } + err = s.ds.SaveAppConfig(ctx, appCfg) + require.NoError(t, err) + time.Sleep(2 * time.Second) // Wait 2 seconds for the app config cache to clear. + + team1.Config.Integrations.GoogleCalendar = &fleet.TeamGoogleCalendarIntegration{ + Enable: true, + WebhookURL: "https://example.com", + } + team1, err = s.ds.SaveTeam(ctx, team1) + require.NoError(t, err) + + // Add email mapping for host1Team1 + const user1Email = "user1@example.com" + err = s.ds.ReplaceHostDeviceMapping(ctx, host1Team1.ID, []*fleet.HostDeviceMapping{ + { + HostID: host1Team1.ID, + Email: user1Email, + Source: "google_chrome_profiles", + }, + }, "google_chrome_profiles") + require.NoError(t, err) + assert.Equal(t, 0, calendar.MockChannelsCount()) + + // Trigger the calendar cron, global feature enabled, team1 enabled + // and host1Team1 has a domain email associated. + triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second) + + // An event should be generated for host1Team1 + team1CalendarEvents, err := s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + require.Len(t, team1CalendarEvents, 1) + event := team1CalendarEvents[0] + require.NotZero(t, event.ID) + require.Equal(t, user1Email, event.Email) + require.NotZero(t, event.StartTime) + require.NotZero(t, event.EndTime) + require.NotEmpty(t, event.UUID) + assert.Equal(t, 1, calendar.MockChannelsCount()) + + // Get channel ID + type eventDetails struct { + ChannelID string `json:"channel_id"` + } + var details eventDetails + err = json.Unmarshal(event.Data, &details) + require.NoError(t, err) + + // Send a sync command + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "sync", + }) + + // Send a regular callback with bad channel ID + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusForbidden, map[string]string{ + "X-Goog-Channel-Id": "bad", + "X-Goog-Resource-State": "exists", + }) + + // Send a regular callback + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + + // Delete the event on the calendar + calendar.ClearMockEvents() + + // This callback should recreate the event + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusOK, map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + + team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + require.Len(t, team1CalendarEvents, 1) + eventRecreated := team1CalendarEvents[0] + assert.NotZero(t, eventRecreated.ID) + assert.Equal(t, user1Email, eventRecreated.Email) + assert.NotZero(t, eventRecreated.StartTime) + assert.NotZero(t, eventRecreated.EndTime) + assert.NotEmpty(t, eventRecreated.UUID) + assert.NotEqual(t, event.UUID, eventRecreated.UUID) + assert.NotEqual(t, event.StartTime, eventRecreated.StartTime) + assert.NotEqual(t, event.EndTime, eventRecreated.EndTime) + assert.Equal(t, 1, calendar.MockChannelsCount()) + + // The previous event UUID should not work anymore + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+event.UUID, []byte(""), http.StatusNotFound, map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + + err = json.Unmarshal(eventRecreated.Data, &details) + require.NoError(t, err) + + // New event callback should work + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, + map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + + // Update the time of the event + events := calendar.ListGoogleMockEvents() + require.Len(t, events, 1) + for _, e := range events { + st, err := time.Parse(time.RFC3339, e.Start.DateTime) + require.NoError(t, err) + newStartTime := st.Add(5 * time.Minute).Format(time.RFC3339) + e.Start.DateTime = newStartTime + } + + // New event callback should cause the time to be updated in the DB + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, + map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + + // Check that the time was updated in the DB + team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + require.Len(t, team1CalendarEvents, 1) + eventUpdated := team1CalendarEvents[0] + assert.NotZero(t, eventUpdated.ID) + assert.Equal(t, user1Email, eventUpdated.Email) + assert.Equal(t, eventRecreated.UUID, eventUpdated.UUID) + assert.Greater(t, eventUpdated.StartTime, eventRecreated.StartTime) + assert.Equal(t, eventRecreated.EndTime, eventUpdated.EndTime) + assert.Equal(t, 1, calendar.MockChannelsCount()) + + // Delete the event on the calendar + calendar.ClearMockEvents() + + // Make host1Team1 pass all policies. + s.DoJSON("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host1Team1, + map[uint]*bool{ + team1Policy1Calendar.ID: ptr.Bool(true), + team1Policy2.ID: ptr.Bool(true), + globalPolicy.ID: nil, + }, + ), http.StatusOK, &distributedResp) + + // Callback should still work, but only clear the callback channel. Event in DB will be deleted on the next cron run. + _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, + map[string]string{ + "X-Goog-Channel-Id": details.ChannelID, + "X-Goog-Resource-State": "exists", + }) + assert.Equal(t, 0, calendar.MockChannelsCount()) + + team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + require.Len(t, team1CalendarEvents, 1) + assert.Equal(t, eventUpdated, team1CalendarEvents[0]) + + // Trigger calendar should cleanup the events + triggerAndWait(ctx, t, s.ds, s.calendarSchedule, 5*time.Second) + assert.Equal(t, 0, calendar.MockChannelsCount()) + + // Event should be cleaned up from our database. + team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) + require.NoError(t, err) + assert.Empty(t, team1CalendarEvents) + +} diff --git a/tools/calendar/README.md b/tools/calendar/README.md index bab2f481b4..b6bb88a4f5 100644 --- a/tools/calendar/README.md +++ b/tools/calendar/README.md @@ -4,6 +4,8 @@ To delete all downtime events from a Google Calendar, use `delete-events/delete- To move all downtime events from multiple Google Calendars to a specific time, use `move-events/move-events.go` +To use the helper scripts, you must set `FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL` and `FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY` environment variables. The email is the `client_email` from JSON key file. The private key also comes from JSON key file for the service account, and starts with `-----BEGIN PRIVATE KEY-----`. + # Calendar server for load testing Test calendar server that provides a REST API for managing events. diff --git a/tools/calendar/delete-events/delete-events.go b/tools/calendar/delete-events/delete-events.go index dea8197dfa..676da9d88f 100644 --- a/tools/calendar/delete-events/delete-events.go +++ b/tools/calendar/delete-events/delete-events.go @@ -34,6 +34,8 @@ func main() { if serviceEmail == "" || privateKey == "" { log.Fatal("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL and FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY must be set") } + // Strip newlines from private key + privateKey = strings.Replace(privateKey, "\\n", "\n", -1) userEmails := flag.String("users", "", "Comma-separated list of user emails to impersonate") flag.Parse() if *userEmails == "" { diff --git a/tools/calendar/move-events/move-events.go b/tools/calendar/move-events/move-events.go index 7e413abaf7..3e22032c99 100644 --- a/tools/calendar/move-events/move-events.go +++ b/tools/calendar/move-events/move-events.go @@ -35,6 +35,8 @@ func main() { if serviceEmail == "" || privateKey == "" { log.Fatal("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL and FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY must be set") } + // Strip newlines from private key + privateKey = strings.Replace(privateKey, "\\n", "\n", -1) userEmails := flag.String("users", "", "Comma-separated list of user emails to impersonate") dateTimeStr := flag.String("datetime", "", "Event time in "+time.RFC3339+" format") flag.Parse() diff --git a/tools/calendar/stop-channel/stop-channel.go b/tools/calendar/stop-channel/stop-channel.go new file mode 100644 index 0000000000..15e61a27e2 --- /dev/null +++ b/tools/calendar/stop-channel/stop-channel.go @@ -0,0 +1,123 @@ +package main + +import ( + "context" + "errors" + "flag" + "github.com/cenkalti/backoff/v4" + "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" + "log" + "net/http" + "os" + "strings" + "time" +) + +// Stop watching the channel with the given ID. This command only accepts one user. +// Reference: https://developers.google.com/calendar/api/v3/reference/channels/stop + +// Example: go run stop-channel.go --users john@example.com --channel-id 55ebefd7-4271-4295-a80a-97f4dcb01d93 --resource-id Io5ygBoEZ-FmQus7ziNrS_Jjcz4 + +var ( + serviceEmail = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL") + privateKey = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY") +) + +func main() { + if serviceEmail == "" || privateKey == "" { + log.Fatal("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL and FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY must be set") + } + // Strip newlines from private key + privateKey = strings.Replace(privateKey, "\\n", "\n", -1) + userEmails := flag.String("users", "", "Comma-separated list of user emails to impersonate") + channelIDStr := flag.String("channel-id", "", "Channel ID") + resourceIDStr := flag.String("resource-id", "", "Resource ID") + flag.Parse() + if *userEmails == "" { + log.Fatal("--users are required") + } + if *channelIDStr == "" { + log.Fatal("--channel-id is required") + } + if *resourceIDStr == "" { + log.Fatal("--resource-id is required") + } + userEmailList := strings.Split(*userEmails, ",") + if len(userEmailList) == 0 { + log.Fatal("No user emails provided") + } + if len(userEmailList) > 1 { + log.Fatal("Only one user email is allowed") + } + + ctx := context.Background() + + userEmail := userEmailList[0] + 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) + } + + _, err = withRetry( + func() (any, error) { + return nil, service.Channels.Stop(&calendar.Channel{ + Id: *channelIDStr, + ResourceId: *resourceIDStr, + }).Do() + }, + ) + + if err != nil { + log.Fatalf("Unable to stop watching channel: %v", err) + } + log.Printf("DONE. Stopped watching channel resource for %s", userEmail) + +} + +func withRetry(fn func() (any, error)) (any, error) { + retryStrategy := backoff.NewExponentialBackOff() + retryStrategy.MaxElapsedTime = 60 * time.Minute + var result any + err := backoff.Retry( + func() error { + var err error + result, err = fn() + if err != nil { + if isRateLimited(err) { + return err + } + return backoff.Permanent(err) + } + return nil + }, retryStrategy, + ) + return result, err +} + +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", + )))) +}