From 2364696b05a31f04c0d9d608fd987db66d0376b5 Mon Sep 17 00:00:00 2001 From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:36:50 +0200 Subject: [PATCH 01/11] Update calendar preview example in UI (#20572) Update date in preview calendar example to match article and be realistic since it will happen every Tuesday by default. Related to: #19031 --- .../CalendarEventPreviewModal/CalendarEventPreviewModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx index f6b5379675..0199d029c9 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/CalendarEventPreviewModal/CalendarEventPreviewModal.tsx @@ -51,7 +51,7 @@ const CalendarEventPreviewModal = ({ 💻 🚫 Scheduled maintenance
- Friday, April 5 + Tuesday, June 18 5-5:30pm
From c1a5e3b7b698f5de4c5a162fd9184aa76e094102 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Wed, 24 Jul 2024 13:40:33 +0200 Subject: [PATCH 02/11] Fix calendar duplicated events and other issues (#20443) #19352 Includes the following changes: - Re-enable calendar callback - Introduced a new Redis key that indicates event was updated by calendar callback. In that case, we ignore subsequent callbacks for 10 seconds. - This reduces the amount of Google API calls, including handling of the unneeded callback generated by our own event change. - Read event from DB after acquiring lock. This is critical since we get the updated ETag of the Google Calendar event from our DB. Using the previous ETag when fetching event sometimes returns stale data, resulting in duplicate events. - Fixed bug in getCalendarLock where calendar cron would always think it got the lock - Do not refetch timezone during calendar callback to reduce Google API load - Watch for calendar event changes for 1 week after event end (to account for user moving event into the future) - #20442: Speculative improvement for Google callback latency by keeping the same notification channel (callback URL). - processCalendarAsync now takes at least 1 sec to process all events, to reduce CPU/Redis load - Increased lock expiration time from 1 minute to 20 minutes to account for potential Google API retries, fixing occasional duplicate events. - Added `get-events.go` helper script that gets maintenance events from user calendars, and checks for duplicates # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/19352-calendar-real-time | 3 + ee/server/calendar/google_calendar.go | 160 +++++++------- .../google_calendar_integration_test.go | 42 +++- ee/server/calendar/google_calendar_test.go | 46 ++-- ee/server/service/calendar.go | 129 ++++++++++-- ee/server/service/calendar_test.go | 108 ++++++++++ server/cron/calendar_cron.go | 41 +++- server/fleet/calendar.go | 19 +- server/service/calendar.go | 72 +++---- server/service/calendar/calendar.go | 20 ++ server/service/handler.go | 3 +- server/service/integration_enterprise_test.go | 29 ++- server/service/testing_utils.go | 2 +- tools/calendar/get-events/get-events.go | 196 ++++++++++++++++++ 14 files changed, 690 insertions(+), 180 deletions(-) create mode 100644 changes/19352-calendar-real-time create mode 100644 ee/server/service/calendar_test.go create mode 100644 tools/calendar/get-events/get-events.go diff --git a/changes/19352-calendar-real-time b/changes/19352-calendar-real-time new file mode 100644 index 0000000000..d96cf1fa11 --- /dev/null +++ b/changes/19352-calendar-real-time @@ -0,0 +1,3 @@ +- In maintenance windows using Google Calendar, calendar event is now recreated within 30 seconds if deleted or moved to the past. + - Fleet server watches for potential changes for up to 1 week after original event time. If event is moved forward more than 1 week, then after 1 week Fleet server will check for event changes once every 30 minutes. + - These near real-time updates may add additional load to the Google Calendar API, so it is recommended to use API usage alerts or other monitoring methods. diff --git a/ee/server/calendar/google_calendar.go b/ee/server/calendar/google_calendar.go index d0d6477d28..c527cec3a2 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" @@ -191,41 +192,35 @@ func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error { } func (lowLevelAPI *GoogleCalendarLowLevelAPI) Watch(eventUUID string, channelID string, ttl uint64) (resourceID string, err error) { - // Disabling this feature to address bugs - return "", nil - - // 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 + 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 { - // Disabling this feature to address bugs - return nil - - // _, err := lowLevelAPI.withRetry( - // func() (any, error) { - // return nil, lowLevelAPI.service.Channels.Stop(&calendar.Channel{ - // Id: channelID, - // ResourceId: resourceID, - // }).Do() - // }, - // ) - // return err + _, 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) { @@ -266,7 +261,8 @@ func (c *GoogleCalendar) Configure(userEmail string) error { return nil } -func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) (body string, updated bool, err error)) ( +func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) (body string, updated bool, err error), + opts fleet.CalendarGetAndUpdateEventOpts) ( *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. @@ -275,18 +271,28 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn return nil, false, err } - // Set current calendar instance timezone to the latest from google calendar. - c.location, err = getTimezone(c) - if err != nil { - return nil, false, err + // Set current calendar instance timezone to the latest from Google calendar. + var tzUpdated bool + var latestTzName string + updateTimezone := func() error { + c.location, err = getTimezone(c) + if err != nil { + return err + } + latestTzName = c.location.String() + // nil if cal event created before Fleet tracked timezone + tzUpdated = event.TimeZone == nil || (latestTzName != *event.TimeZone) + return nil + } + if opts.UpdateTimezone { + err = updateTimezone() + if err != nil { + return nil, false, err + } } - latestTzName := c.location.String() - // nil if cal event created before Fleet tracked timezone - tzUpdated := event.TimeZone == nil || (latestTzName != *event.TimeZone) gEvent, err := c.config.API.GetEvent(details.ID, details.ETag) - - var deleted, channelStopped bool + var deleted bool switch { // http.StatusNotModified is returned sometimes, but not always, so we need to check ETag explicitly later case googleapi.IsNotModified(err): @@ -327,7 +333,6 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err) } deleted = true - channelStopped = true } var endTime *time.Time @@ -344,7 +349,6 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which is in the past", "err", err) } deleted = true - channelStopped = true } } if !deleted { @@ -360,10 +364,16 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err) } deleted = true - channelStopped = true } } if !deleted { + if c.location == nil { + // When we are updating the event, also update the timezone if needed + err = updateTimezone() + if err != nil { + return nil, false, err + } + } startTime, err := c.parseDateTime(gEvent.Start) if err != nil { return nil, false, err @@ -376,17 +386,19 @@ func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn } } - // If event was deleted/cancelled, we need to stop watching it - if !channelStopped { - 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) - } - } - + // When calculating the new event date, we don't check if the user's calendar timezone has changed. + // If the user went across international dateline into "tomorrow", then their event may be re-scheduled for the same day. + // Since this rare corner case does not cause any harm, we will ignore it. newStartDate := calculateNewEventDate(event.StartTime) - fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn) + var createOpts fleet.CalendarCreateEventOpts + // Check for backward compatibility, for events created before we introduced notification channels + if details.ChannelID != "" && details.ResourceID != "" { + createOpts.EventUUID = event.UUID + createOpts.ChannelID = details.ChannelID + createOpts.ResourceID = details.ResourceID + } + fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn, createOpts) if err != nil { return nil, false, err } @@ -464,14 +476,16 @@ func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDet } 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) + genBodyFn func(conflict bool) (body string, ok bool, err error), + opts fleet.CalendarCreateEventOpts) (*fleet.CalendarEvent, error) { + return c.createEvent(dayOfEvent, genBodyFn, time.Now, opts) } // 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) (body string, ok bool, err error), timeNow func() time.Time, + opts fleet.CalendarCreateEventOpts, ) (*fleet.CalendarEvent, error) { var err error if c.location == nil { @@ -579,17 +593,26 @@ func (c *GoogleCalendar) createEvent( 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") + // Watch for event changes, if not already watching. + var eventUUID, channelID, resourceID string + if opts.EventUUID == "" || opts.ChannelID == "" || opts.ResourceID == "" { + // Watch for changes until the end of the event, plus 1 more week. The extra time is to handle cases when end user moves the event forward. + // We don't support watching events longer than 1 week from the original event time. + secondsToEventEnd := (eventEnd.Sub(now).Milliseconds() / 1000) + (7 * 24 * 60 * 60) + eventUUID = strings.ToUpper(uuid.New().String()) // Standardize on uppercase UUIDs since that's how they come from DB + channelID = uuid.New().String() + resourceID, err = c.config.API.Watch(eventUUID, channelID, uint64(secondsToEventEnd)) + if err != nil { + return nil, ctxerr.Wrap(c.config.Context, err, "watching Google calendar event") + } + } else { + eventUUID = opts.EventUUID + channelID = opts.ChannelID + resourceID = opts.ResourceID } // Convert Google event to Fleet event - fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelUUID, resourceID) + fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event, eventUUID, channelID, resourceID) if err != nil { return nil, err } @@ -670,13 +693,6 @@ 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 { @@ -696,7 +712,7 @@ func (c *GoogleCalendar) StopEventChannel(event *fleet.CalendarEvent) error { 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) + level.Info(c.config.Logger).Log("msg", "stopping Google calendar event watch", "err", stopErr) } } return nil diff --git a/ee/server/calendar/google_calendar_integration_test.go b/ee/server/calendar/google_calendar_integration_test.go index 6e2bce57a2..0efe396167 100644 --- a/ee/server/calendar/google_calendar_integration_test.go +++ b/ee/server/calendar/google_calendar_integration_test.go @@ -2,16 +2,17 @@ package calendar import ( "context" + "net/http/httptest" + "os" + "testing" + "time" + "github.com/fleetdm/fleet/v4/ee/server/calendar/load_test" "github.com/fleetdm/fleet/v4/server/fleet" kitlog "github.com/go-kit/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "net/http/httptest" - "os" - "testing" - "time" ) type googleCalendarIntegrationTestSuite struct { @@ -70,12 +71,20 @@ func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() { return "Test event", true, nil } eventDate := time.Now().Add(48 * time.Hour) - event, err := gCal.CreateEvent(eventDate, genBodyFn) + event, err := gCal.CreateEvent(eventDate, genBodyFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, startHour, event.StartTime.Hour()) assert.Equal(t, 0, event.StartTime.Minute()) + details, err := gCal.unmarshalDetails(event) + require.NoError(t, err) + eventUUID := event.UUID + channelID := details.ChannelID + resourceID := details.ResourceID + assert.NotEmpty(t, eventUUID) + assert.NotEmpty(t, channelID) + assert.NotEmpty(t, resourceID) - eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn) + eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) require.NoError(t, err) assert.False(t, updated) assert.Equal(t, event, eventRsp) @@ -87,10 +96,25 @@ func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() { assert.NoError(t, err) // Try to get deleted event - eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn) + eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) require.NoError(t, err) assert.True(t, updated) assert.NotEqual(t, event.StartTime.UTC().Truncate(24*time.Hour), eventRsp.StartTime.UTC().Truncate(24*time.Hour)) + + opts := fleet.CalendarCreateEventOpts{ + ChannelID: channelID, + ResourceID: resourceID, + EventUUID: eventUUID, + } + event, err = gCal.CreateEvent(eventDate, genBodyFn, opts) + require.NoError(t, err) + assert.Equal(t, startHour, event.StartTime.Hour()) + assert.Equal(t, 0, event.StartTime.Minute()) + details, err = gCal.unmarshalDetails(event) + require.NoError(t, err) + assert.Equal(t, channelID, details.ChannelID) + assert.Equal(t, resourceID, details.ResourceID) + assert.Equal(t, eventUUID, event.UUID) } func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() { @@ -114,7 +138,7 @@ func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() { return "Test event", true, nil } eventDate := time.Now().Add(48 * time.Hour) - event, err := gCal.CreateEvent(eventDate, genBodyFn) + event, err := gCal.CreateEvent(eventDate, genBodyFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, startHour, event.StartTime.Hour()) assert.Equal(t, 0, event.StartTime.Minute()) @@ -124,7 +148,7 @@ func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() { if !(currentEventTime.Hour() == endHour-1 && currentEventTime.Minute() == 30) { currentEventTime = currentEventTime.Add(30 * time.Minute) } - event, err = gCal.CreateEvent(eventDate, genBodyFn) + event, err = gCal.CreateEvent(eventDate, genBodyFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, currentEventTime.UTC(), event.StartTime.UTC()) } diff --git a/ee/server/calendar/google_calendar_test.go b/ee/server/calendar/google_calendar_test.go index 2aaaefa1b5..79d541152f 100644 --- a/ee/server/calendar/google_calendar_test.go +++ b/ee/server/calendar/google_calendar_test.go @@ -232,7 +232,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { } // ETag matches - retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.NoError(t, err) assert.False(t, updated) assert.Equal(t, event, retrievedEvent) @@ -241,7 +241,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { return nil, &googleapi.Error{Code: http.StatusNotModified} } - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.NoError(t, err) assert.False(t, updated) assert.Equal(t, event, retrievedEvent) @@ -252,14 +252,14 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { EndTime: time.Now().Add(time.Hour), Data: []byte(`{"bozo`), } - _, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn) + _, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.Error(t, err) // API error test mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { return nil, assert.AnError } - _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.ErrorIs(t, err, assert.AnError) // Event has been modified @@ -273,7 +273,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, }, nil } - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.NoError(t, err) assert.True(t, updated) assert.NotEqual(t, event, retrievedEvent) @@ -296,7 +296,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { End: &calendar.EventDateTime{DateTime: ""}, }, nil } - _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.Error(t, err) // missing start time @@ -307,7 +307,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)}, }, nil } - _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.Error(t, err) // Bad time format @@ -319,7 +319,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { End: &calendar.EventDateTime{DateTime: "bozo"}, }, nil } - _, _, err = cal.GetAndUpdateEvent(event, genBodyFn) + _, _, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) assert.Error(t, err) // Event has been modified, with custom timezone. @@ -338,7 +338,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339), TimeZone: newTzName}, }, nil } - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{UpdateTimezone: true}) assert.NoError(t, err) assert.True(t, updated) assert.NotEqual(t, event, retrievedEvent) @@ -382,7 +382,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { eventCreated = true return event, nil } - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) require.NoError(t, err) assert.True(t, updated) assert.NotEqual(t, event, retrievedEvent) @@ -410,7 +410,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { }, nil } eventCreated = false - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) require.NoError(t, err) assert.True(t, updated) require.NotNil(t, retrievedEvent) @@ -433,7 +433,7 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { }, nil } eventCreated = false - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) require.NoError(t, err) assert.True(t, updated) require.NotNil(t, retrievedEvent) @@ -446,13 +446,13 @@ func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) { mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) { return &calendar.Event{ Id: baseEventID, - Etag: "new-eTag", + Etag: "new-eTag in past", Start: &calendar.EventDateTime{DateTime: startTime.Add(-2 * time.Hour).Format(time.RFC3339)}, End: &calendar.EventDateTime{DateTime: endTime.Add(-2 * time.Hour).Format(time.RFC3339)}, }, nil } eventCreated = false - retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn) + retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) require.NoError(t, err) assert.True(t, updated) require.NotNil(t, retrievedEvent) @@ -508,7 +508,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { assert.Greater(t, ttl, uint64(60*30-1)) return baseResourceID, nil } - event, err := cal.CreateEvent(date, genBodyFn) + event, err := cal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, uuid, event.UUID) assert.Equal(t, baseUserEmail, event.Email) @@ -529,7 +529,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { // Workday already ended date = time.Now().Add(-48 * time.Hour) - _, err = cal.CreateEvent(date, genBodyFn) + _, err = cal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{}) assert.ErrorAs(t, err, &fleet.DayEndedError{}) // There is no time left in the day to schedule an event @@ -538,7 +538,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location) return now } - _, err = gCal.createEvent(date, genBodyFn, timeNow) + _, err = gCal.createEvent(date, genBodyFn, timeNow, fleet.CalendarCreateEventOpts{}) assert.ErrorAs(t, err, &fleet.DayEndedError{}) // Workday already started @@ -547,7 +547,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { timeNow = func() time.Time { return expectedStartTime } - event, err = gCal.createEvent(date, genBodyFn, timeNow) + event, err = gCal.createEvent(date, genBodyFn, timeNow, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) @@ -640,7 +640,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { return gEvents, nil } expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location) - event, err = gCal.CreateEvent(date, genBodyFn) + event, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) @@ -660,7 +660,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { return gEvents, nil } expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location) - event, err = gCal.CreateEvent(date, genBodyConflictFn) + event, err = gCal.CreateEvent(date, genBodyConflictFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) @@ -680,7 +680,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { return gEvents, nil } expectedStartTime = dayEnd - event, err = gCal.CreateEvent(date, genBodyFn) + event, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{}) require.NoError(t, err) assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC()) assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC()) @@ -689,7 +689,7 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) { return nil, assert.AnError } - _, err = gCal.CreateEvent(date, genBodyFn) + _, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{}) assert.ErrorIs(t, err, assert.AnError) // API error in CreateEvent @@ -699,6 +699,6 @@ func TestGoogleCalendar_CreateEvent(t *testing.T) { mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) { return nil, assert.AnError } - _, err = gCal.CreateEvent(date, genBodyFn) + _, err = gCal.CreateEvent(date, genBodyFn, fleet.CalendarCreateEventOpts{}) assert.ErrorIs(t, err, assert.AnError) } diff --git a/ee/server/service/calendar.go b/ee/server/service/calendar.go index 34b4e5daba..b6762ae90b 100644 --- a/ee/server/service/calendar.go +++ b/ee/server/service/calendar.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sync" + "time" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -39,6 +40,30 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann return nil } + // If the event was updated recently, we will ignore the callback. + // If this was a legitimate update, then it will be caught by the next cron job run (or a future callback). + recent, err := svc.distributedLock.Get(ctx, calendar.RecentUpdateKeyPrefix+eventUUID) + if err != nil { + return err + } + if recent != nil && *recent == calendar.RecentCalendarUpdateValue { + svc.authz.SkipAuthorization(ctx) + return nil + } + + // In the common case, we get the lock right away and process the event. + // Otherwise, we do additional validation to see if we need to process the event. + lockValue, reserved, err := svc.getCalendarLock(ctx, eventUUID, false) + if err != nil { + return err + } + unlocked := false + defer func() { + if !unlocked && lockValue != "" { + svc.releaseCalendarLock(ctx, eventUUID, lockValue) + } + }() + eventDetails, err := svc.ds.GetCalendarEventDetailsByUUID(ctx, eventUUID) if err != nil { svc.authz.SkipAuthorization(ctx) @@ -71,10 +96,28 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann return authz.ForbiddenWithInternal(fmt.Sprintf("calendar channel ID mismatch: %s != %s", savedChannelID, channelID), nil, nil, nil) } - lockValue, reserved, err := svc.getCalendarLock(ctx, eventUUID, true) - if err != nil { - return err + // Now that we fully validated the request, try to get the lock again if we didn't get it the first time. + // This time the event will be added to the queue if needed. + if lockValue == "" { + lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, true) + if err != nil { + return err + } + if lockValue != "" { + // We got the lock, so we can process the event. We need to refetch the event from DB, since it may have changed since the last fetch. + eventDetails, err = svc.ds.GetCalendarEventDetailsByUUID(ctx, eventUUID) + if err != nil { + if fleet.IsNotFound(err) { + // We found the event the first time, but it was deleted before we got the lock. + level.Info(svc.logger).Log("msg", "Received calendar callback, but the event was just deleted", "event_uuid", + eventUUID, "channel_id", channelID) + return nil + } + return err + } + } } + // If lock has been reserved by cron, we will need to re-process this event in case the calendar event was changed after the cron job read it. if lockValue == "" && !reserved { // We did not get a lock, so there is nothing to do here @@ -82,13 +125,6 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann } if !reserved { - unlocked := false - defer func() { - if !unlocked { - svc.releaseCalendarLock(ctx, eventUUID, lockValue) - } - }() - // Remove event from the queue so that we don't process this event again. // Note: This item can be added back to the queue while we are processing it. err = svc.distributedLock.RemoveFromSet(ctx, calendar.QueueKey, eventUUID) @@ -125,6 +161,8 @@ func (svc *Service) CalendarWebhook(ctx context.Context, eventUUID string, chann func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *fleet.CalendarEventDetails, googleCalendarIntegrationConfig *fleet.GoogleCalendarIntegration, userCalendar fleet.UserCalendar) error { + // This flag indicates that calendar event should no longer exist, and we can stop watching it. + stopChannel := false genBodyFn := func(conflict bool) (body string, ok bool, err error) { // This function is called when a new event is being created. @@ -136,6 +174,7 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee if team.Config.Integrations.GoogleCalendar == nil || !team.Config.Integrations.GoogleCalendar.Enable { + stopChannel = true return "", false, nil } @@ -146,6 +185,7 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee } if len(policies) == 0 { + stopChannel = true return "", false, nil } @@ -161,10 +201,12 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee return "", false, err } if len(hosts) != 1 { + stopChannel = true return "", false, nil } host := hosts[0] if host.Passing { // host is passing all configured policies + stopChannel = true return "", false, nil } if host.Email == "" { @@ -179,17 +221,36 @@ func (svc *Service) processCalendarEvent(ctx context.Context, eventDetails *flee if err != nil { return ctxerr.Wrap(ctx, err, "configure calendar") } - event, updated, err := userCalendar.GetAndUpdateEvent(&eventDetails.CalendarEvent, genBodyFn) + event, updated, err := userCalendar.GetAndUpdateEvent(&eventDetails.CalendarEvent, genBodyFn, fleet.CalendarGetAndUpdateEventOpts{}) if err != nil { return ctxerr.Wrap(ctx, err, "get and update event") } if updated && event != nil { + // Event was updated, so we set a flag. + _, err = svc.distributedLock.AcquireLock(ctx, calendar.RecentUpdateKeyPrefix+event.UUID, calendar.RecentCalendarUpdateValue, + uint64(calendar.RecentCalendarUpdateDuration.Milliseconds())) + if err != nil { + return ctxerr.Wrap(ctx, err, "set recent update flag") + } // 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.HostID, fleet.CalendarWebhookStatusNone) if err != nil { return ctxerr.Wrap(ctx, err, "create or update calendar event") } + // Remove event from the queue (again) so that we don't process this event again in case we got a callback from the event change which we ourselves made. + err = svc.distributedLock.RemoveFromSet(ctx, calendar.QueueKey, event.UUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "remove calendar event from queue") + } + + } + if stopChannel { + // The cron job could have already stopped the channel. For example, if calendar was disabled. + err = userCalendar.StopEventChannel(&eventDetails.CalendarEvent) + if err != nil { + return ctxerr.Wrap(ctx, err, "stop event channel") + } } return nil @@ -202,7 +263,7 @@ func (svc *Service) releaseCalendarLock(ctx context.Context, eventUUID string, l } if !ok { // If the lock was not released, it will expire on its own. - level.Warn(svc.logger).Log("msg", "Failed to release calendar lock") + level.Error(svc.logger).Log("msg", "Failed to release calendar lock", "event uuid", eventUUID, "lockValue", lockValue) } } @@ -221,7 +282,8 @@ func (svc *Service) getCalendarLock(ctx context.Context, eventUUID string, addTo if !reserved { // Try to acquire the lock lockValue = uuid.New().String() - lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0) + lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, + calendar.DistributedLockExpireMs) if err != nil { return "", false, ctxerr.Wrap(ctx, err, "acquire calendar lock") } @@ -240,15 +302,15 @@ func (svc *Service) getCalendarLock(ctx context.Context, eventUUID string, addTo } // Try to acquire the lock again in case it was released while we were adding the event to the queue. - lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0) + lockAcquired, err = svc.distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, + calendar.DistributedLockExpireMs) if err != nil { return "", false, ctxerr.Wrap(ctx, err, "acquire calendar lock again") } - - if !lockAcquired { - // We could not acquire the lock, so we are done here. - return "", reserved, nil - } + } + if !lockAcquired { + // We could not acquire the lock, so we are done here. + return "", reserved, nil } return lockValue, false, nil } @@ -259,15 +321,24 @@ func (svc *Service) processCalendarAsync(ctx context.Context, eventIDs []string) asyncCalendarProcessing = false asyncMutex.Unlock() }() + const minLoopTime = time.Second + runTime := minLoopTime for { if len(eventIDs) == 0 { return } + // We want to make sure we don't run this too often to reduce load on CPU/Redis, so we wait at least a second between runs. + if runTime < minLoopTime && runTime > 0 { + time.Sleep(minLoopTime - runTime) + } + start := svc.clock.Now() for _, eventUUID := range eventIDs { if ok := svc.processCalendarEventAsync(ctx, eventUUID); !ok { return } } + end := svc.clock.Now() + runTime = end.Sub(start) // Now we check whether there are any more events in the queue. var err error @@ -280,6 +351,22 @@ func (svc *Service) processCalendarAsync(ctx context.Context, eventIDs []string) } func (svc *Service) processCalendarEventAsync(ctx context.Context, eventUUID string) bool { + // If the event was updated recently, we will ignore it. + // If this was a legitimate update, then it will be caught by the next cron job run (or a future callback). + recent, err := svc.distributedLock.Get(ctx, calendar.RecentUpdateKeyPrefix+eventUUID) + if err != nil { + level.Error(svc.logger).Log("msg", "Failed to get recent update flag", "err", err) + return false + } + if recent != nil && *recent == calendar.RecentCalendarUpdateValue { + err = svc.distributedLock.RemoveFromSet(ctx, calendar.QueueKey, eventUUID) + if err != nil { + level.Error(svc.logger).Log("msg", "Failed to remove calendar event from queue", "err", err) + return false + } + return true + } + lockValue, _, err := svc.getCalendarLock(ctx, eventUUID, false) if err != nil { level.Error(svc.logger).Log("msg", "Failed to get calendar lock", "err", err) @@ -289,7 +376,9 @@ func (svc *Service) processCalendarEventAsync(ctx context.Context, eventUUID str // We did not get a lock, so there is nothing to do here return true } - defer svc.releaseCalendarLock(ctx, eventUUID, lockValue) + defer func() { + svc.releaseCalendarLock(ctx, eventUUID, lockValue) + }() // Remove event from the queue so that we don't process this event again. // Note: This item can be added back to the queue while we are processing it. diff --git a/ee/server/service/calendar_test.go b/ee/server/service/calendar_test.go new file mode 100644 index 0000000000..499112f5dd --- /dev/null +++ b/ee/server/service/calendar_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Implement fleet.Lock interface +type mockLock struct { + AcquireLockFn func(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) + GetFn func(ctx context.Context, key string) (*string, error) + AddToSetFn func(ctx context.Context, key string, value string) error +} + +func (m *mockLock) AcquireLock(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) { + return m.AcquireLockFn(ctx, key, value, expireMs) +} + +func (m *mockLock) ReleaseLock(ctx context.Context, key string, value string) (ok bool, err error) { + panic("implement me") +} + +func (m *mockLock) Get(ctx context.Context, key string) (*string, error) { + return m.GetFn(ctx, key) +} + +func (m *mockLock) AddToSet(ctx context.Context, key string, value string) error { + return m.AddToSetFn(ctx, key, value) +} + +func (m *mockLock) RemoveFromSet(ctx context.Context, key string, value string) error { + panic("implement me") +} + +func (m *mockLock) GetSet(ctx context.Context, key string) ([]string, error) { + panic("implement me") +} + +var calendarTestSetup = func(t *testing.T) (*mockLock, *Service) { + lock := &mockLock{} + svc := &Service{ + distributedLock: lock, + } + return lock, svc +} + +func TestGetCalendarLock(t *testing.T) { + lock, svc := calendarTestSetup(t) + ctx := context.Background() + eventUUID := "testUUID" + lock.AcquireLockFn = func(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) { + return true, nil + } + lock.GetFn = func(ctx context.Context, key string) (*string, error) { + // not reserved + return nil, nil + } + lockValue, reserved, err := svc.getCalendarLock(ctx, eventUUID, false) + require.NoError(t, err) + assert.False(t, reserved) + assert.NotEmpty(t, lockValue) + + // Make sure lock value is empty if we don't acquire the lock. + lock.AcquireLockFn = func(ctx context.Context, key string, value string, expireMs uint64) (ok bool, err error) { + return false, nil + } + lock.GetFn = func(ctx context.Context, key string) (*string, error) { + value := "value" + return &value, nil + } + lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, false) + require.NoError(t, err) + assert.True(t, reserved) + assert.Empty(t, lockValue) + + addedToSet := false + lock.AddToSetFn = func(ctx context.Context, key string, value string) error { + addedToSet = true + return nil + } + lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, true) + require.NoError(t, err) + assert.True(t, reserved) + assert.Empty(t, lockValue) + assert.True(t, addedToSet) + + addedToSet = false + lock.GetFn = func(ctx context.Context, key string) (*string, error) { + // not reserved + return nil, nil + } + lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, false) + require.NoError(t, err) + assert.False(t, reserved) + assert.Empty(t, lockValue) + assert.False(t, addedToSet) + + addedToSet = false + lockValue, reserved, err = svc.getCalendarLock(ctx, eventUUID, true) + require.NoError(t, err) + assert.False(t, reserved) + assert.Empty(t, lockValue) + assert.True(t, addedToSet) + +} diff --git a/server/cron/calendar_cron.go b/server/cron/calendar_cron.go index 9d85ad0028..a16f3355ac 100644 --- a/server/cron/calendar_cron.go +++ b/server/cron/calendar_cron.go @@ -323,15 +323,16 @@ func processFailingHostExistingCalendarEvent( // Try to acquire the lock. Lock is needed to ensure calendar callback is not processed for this event at the same time. eventUUID := calendarEvent.UUID lockValue := uuid.New().String() - lockAcquired, err := distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0) + lockAcquired, err := distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, calendar.DistributedLockExpireMs) if err != nil { return fmt.Errorf("acquire calendar lock: %w", err) } + lockReserved := false if !lockAcquired { // Lock was not acquired. We reserve the lock and try to acquire it until we do. - var timeoutMs uint64 = 2 * 60 * 1000 - lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.ReservedLockKeyPrefix+eventUUID, lockValue, timeoutMs) + lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.ReservedLockKeyPrefix+eventUUID, lockValue, + calendar.ReserveLockExpireMs) if err != nil { return fmt.Errorf("reserve calendar lock: %w", err) } @@ -344,12 +345,13 @@ func processFailingHostExistingCalendarEvent( go func() { for { // Keep trying to get the lock. - lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, 0) + lockAcquired, err = distributedLock.AcquireLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue, + calendar.DistributedLockExpireMs) if err != nil || lockAcquired { done <- struct{}{} return } - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } }() select { @@ -358,7 +360,7 @@ func processFailingHostExistingCalendarEvent( if err != nil { return fmt.Errorf("try to acquire calendar lock: %w", err) } - case <-time.After(time.Duration(timeoutMs) * time.Millisecond): + case <-time.After(time.Duration(calendar.ReserveLockExpireMs) * time.Millisecond): // We couldn't acquire the lock in time. return errors.New("could not acquire calendar lock in time") } @@ -372,7 +374,7 @@ func processFailingHostExistingCalendarEvent( } if !ok { // If the lock was not released, it will expire on its own. - level.Warn(logger).Log("msg", "Failed to release calendar reserve lock") + level.Error(logger).Log("msg", "Failed to release calendar reserve lock", "event uuid", eventUUID, "lockValue", lockValue) } } ok, err := distributedLock.ReleaseLock(ctx, calendar.LockKeyPrefix+eventUUID, lockValue) @@ -380,8 +382,8 @@ func processFailingHostExistingCalendarEvent( level.Error(logger).Log("msg", "Failed to release calendar lock", "err", err) } if !ok { - // If the lock was not released, it will expire on its own. - level.Warn(logger).Log("msg", "Failed to release calendar lock") + // If the lock was not released, it will expire on its own. However, we should adjust expiration time or something else to make sure we don't get here. + level.Error(logger).Log("msg", "Failed to release calendar lock", "event uuid", eventUUID, "lockValue", lockValue) } }() @@ -390,11 +392,23 @@ func processFailingHostExistingCalendarEvent( now := time.Now() if calendarConfig.AlwaysReloadEvent() || shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) { - var err error + // Refetch the event since it may have updated since we got the lock. + // We need the latest event data (ETag) to make sure that we get correct data from the calendar service. + calendarEvent, err = ds.GetCalendarEvent(ctx, calendarEvent.Email) + if err != nil { + if fleet.IsNotFound(err) { + // Event was deleted while we were processing it. It will be recreated if needed on the next cron run + return nil + } + return fmt.Errorf("get calendar event from db: %w", err) + } + // We could check the updated_at timestamp and avoid updating the event if it was updated recently. + updatedEvent, _, err = userCalendar.GetAndUpdateEvent( calendarEvent, func(conflict bool) (string, bool, error) { return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil }, + fleet.CalendarGetAndUpdateEventOpts{UpdateTimezone: true}, ) if err != nil { return fmt.Errorf("get event calendar on db: %w", err) @@ -417,6 +431,7 @@ func processFailingHostExistingCalendarEvent( } // Remove event from the queue so that we don't process this event again. + // If we just modified the event in the calendar, calendar will send a callback, and we don't need to process that callback. err = distributedLock.RemoveFromSet(ctx, calendar.QueueKey, eventUUID) if err != nil { return fmt.Errorf("remove calendar event from queue: %w", err) @@ -528,7 +543,7 @@ func attemptCreatingEventOnUserCalendar( calendarEvent, err := userCalendar.CreateEvent( preferredDate, func(conflict bool) (string, bool, error) { return calendar.GenerateCalendarEventBody(ctx, ds, orgName, host, policyIDtoPolicy, conflict, logger), true, nil - }, + }, fleet.CalendarCreateEventOpts{}, ) var dee fleet.DayEndedError switch { @@ -817,6 +832,10 @@ func deleteCalendarEvent( return fmt.Errorf("delete calendar event: %w", err) } } + // Stop watching for calendar changes + if err := userCalendar.StopEventChannel(calendarEvent); err != nil { + return fmt.Errorf("stop event channel: %w", err) + } } if err := ds.DeleteCalendarEvent(ctx, calendarEvent.ID); err != nil { return fmt.Errorf("delete db calendar event: %w", err) diff --git a/server/fleet/calendar.go b/server/fleet/calendar.go index e4a76354d8..f168e26620 100644 --- a/server/fleet/calendar.go +++ b/server/fleet/calendar.go @@ -28,11 +28,16 @@ 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) (body string, ok bool, err error)) (event *CalendarEvent, err error) + CreateEvent( + dateOfEvent time.Time, + genBodyFn func(conflict bool) (body string, ok bool, err error), + opts CalendarCreateEventOpts, + ) (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) (body string, ok bool, err error)) (updatedEvent *CalendarEvent, + GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) (body string, ok bool, err error), + opts CalendarGetAndUpdateEventOpts) (updatedEvent *CalendarEvent, updated bool, err error) // DeleteEvent deletes the event with the given ID. DeleteEvent(event *CalendarEvent) error @@ -61,6 +66,16 @@ type Lock interface { GetSet(ctx context.Context, key string) ([]string, error) } +type CalendarCreateEventOpts struct { + EventUUID string + ChannelID string + ResourceID string +} + +type CalendarGetAndUpdateEventOpts struct { + UpdateTimezone bool +} + type CalendarWebhookPayload struct { Timestamp time.Time `json:"timestamp"` HostID uint `json:"host_id"` diff --git a/server/service/calendar.go b/server/service/calendar.go index ce89db6779..6f7ff6b26e 100644 --- a/server/service/calendar.go +++ b/server/service/calendar.go @@ -2,53 +2,55 @@ 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" ) -// Disabling the calendarWebhookEndpoint to address bugs - -// type calendarWebhookRequest struct { -// eventUUID string -// googleChannelID string -// googleResourceState string -// } +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 +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") + req.googleChannelID = r.Header.Get("X-Goog-Channel-Id") + req.googleResourceState = r.Header.Get("X-Goog-Resource-State") -// return &req, nil -// } + return &req, nil +} -// type calendarWebhookResponse struct { -// Err error `json:"error,omitempty"` -// } +type calendarWebhookResponse struct { + Err error `json:"error,omitempty"` +} -// func (r calendarWebhookResponse) error() error { return r.Err } +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 -// } +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 -// } + 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. diff --git a/server/service/calendar/calendar.go b/server/service/calendar/calendar.go index a125ddb7a3..d6ec6bdb1b 100644 --- a/server/service/calendar/calendar.go +++ b/server/service/calendar/calendar.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/fleetdm/fleet/v4/ee/server/calendar" "github.com/fleetdm/fleet/v4/server/config" @@ -19,9 +20,28 @@ import ( const ( LockKeyPrefix = "calendar:lock:" ReservedLockKeyPrefix = "calendar:reserved:" + RecentUpdateKeyPrefix = "calendar:recent_update:" QueueKey = "calendar:queue" + + // DistributedLockExpireMs is the time Redis will hold the lock before automatically releasing it. + // Our current max retry time for calendar API is 10 minutes, and multiple API calls (with their own retry timing) can be made during event processing. + // If a Fleet server gets the lock and is then shut down before releasing the lock, the next server may need to wait this long + // before getting the lock. + DistributedLockExpireMs = 20 * 60 * 1000 + // ReserveLockExpireMs is used by cron job to guarantee that it gets the next lock. + ReserveLockExpireMs = 2 * DistributedLockExpireMs + + // RecentCalendarUpdateValue is the value stored in Redis to indicate that a calendar event was recently updated. + RecentCalendarUpdateValue = "1" ) +// RecentCalendarUpdateDuration is the duration during which we will ignore a calendar event callback if the event in DB was just updated by a previous callback. +// This reduces CPU load and Google API load. If we update the event, Google calendar may send a callback which we don't need to process. +// We are using Redis instead of updated_at timestamp in DB because the calendar cron job may update the timestamp even when the event did not change, which could +// cause us to miss a legitimate update. +// This variable is exposed so that it can be modified by unit tests. +var RecentCalendarUpdateDuration = 10 * time.Second + type Config struct { config.CalendarConfig fleet.GoogleCalendarIntegration diff --git a/server/service/handler.go b/server/service/handler.go index 48fe34c6ec..66709d72f5 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -941,8 +941,7 @@ 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 - // Disabling the calendarWebhookEndpoint to address bugs - // ne.POST("/api/_version_/fleet/calendar/webhook/{event_uuid}", calendarWebhookEndpoint, calendarWebhookRequest{}) + 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_enterprise_test.go b/server/service/integration_enterprise_test.go index b93e8b23ad..dc110cdc40 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -10983,9 +10983,8 @@ func (s *integrationEnterpriseTestSuite) TestPKGSoftwareReconciliation() { } func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { - t := s.T() - t.Skip("disabled calendar callbacks to address bugs") ctx := context.Background() + t := s.T() t.Cleanup(func() { calendar.ClearMockEvents() calendar.ClearMockChannels() @@ -10997,6 +10996,12 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { require.NoError(t, err) }) + origRecentUpdateDuration := commonCalendar.RecentCalendarUpdateDuration + commonCalendar.RecentCalendarUpdateDuration = 1 * time.Millisecond + t.Cleanup(func() { + commonCalendar.RecentCalendarUpdateDuration = origRecentUpdateDuration + }) + team1, err := s.ds.NewTeam(ctx, &fleet.Team{ Name: "team1", }) @@ -11241,8 +11246,9 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { time.Sleep(100 * time.Millisecond) team1CalendarEvents, err = s.ds.ListCalendarEvents(ctx, &team1.ID) require.NoError(t, err) - require.Len(t, team1CalendarEvents, 1) - if event.UUID != team1CalendarEvents[0].UUID { + // Event should be rescheduled on a future date/time + if len(team1CalendarEvents) == 1 && team1CalendarEvents[0].UUID == event.UUID && + team1CalendarEvents[0].StartTime.After(event.StartTime) { done <- struct{}{} return } @@ -11392,7 +11398,20 @@ func (s *integrationEnterpriseTestSuite) TestCalendarCallback() { }, ), http.StatusOK, &distributedResp) - // Callback should still work, but only clear the callback channel. Event in DB will be deleted on the next cron run. + // We set a flag that event was updated recently. Callback shouldn't do anything since event was updated recently + _, err = distributedLock.AcquireLock(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue, + 1000) + require.NoError(t, err) + _ = 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, 1, calendar.MockChannelsCount()) + + // Callback should work, but only clear the callback channel. Event in DB will be deleted on the next cron run. + _, err = distributedLock.ReleaseLock(ctx, commonCalendar.RecentUpdateKeyPrefix+event.UUID, commonCalendar.RecentCalendarUpdateValue) + require.NoError(t, err) _ = s.DoRawWithHeaders("POST", "/api/v1/fleet/calendar/webhook/"+eventRecreated.UUID, []byte(""), http.StatusOK, map[string]string{ "X-Goog-Channel-Id": details.ChannelID, diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index df5fa5c219..ce7a82ea9e 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -188,7 +188,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf svc, err = eeservice.NewService( svc, ds, - kitlog.NewNopLogger(), + logger, fleetConfig, mailer, c, diff --git a/tools/calendar/get-events/get-events.go b/tools/calendar/get-events/get-events.go new file mode 100644 index 0000000000..46ce64cd4a --- /dev/null +++ b/tools/calendar/get-events/get-events.go @@ -0,0 +1,196 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "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" +) + +// Get all events with eventTitle from the primary calendar of the specified users. +// Example: go run delete-events.go --users john@example.com,jane@example.com + +var ( + serviceEmail = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_SERVICE_EMAIL") + privateKey = os.Getenv("FLEET_TEST_GOOGLE_CALENDAR_PRIVATE_KEY") +) + +const ( + eventTitle = "💻🚫 Scheduled maintenance" +) + +var regexMachineName = regexp.MustCompile(`your work computer (because there was no remaining availability )?\((?P.*)\)\.`) + +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 == "" { + log.Fatal("--users are required") + } + userEmailList := strings.Split(*userEmails, ",") + if len(userEmailList) == 0 { + log.Fatal("No user emails provided") + } + + ctx := context.Background() + + var wg sync.WaitGroup + + type summary struct { + total int + totalByDate map[string]int + duplicates map[string]struct{} + } + summaryByUser := make(map[string]summary) + + for _, userEmail := range userEmailList { + wg.Add(1) + go func(userEmail string) { + defer wg.Done() + conf := &jwt.Config{ + Email: serviceEmail, + Scopes: []string{ + "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.settings.readonly", + }, + PrivateKey: []byte(privateKey), + TokenURL: google.JWTTokenURL, + Subject: userEmail, + } + client := conf.Client(ctx) + // Create a new calendar service + service, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + log.Fatalf("Unable to create Calendar service: %v", err) + } + var maxResults int64 = 1000 + pageToken := "" + var total = 0 + var totalByDate = make(map[string]int) + var machines = make(map[string]struct{}) + var duplicates = make(map[string]struct{}) + for { + list, err := withRetry( + func() (any, error) { + return service.Events.List("primary"). + EventTypes("default"). + MaxResults(maxResults). + OrderBy("startTime"). + SingleEvents(true). + ShowDeleted(false). + Q(eventTitle). + PageToken(pageToken). + Do() + }, + ) + if err != nil { + log.Fatalf("Unable to retrieve list of events: %v", err) + } + for _, item := range list.(*calendar.Events).Items { + if item.Summary == eventTitle { + created, err := time.Parse(time.RFC3339, item.Created) + if err != nil { + log.Fatalf("Unable to parse event created time: %v", err) + } + var startTime time.Time + if item.Start != nil { + startTime, err = time.Parse(time.RFC3339, item.Start.DateTime) + if err != nil { + log.Fatalf("Unable to parse event start time: %v", err) + } + } + matches := regexMachineName.FindStringSubmatch(item.Description) + machineName := "NOT_FOUND" + if matches != nil { + machineName = matches[regexMachineName.SubexpIndex("machine")] + if _, ok := machines[machineName]; ok { + duplicates[machineName] = struct{}{} + } + machines[machineName] = struct{}{} + } + total += 1 + dateStr := startTime.Format("2006-01-02") + totalByDate[dateStr] += 1 + fmt.Printf("%s created_at:%s user:%s machine:%s\n", startTime.Format(time.RFC3339), created.Format(time.RFC3339), + userEmail, machineName) + } + } + pageToken = list.(*calendar.Events).NextPageToken + if pageToken == "" || len(list.(*calendar.Events).Items) == 0 { + summaryByUser[userEmail] = summary{total: total, totalByDate: totalByDate, duplicates: duplicates} + break + } + } + }(userEmail) + } + + // Wait for all goroutines to finish + wg.Wait() + + fmt.Printf("Summary:\n") + for userEmail, s := range summaryByUser { + fmt.Printf("User: %s, Total: %d\n", userEmail, s.total) + for date, count := range s.totalByDate { + fmt.Printf("User: %s, Date: %s, Count: %d\n", userEmail, date, count) + } + if len(s.duplicates) > 0 { + dups := make([]string, 0, len(s.duplicates)) + for k := range s.duplicates { + dups = append(dups, k) + } + fmt.Printf("User: %s, Machines with multiple events: %v\n", userEmail, dups) + } + } + +} + +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", + )))) +} From fb6b263fb0c294230f83feb6a55b700905f04d71 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:08:38 -0400 Subject: [PATCH 03/11] =?UTF-8?q?Frontend=20refactor:=20To=20typescript,?= =?UTF-8?q?=20remove=20unused=20testing=20stubs,=20functi=E2=80=A6=20(#203?= =?UTF-8?q?06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{TargetDetails.jsx => TargetDetails.tsx} | 130 +++++----- .../TargetDetails/{index.js => index.ts} | 0 .../TargetOption/TargetIcon.jsx | 32 --- .../TargetOption/TargetIcon.tsx | 36 +++ .../TargetOption/TargetOption.jsx | 79 ------ ...ption.tests.jsx => TargetOption.tests.tsx} | 48 +++- .../TargetOption/TargetOption.tsx | 81 ++++++ .../TargetOption/{index.js => index.ts} | 0 .../fields/SelectTargetsDropdown/helpers.ts | 24 ++ .../components/icons/FleetIcon/FleetIcon.jsx | 32 --- ...leetIcon.tests.jsx => FleetIcon.tests.tsx} | 0 .../components/icons/FleetIcon/FleetIcon.tsx | 29 +++ .../icons/FleetIcon/{index.js => index.ts} | 0 .../icons/PlatformIcon/PlatformIcon.jsx | 40 --- ...mIcon.tests.jsx => PlatformIcon.tests.tsx} | 0 .../icons/PlatformIcon/PlatformIcon.tsx | 43 ++++ .../icons/PlatformIcon/{index.js => index.ts} | 0 frontend/interfaces/label.ts | 4 +- frontend/interfaces/target.ts | 4 + .../helpers/userManagementHelpers.tests.ts | 7 +- frontend/test/stubs.ts | 239 ------------------ 21 files changed, 322 insertions(+), 506 deletions(-) rename frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/{TargetDetails.jsx => TargetDetails.tsx} (73%) rename frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/{index.js => index.ts} (100%) delete mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.jsx create mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.tsx delete mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.jsx rename frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/{TargetOption.tests.jsx => TargetOption.tests.tsx} (52%) create mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tsx rename frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/{index.js => index.ts} (100%) create mode 100644 frontend/components/forms/fields/SelectTargetsDropdown/helpers.ts delete mode 100644 frontend/components/icons/FleetIcon/FleetIcon.jsx rename frontend/components/icons/FleetIcon/{FleetIcon.tests.jsx => FleetIcon.tests.tsx} (100%) create mode 100644 frontend/components/icons/FleetIcon/FleetIcon.tsx rename frontend/components/icons/FleetIcon/{index.js => index.ts} (100%) delete mode 100644 frontend/components/icons/PlatformIcon/PlatformIcon.jsx rename frontend/components/icons/PlatformIcon/{PlatformIcon.tests.jsx => PlatformIcon.tests.tsx} (100%) create mode 100644 frontend/components/icons/PlatformIcon/PlatformIcon.tsx rename frontend/components/icons/PlatformIcon/{index.js => index.ts} (100%) diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.jsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx similarity index 73% rename from frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.jsx rename to frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx index 9b0765ed4c..d3780901c6 100644 --- a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.jsx +++ b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/TargetDetails.tsx @@ -1,28 +1,35 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; +import React from "react"; import { noop } from "lodash"; import AceEditor from "react-ace"; import classnames from "classnames"; import { humanHostMemory } from "utilities/helpers"; +// @ts-ignore import FleetIcon from "components/icons/FleetIcon"; +// @ts-ignore import PlatformIcon from "components/icons/PlatformIcon"; -import targetInterface from "interfaces/target"; +import { ISelectHost, ISelectLabel, ISelectTeam } from "interfaces/target"; + +import { isTargetHost, isTargetTeam, isTargetLabel } from "../helpers"; const baseClass = "target-details"; -class TargetDetails extends Component { - static propTypes = { - target: targetInterface, - className: PropTypes.string, - handleBackToResults: PropTypes.func, - }; +interface ITargetDetailsProps { + target: ISelectHost | ISelectTeam | ISelectLabel; // Replace with Target + className?: string; + handleBackToResults?: () => void; +} - static defaultProps = { - handleBackToResults: noop, - }; - - onlineHosts = (labelBaseClass, count, online) => { +const TargetDetails = ({ + target, + className = "", + handleBackToResults = noop, +}: ITargetDetailsProps): JSX.Element => { + const onlineHosts = ( + labelBaseClass: string, + count: number, + online: number + ) => { const offline = count - online; const percentCount = ((count - offline) / count) * 100; const percentOnline = parseFloat(percentCount.toFixed(2)); @@ -39,8 +46,7 @@ class TargetDetails extends Component { return false; }; - renderHost = () => { - const { className, handleBackToResults, target } = this.props; + const renderHost = (hostTarget: ISelectHost) => { const { display_text: displayText, primary_mac: hostMac, @@ -50,7 +56,7 @@ class TargetDetails extends Component { os_version: osVersion, platform, status, - } = target; + } = hostTarget; const hostBaseClass = "host-target"; const isOnline = status === "online"; const isOffline = status === "offline"; @@ -131,19 +137,17 @@ class TargetDetails extends Component { ); }; - renderLabel = () => { - const { onlineHosts } = this; - const { handleBackToResults, className, target } = this.props; + const renderLabel = (labelTarget: ISelectLabel) => { const { count, description, display_text: displayText, label_type: labelType, - online, + // online, query, - } = target; + } = labelTarget; const labelBaseClass = "label-target"; - + console.log("ERROR 1: labelTarget", labelTarget); return (
); }; - renderTeam = () => { - const { className, target } = this.props; - const { count, display_text: displayText } = target; + const renderTeam = (teamTarget: ISelectTeam) => { + const { count, display_text: displayText } = teamTarget; const labelBaseClass = "label-target"; return ( @@ -217,26 +217,22 @@ class TargetDetails extends Component { ); }; - render() { - const { target } = this.props; - - if (!target) { - return false; - } - - const { target_type: targetType } = target; - const { renderHost, renderLabel, renderTeam } = this; - - if (targetType === "labels") { - return renderLabel(); - } - - if (targetType === "teams") { - return renderTeam(); - } - - return renderHost(); + if (!target) { + return <>; } -} + + if (isTargetHost(target)) { + return renderHost(target); + } + + if (isTargetLabel(target)) { + return renderLabel(target); + } + + if (isTargetTeam(target)) { + return renderTeam(target); + } + return <>; +}; export default TargetDetails; diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/index.js b/frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/index.ts similarity index 100% rename from frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/index.js rename to frontend/components/forms/fields/SelectTargetsDropdown/TargetDetails/index.ts diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.jsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.jsx deleted file mode 100644 index f5488d4b49..0000000000 --- a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -import FleetIcon from "components/icons/FleetIcon"; -import targetInterface from "interfaces/target"; - -const baseClass = "target-option"; - -const TargetIcon = ({ target }) => { - const iconName = () => { - const { name, platform, target_type: targetType } = target; - - if (targetType === "labels") { - return name === "All Hosts" ? "all-hosts" : "label"; - } - - return platform === "darwin" ? "apple" : platform; - }; - - const { status } = target; - - const targetClasses = classnames( - `${baseClass}__icon`, - `${baseClass}__icon--${status}` - ); - - return ; -}; - -TargetIcon.propTypes = { target: targetInterface.isRequired }; - -export default TargetIcon; diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.tsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.tsx new file mode 100644 index 0000000000..a332ac31e7 --- /dev/null +++ b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetIcon.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import classnames from "classnames"; + +// @ts-ignore +import FleetIcon from "components/icons/FleetIcon"; + +import { ISelectTargetsEntity } from "interfaces/target"; +import { isTargetLabel, isTargetHost } from "../helpers"; + +const baseClass = "target-option"; + +interface ITargetIconProps { + target: ISelectTargetsEntity; +} + +const TargetIcon = ({ target }: ITargetIconProps): JSX.Element => { + const iconName = (): string => { + if (isTargetLabel(target)) { + return target.name === "All Hosts" ? "all-hosts" : "label"; + } + if (isTargetHost(target)) { + return target.platform === "darwin" ? "apple" : target.platform; + } + return ""; + }; + + const targetClasses = classnames(`${baseClass}__icon`, { + [`${baseClass}__icon--${ + isTargetHost(target) && target.status + }`]: isTargetHost(target), + }); + + return ; +}; + +export default TargetIcon; diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.jsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.jsx deleted file mode 100644 index 205e7b1fb8..0000000000 --- a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import classnames from "classnames"; - -import targetInterface from "interfaces/target"; -import TargetIcon from "./TargetIcon"; - -const baseClass = "target-option"; - -class TargetOption extends Component { - static propTypes = { - onMoreInfoClick: PropTypes.func, - onSelect: PropTypes.func, - target: targetInterface.isRequired, - }; - - handleSelect = (evt) => { - const { onSelect, target } = this.props; - - return onSelect(target, evt); - }; - - renderTargetDetail = () => { - const { target } = this.props; - const { - count, - primary_ip: hostIpAddress, - target_type: targetType, - } = target; - - if (targetType === "hosts") { - if (!hostIpAddress) { - return false; - } - - return ( - - {hostIpAddress} - - ); - } - - return {count} hosts; - }; - - render() { - const { onMoreInfoClick, target } = this.props; - const { display_text: displayText, target_type: targetType } = target; - const { handleSelect, renderTargetDetail } = this; - const wrapperClassName = classnames(`${baseClass}__wrapper`, { - "is-team": targetType === "teams", - "is-label": targetType === "labels", - "is-host": targetType === "hosts", - }); - - return ( -
- -
- ); - } -} - -export default TargetOption; diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tests.jsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tests.tsx similarity index 52% rename from frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tests.jsx rename to frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tests.tsx index 0d508996c6..94b15315a8 100644 --- a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tests.jsx +++ b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tests.tsx @@ -1,7 +1,9 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; +import { noop } from "lodash"; -import { hostStub, labelStub } from "test/stubs"; +import { createMockLabel } from "__mocks__/labelsMock"; +import createMockHost from "__mocks__/hostMock"; import TargetOption from "./TargetOption"; describe("TargetOption - component", () => { @@ -10,27 +12,31 @@ describe("TargetOption - component", () => { return onMoreInfoClickSpy; }; it("renders a label option for label targets", () => { - const count = 5; const { container } = render( ); expect(container.querySelectorAll(".is-label").length).toEqual(1); - expect(screen.getByText(`${count} hosts`)).toBeInTheDocument(); + expect(screen.getByText(`20 hosts`)).toBeInTheDocument(); }); it("renders a host option for host targets", () => { const { container } = render( ); expect(container.querySelectorAll(".is-host").length).toEqual(1); expect(container.querySelectorAll("i.fleeticon-windows").length).toEqual(1); - expect(screen.getByText(hostStub.primary_ip)).toBeInTheDocument(); + expect(screen.getByText(createMockHost().primary_ip)).toBeInTheDocument(); }); it("calls the onSelect prop when + icon button is clicked", () => { @@ -39,18 +45,36 @@ describe("TargetOption - component", () => { ); - fireEvent.click(container.querySelector(".target-option__add-btn")); - expect(onSelectSpy).toHaveBeenCalled(); + + const addButton = container.querySelector(".target-option__add-btn"); + + expect(addButton).toBeInTheDocument(); + + if (addButton) { + fireEvent.click(addButton); + expect(onSelectSpy).toHaveBeenCalled(); + } }); it("calls the onMoreInfoClick prop when the item content is clicked", () => { const { container } = render( - + ); - fireEvent.click(container.querySelector(".target-option__target-content")); - expect(onMoreInfoClickSpy).toHaveBeenCalled(); + + const moreInfo = container.querySelector(".target-option__target-content"); + + expect(moreInfo).toBeInTheDocument(); + + if (moreInfo) { + fireEvent.click(moreInfo); + expect(onMoreInfoClickSpy).toHaveBeenCalled(); + } }); }); diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tsx b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tsx new file mode 100644 index 0000000000..ddc4e83f9e --- /dev/null +++ b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/TargetOption.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import classnames from "classnames"; + +import { ISelectTargetsEntity } from "interfaces/target"; +// @ts-ignore +import TargetIcon from "./TargetIcon"; +import { isTargetHost, isTargetLabel, isTargetTeam } from "../helpers"; + +const baseClass = "target-option"; + +interface ITargetOptionProps { + onMoreInfoClick: ( + target: ISelectTargetsEntity + ) => (event: React.MouseEvent) => void; + onSelect: (target: ISelectTargetsEntity, event: React.MouseEvent) => void; + target: ISelectTargetsEntity; +} + +const TargetOption = ({ + onMoreInfoClick, + onSelect, + target, +}: ITargetOptionProps): JSX.Element => { + const handleSelect = (evt: React.MouseEvent) => { + return onSelect(target, evt); + }; + + const renderTargetDetail = () => { + if (isTargetHost(target)) { + const { primary_ip: hostIpAddress } = target; + + if (!hostIpAddress) { + return null; + } + + return ( + + {hostIpAddress} + + ); + } + + if (isTargetTeam(target) || isTargetLabel(target)) { + return ( + {target.count} hosts + ); + } + + return <>; + }; + + const { display_text: displayText, target_type: targetType } = target; + const wrapperClassName = classnames(`${baseClass}__wrapper`, { + "is-team": targetType === "teams", + "is-label": targetType === "labels", + "is-host": targetType === "hosts", + }); + + return ( +
+ +
+ ); +}; + +export default TargetOption; diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/index.js b/frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/index.ts similarity index 100% rename from frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/index.js rename to frontend/components/forms/fields/SelectTargetsDropdown/TargetOption/index.ts diff --git a/frontend/components/forms/fields/SelectTargetsDropdown/helpers.ts b/frontend/components/forms/fields/SelectTargetsDropdown/helpers.ts new file mode 100644 index 0000000000..9b9207ef1e --- /dev/null +++ b/frontend/components/forms/fields/SelectTargetsDropdown/helpers.ts @@ -0,0 +1,24 @@ +import { + ISelectTargetsEntity, + ISelectHost, + ISelectLabel, + ISelectTeam, +} from "interfaces/target"; + +export const isTargetHost = ( + target: ISelectTargetsEntity +): target is ISelectHost => { + return target.target_type === "hosts"; +}; + +export const isTargetLabel = ( + target: ISelectTargetsEntity +): target is ISelectLabel => { + return target.target_type === "labels"; +}; + +export const isTargetTeam = ( + target: ISelectTargetsEntity +): target is ISelectTeam => { + return target.target_type === "teams"; +}; diff --git a/frontend/components/icons/FleetIcon/FleetIcon.jsx b/frontend/components/icons/FleetIcon/FleetIcon.jsx deleted file mode 100644 index c38763eeb8..0000000000 --- a/frontend/components/icons/FleetIcon/FleetIcon.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import classnames from "classnames"; - -const baseClass = "fleeticon"; - -export class FleetIcon extends Component { - static propTypes = { - className: PropTypes.string, - fw: PropTypes.bool, - name: PropTypes.string, - size: PropTypes.string, - title: PropTypes.string, - }; - - render() { - const { className, fw, name, size, title } = this.props; - const iconClasses = classnames( - baseClass, - `${baseClass}-${name}`, - className, - { - [`${baseClass}-fw`]: fw, - [`${baseClass}-${size}`]: size, - } - ); - - return ; - } -} - -export default FleetIcon; diff --git a/frontend/components/icons/FleetIcon/FleetIcon.tests.jsx b/frontend/components/icons/FleetIcon/FleetIcon.tests.tsx similarity index 100% rename from frontend/components/icons/FleetIcon/FleetIcon.tests.jsx rename to frontend/components/icons/FleetIcon/FleetIcon.tests.tsx diff --git a/frontend/components/icons/FleetIcon/FleetIcon.tsx b/frontend/components/icons/FleetIcon/FleetIcon.tsx new file mode 100644 index 0000000000..e46b37bfba --- /dev/null +++ b/frontend/components/icons/FleetIcon/FleetIcon.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import classnames from "classnames"; + +interface IFleetIconProps { + className?: string; + fw?: boolean; + name: string; + size?: string; + title?: string; +} + +const baseClass = "fleeticon"; + +const FleetIcon = ({ + className, + fw, + name, + size, + title, +}: IFleetIconProps): JSX.Element => { + const iconClasses = classnames(baseClass, `${baseClass}-${name}`, className, { + [`${baseClass}-fw`]: fw, + [`${baseClass}-${size}`]: !!size, + }); + + return ; +}; + +export default FleetIcon; diff --git a/frontend/components/icons/FleetIcon/index.js b/frontend/components/icons/FleetIcon/index.ts similarity index 100% rename from frontend/components/icons/FleetIcon/index.js rename to frontend/components/icons/FleetIcon/index.ts diff --git a/frontend/components/icons/PlatformIcon/PlatformIcon.jsx b/frontend/components/icons/PlatformIcon/PlatformIcon.jsx deleted file mode 100644 index 0fefdd8e58..0000000000 --- a/frontend/components/icons/PlatformIcon/PlatformIcon.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import classnames from "classnames"; - -import FleetIcon from "components/icons/FleetIcon"; -import platformIconClass from "utilities/platform_icon_class"; - -const baseClass = "platform-icon"; - -export class PlatformIcon extends Component { - static propTypes = { - className: PropTypes.string, - fw: PropTypes.bool, - name: PropTypes.string.isRequired, - size: PropTypes.string, - title: PropTypes.string, - }; - - render() { - const { className, name, fw, size, title } = this.props; - const iconClasses = classnames(baseClass, className); - let iconName = platformIconClass(name); - - if (!iconName) { - iconName = "single-host"; - } - - return ( - - ); - } -} - -export default PlatformIcon; diff --git a/frontend/components/icons/PlatformIcon/PlatformIcon.tests.jsx b/frontend/components/icons/PlatformIcon/PlatformIcon.tests.tsx similarity index 100% rename from frontend/components/icons/PlatformIcon/PlatformIcon.tests.jsx rename to frontend/components/icons/PlatformIcon/PlatformIcon.tests.tsx diff --git a/frontend/components/icons/PlatformIcon/PlatformIcon.tsx b/frontend/components/icons/PlatformIcon/PlatformIcon.tsx new file mode 100644 index 0000000000..4077ea4cf8 --- /dev/null +++ b/frontend/components/icons/PlatformIcon/PlatformIcon.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import classnames from "classnames"; + +// @ts-ignore +import FleetIcon from "components/icons/FleetIcon"; +import platformIconClass from "utilities/platform_icon_class"; + +interface IPlatformIconProps { + className?: string; + fw?: boolean; + name: string; + size?: string; + title?: string; +} + +const baseClass = "platform-icon"; + +const PlatformIcon = ({ + className, + name, + fw, + size, + title, +}: IPlatformIconProps): JSX.Element => { + const iconClasses = classnames(baseClass, className); + let iconName = platformIconClass(name); + + if (!iconName) { + iconName = "single-host"; + } + + return ( + + ); +}; + +export default PlatformIcon; diff --git a/frontend/components/icons/PlatformIcon/index.js b/frontend/components/icons/PlatformIcon/index.ts similarity index 100% rename from frontend/components/icons/PlatformIcon/index.js rename to frontend/components/icons/PlatformIcon/index.ts diff --git a/frontend/interfaces/label.ts b/frontend/interfaces/label.ts index bae3e980b4..622cc47301 100644 --- a/frontend/interfaces/label.ts +++ b/frontend/interfaces/label.ts @@ -6,8 +6,8 @@ export default PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.number]), name: PropTypes.string, query: PropTypes.string, - label_type: PropTypes.string, - label_membership_type: PropTypes.string, + label_type: PropTypes.oneOf(["regular", "builtin"]), + label_membership_type: PropTypes.oneOf(["dynamic", "manual"]), hosts_count: PropTypes.number, display_text: PropTypes.string, count: PropTypes.number, // seems to be a repeat of hosts_count issue #1618 diff --git a/frontend/interfaces/target.ts b/frontend/interfaces/target.ts index 06f464cf51..284811e432 100644 --- a/frontend/interfaces/target.ts +++ b/frontend/interfaces/target.ts @@ -30,10 +30,14 @@ export interface ISelectHost extends IHost { export interface ISelectLabel extends ILabelSummary { target_type?: string; + display_text?: string; + query?: string; + count?: number; } export interface ISelectTeam extends ITeam { target_type?: string; + display_text?: string; } export type ISelectTargetsEntity = ISelectHost | ISelectLabel | ISelectTeam; diff --git a/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.tests.ts b/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.tests.ts index c7dd43fef4..5fad9e0da8 100644 --- a/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.tests.ts +++ b/frontend/pages/admin/UserManagementPage/helpers/userManagementHelpers.tests.ts @@ -1,4 +1,5 @@ -import { userStub, userTeamStub } from "test/stubs"; +import { userTeamStub } from "test/stubs"; +import createMockUser from "__mocks__/userMock"; import { IUserUpdateBody } from "interfaces/user"; import { IFormData, NewUserType } from "../components/UserForm/UserForm"; @@ -20,13 +21,13 @@ describe("userManagementHelpers module", () => { const formData: IFormData = { email: "newemail@test.com", sso_enabled: false, - name: "Gnar Mike", + name: "Test User", newUserType: NewUserType.AdminCreated, // TODO revisit test global_role: "admin", teams: [updatedTeam, newTeam], }; const updatedData = userManagementHelpers.generateUpdateData( - userStub, + createMockUser({ role: "Observer", global_role: null }), formData ); diff --git a/frontend/test/stubs.ts b/frontend/test/stubs.ts index dceeb54370..6901ce23cf 100644 --- a/frontend/test/stubs.ts +++ b/frontend/test/stubs.ts @@ -1,203 +1,6 @@ import { IUser } from "interfaces/user"; import { ITeam } from "interfaces/team"; -export const adminUserStub = { - id: 1, - email: "hi@gnar.dog", - force_password_reset: false, - api_only: false, - global_role: "admin", - gravatar_url: "https://image.com", - name: "Gnar Mike", - sso_enabled: false, - teams: [], -}; - -export const configStub = { - org_info: { - org_name: "Fleet", - org_logo_url: "0.0.0.0:8080/logo.png", - }, - server_settings: { - server_url: "", - live_query_disabled: false, - }, - smtp_settings: { - configured: false, - domain: "", - sender_address: "", - server: "", - port: 587, - authentication_type: "authtype_username_password", - user_name: "", - password: "", - enable_ssl_tls: true, - authentication_method: "authmethod_plain", - verify_ssl_certs: true, - enable_start_tls: true, - }, - host_expiry_settings: { - host_expiry_enabled: false, - host_expiry_window: 0, - }, - webhook_settings: { - host_status_webhook: { - enable_host_status_webhook: false, - destination_url: "http://server.com/example", - host_percentage: 5, - days_count: 7, - }, - }, -}; - -export const flatConfigStub = { - org_name: "Fleet", - org_logo_url: "0.0.0.0:8080/logo.png", - server_url: "", - configured: false, - domain: "", - sender_address: "", - server: "", - port: 587, - authentication_type: "authtype_username_password", - user_name: "", - password: "", - enable_ssl_tls: true, - authentication_method: "authmethod_plain", - verify_ssl_certs: true, - enable_start_tls: true, - host_expiry_enabled: false, - host_expiry_window: 0, - live_query_disabled: false, - enable_host_status_webhook: false, - destination_url: "http://server.com/example", - host_percentage: 5, - days_count: 7, -}; - -export const hostStub = { - created_at: "2017-01-10T19:18:55Z", - updated_at: "2017-01-10T20:13:52Z", - id: 1, - detail_updated_at: "2017-01-10T20:01:48Z", - seen_time: "2017-01-10T20:13:54Z", - hostname: "52883a0ba916", - display_name: "52883a0ba916", - uuid: "FD87130B-09A9-683D-9095-D92CD20728CA", - platform: "ubuntu", - osquery_version: "2.1.2", - os_version: "Ubuntu 14.4.", - build: "", - platform_like: "debian", - code_name: "", - uptime: 45469000000000, - memory: 2094940160, - cpu_type: "1 x 2.4Ghz", - cpu_subtype: "78", - cpu_brand: "Intel(R) Core(TM) i5-6267U CPU @ 2.90GHz", - cpu_physical_cores: 2, - cpu_logical_cores: 2, - hardware_vendor: " ", - hardware_model: "BHYVE", - hardware_version: "1.0", - hardware_serial: "None", - computer_name: "52883a0ba916", - primary_ip: "172.19.0.8", - primary_mac: "02:42:ac:13:00:08", - status: "online", - display_text: "52883a0ba916", - target_type: "hosts", -}; - -export const labelStub = { - created_at: "2017-01-16T23:11:01Z", - updated_at: "2017-01-16T23:11:01Z", - id: 1, - name: "All Hosts", - description: "", - query: "select 1;", - platform: "", - label_type: 1, - display_text: "All Hosts", - count: 20, - online: 20, - offline: 0, - missing_in_action: 0, - host_ids: [], - type: "all", - target_type: "labels", -}; - -export const packStub = { - created_at: "0001-01-01T00:00:00Z", - updated_at: "0001-01-01T00:00:00Z", - id: 3, - name: "Pack Name", - description: "Pack Description", - platform: "", - created_by: 1, - disabled: false, - host_ids: [], - label_ids: [], - team_ids: [], -}; - -export const queryStub = { - created_at: "2016-10-17T07:06:00Z", - description: "", - differential: false, - id: 1, - interval: 0, - name: "dev_query_1", - platform: "", - query: "select * from processes", - snapshot: false, - updated_at: "2016-10-17T07:06:00Z", - version: "", - observer_can_run: true, -}; - -export const scheduledQueryStub = { - id: 1, - interval: 60, - name: "Get all users", - query_name: "users", - pack_id: 123, - platform: "darwin", - query: "SELECT * FROM users", - query_id: 5, - removed: false, - shard: 12, - snapshot: true, -}; - -export const globalScheduledQueryStub = { - id: 1, - interval: 60, - name: "Get all users", - query_name: "users", - platform: "darwin", - query: "SELECT * FROM users", - query_id: 5, - removed: false, - shard: 12, - snapshot: true, -}; - -export const teamScheduledQueryStub = { - id: 1, - interval: 60, - name: "Get all users", - query_name: "users", - platform: "darwin", - query: "SELECT * FROM users", - query_id: 5, - removed: false, - shard: 12, - snapshot: true, - team_id: 2, -}; - export const teamStub: ITeam = { description: "This is the test team", host_count: 10, @@ -224,48 +27,6 @@ export const userStub: IUser = { teams: [{ ...userTeamStub }], }; -const queryResultStub = { - description: "root", - directory: "/root", - gid: "0", - gid_signed: "0", - groupname: "root", - host_display_name: hostStub.display_name, -}; - -export const campaignStub = { - hosts: [hostStub, { ...hostStub, id: 100 }], - hosts_count: { - failed: 0, - successful: 2, - total: 2, - }, - Metrics: { - OnlineHosts: 2, - OfflineHosts: 0, - }, - id: 1, - query_id: queryStub.id, - query_results: [queryResultStub], - totals: { - count: 2, - missing_in_action: 0, - offline: 0, - online: 2, - }, -}; - export default { - adminUserStub, - campaignStub, - configStub, - flatConfigStub, - hostStub, - labelStub, - packStub, - queryStub, - scheduledQueryStub, - globalScheduledQueryStub, - teamScheduledQueryStub, userStub, }; From 362a0e545e66ea2a7b07ec2e5a64a18b02a016a2 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Wed, 24 Jul 2024 10:53:33 -0600 Subject: [PATCH 04/11] 18913 ignore rejected NVD vulnerabilities (#20193) #18913 Recreating PR (ref: https://github.com/fleetdm/fleet/pull/19972) --- changes/18913-ignore-rejected-cves | 1 + server/vulnerabilities/nvd/cve_test.go | 4 +++- server/vulnerabilities/nvd/sync/cve_syncer.go | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changes/18913-ignore-rejected-cves diff --git a/changes/18913-ignore-rejected-cves b/changes/18913-ignore-rejected-cves new file mode 100644 index 0000000000..1fabe60f9f --- /dev/null +++ b/changes/18913-ignore-rejected-cves @@ -0,0 +1 @@ +CVEs identified as 'Rejected' in NVD will no longer match against software \ No newline at end of file diff --git a/server/vulnerabilities/nvd/cve_test.go b/server/vulnerabilities/nvd/cve_test.go index a98c9ca239..c69897179d 100644 --- a/server/vulnerabilities/nvd/cve_test.go +++ b/server/vulnerabilities/nvd/cve_test.go @@ -318,7 +318,9 @@ func TestTranslateCPEToCVE(t *testing.T) { {ID: "CVE-2023-42950", resolvedInVersion: "17.2"}, {ID: "CVE-2024-23273", resolvedInVersion: "17.4"}, }, - excludedCVEs: []string{"CVE-2023-28205"}, + excludedCVEs: []string{ + "CVE-2023-28205", // This vulnerability is for Safari 16.4.0 + }, continuesToUpdate: true, }, "cpe:2.3:a:apple:safari:16.4.0:*:*:*:*:macos:*:*": { diff --git a/server/vulnerabilities/nvd/sync/cve_syncer.go b/server/vulnerabilities/nvd/sync/cve_syncer.go index 178429cf65..bad1289a36 100644 --- a/server/vulnerabilities/nvd/sync/cve_syncer.go +++ b/server/vulnerabilities/nvd/sync/cve_syncer.go @@ -199,6 +199,9 @@ func (s *CVE) updateYearFile(year int, cves []nvdapi.CVEItem) error { // Convert new API 2.0 format to legacy feed format and create map of new CVE information. newLegacyCVEs := make(map[string]*schema.NVDCVEFeedJSON10DefCVEItem) for _, cve := range cves { + if cve.CVE.VulnStatus != nil && *cve.CVE.VulnStatus == "Rejected" { + continue + } legacyCVE := convertAPI20CVEToLegacy(cve.CVE, s.logger) newLegacyCVEs[legacyCVE.CVE.CVEDataMeta.ID] = legacyCVE } @@ -249,6 +252,9 @@ func (s *CVE) updateVulnCheckYearFile(year int, cves []VulnCheckCVE, modCount, a // Convert new API 2.0 format to legacy feed format and create map of new CVE information. newLegacyCVEs := make(map[string]*schema.NVDCVEFeedJSON10DefCVEItem) for _, cve := range cves { + if cve.CVE.VulnStatus != nil && *cve.CVE.VulnStatus == "Rejected" { + continue + } legacyCVE := convertAPI20CVEToLegacy(cve.CVE, s.logger) updateWithVulnCheckConfigurations(legacyCVE, cve.VcConfigurations) newLegacyCVEs[legacyCVE.CVE.CVEDataMeta.ID] = legacyCVE From 70d45584488bd5acadb194d01cfa491cd6e7cb7c Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Wed, 24 Jul 2024 14:34:23 -0300 Subject: [PATCH 05/11] Backend support for iOS/iPadOS OS updates (#20649) #20469 and #20471 - [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] Added/updated tests - [X] Manual QA for all new/changed functionality --- changes/20469-backend-ios-ipados-os-updates | 1 + cmd/fleetctl/apply_test.go | 65 ++- cmd/fleetctl/get_test.go | 12 +- cmd/fleetctl/gitops_test.go | 129 ++++- .../expectedGetConfigAppConfigJson.json | 8 + .../expectedGetConfigAppConfigYaml.yml | 6 + ...ectedGetConfigIncludeServerConfigJson.json | 8 + ...pectedGetConfigIncludeServerConfigYaml.yml | 6 + .../testdata/expectedGetTeamsJson.json | 16 + .../testdata/expectedGetTeamsYaml.yml | 12 + .../macosSetupExpectedAppConfigEmpty.yml | 6 + .../macosSetupExpectedAppConfigSet.yml | 6 + .../macosSetupExpectedTeam1And2Empty.yml | 12 + .../macosSetupExpectedTeam1And2Set.yml | 12 + .../testdata/macosSetupExpectedTeam1Empty.yml | 7 +- .../testdata/macosSetupExpectedTeam1Set.yml | 6 + docs/Using Fleet/Audit-logs.md | 42 ++ ee/server/service/mdm.go | 53 ++- ee/server/service/mdm_external_test.go | 2 +- ee/server/service/service.go | 2 +- ee/server/service/teams.go | 93 +++- orbit/pkg/update/nudge_test.go | 4 +- pkg/spec/gitops.go | 2 + pkg/spec/gitops_test.go | 4 + pkg/spec/testdata/controls.yml | 6 + pkg/spec/testdata/global_config_no_paths.yml | 6 + pkg/spec/testdata/team_config_no_paths.yml | 8 +- .../cached_mysql/cached_mysql_test.go | 4 +- server/datastore/mysql/schema.sql | 2 +- server/datastore/mysql/teams_test.go | 22 +- server/fleet/activities.go | 52 ++ server/fleet/app.go | 17 +- server/fleet/app_test.go | 30 +- server/fleet/mdm.go | 8 + server/fleet/nudge.go | 2 +- server/fleet/service.go | 2 +- server/fleet/teams.go | 36 +- server/fleet/teams_test.go | 2 +- server/mdm/mdm.go | 16 +- server/service/appconfig.go | 106 ++++- server/service/appconfig_test.go | 24 +- server/service/apple_mdm.go | 2 +- server/service/client.go | 26 + server/service/integration_enterprise_test.go | 446 ++++++++++++++++-- .../service/integration_mdm_profiles_test.go | 19 +- server/service/integration_mdm_test.go | 11 +- .../generated_files/appconfig.txt | 8 +- .../cloner-check/generated_files/teammdm.txt | 8 +- 48 files changed, 1193 insertions(+), 184 deletions(-) create mode 100644 changes/20469-backend-ios-ipados-os-updates diff --git a/changes/20469-backend-ios-ipados-os-updates b/changes/20469-backend-ios-ipados-os-updates new file mode 100644 index 0000000000..075cca4876 --- /dev/null +++ b/changes/20469-backend-ios-ipados-os-updates @@ -0,0 +1 @@ +* Adding OS updates support to iOS/iPadOS devices. diff --git a/cmd/fleetctl/apply_test.go b/cmd/fleetctl/apply_test.go index 5798e26aa8..3cd8eb03dd 100644 --- a/cmd/fleetctl/apply_test.go +++ b/cmd/fleetctl/apply_test.go @@ -182,8 +182,17 @@ func TestApplyTeamSpecs(t *testing.T) { } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { - require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) - return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + require.Len(t, labels, 1) + switch labels[0] { + case fleet.BuiltinLabelMacOS14Plus: + return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + case fleet.BuiltinLabelIOS: + return map[string]uint{fleet.BuiltinLabelIOS: 2}, nil + case fleet.BuiltinLabelIPadOS: + return map[string]uint{fleet.BuiltinLabelIPadOS: 3}, nil + default: + return nil, ¬FoundError{} + } } ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { @@ -222,7 +231,7 @@ spec: newAgentOpts := json.RawMessage(`{"config":{"views":{"foo":"bar"}}}`) newMDMSettings := fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2011-03-01"), }, @@ -258,7 +267,7 @@ spec: `) require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename})) newMDMSettings = fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2011-03-01"), }, @@ -286,7 +295,7 @@ spec: `, mobileCfgPath)) newMDMSettings = fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2011-03-01"), }, @@ -325,15 +334,29 @@ spec: macos_updates: minimum_version: 10.10.10 deadline: 1992-03-01 + ios_updates: + minimum_version: 11.11.11 + deadline: 1993-04-02 + ipados_updates: + minimum_version: 12.12.12 + deadline: 1994-05-03 secrets: - secret: BBB `) newMDMSettings = fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("11.11.11"), + Deadline: optjson.SetString("1993-04-02"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("12.12.12"), + Deadline: optjson.SetString("1994-05-03"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(5), GracePeriodDays: optjson.SetInt(1), @@ -398,6 +421,12 @@ spec: macos_updates: minimum_version: deadline: + ios_updates: + minimum_version: + deadline: + ipados_updates: + minimum_version: + deadline: windows_updates: deadline_days: grace_period_days: @@ -406,7 +435,15 @@ spec: `) newMDMSettings = fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.String{Set: true}, + Deadline: optjson.String{Set: true}, + }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.String{Set: true}, + Deadline: optjson.String{Set: true}, + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, }, @@ -624,7 +661,7 @@ spec: newMDMSettings := fleet.MDM{ AppleBMDefaultTeam: "team1", AppleBMTermsExpired: false, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), }, @@ -679,7 +716,7 @@ spec: newMDMSettings = fleet.MDM{ AppleBMDefaultTeam: "team1", AppleBMTermsExpired: false, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.1.1"), Deadline: optjson.SetString("2011-02-01"), }, @@ -1315,7 +1352,7 @@ spec: MacOSSetupAssistant: optjson.SetString(emptySetupAsst), EnableReleaseDeviceManually: optjson.SetBool(false), }, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("2020-02-02"), }, @@ -1358,7 +1395,7 @@ spec: BootstrapPackage: optjson.SetString(bootstrapURL), EnableReleaseDeviceManually: optjson.SetBool(false), }, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("2020-02-02"), }, @@ -1409,7 +1446,7 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}}, }, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, @@ -1451,7 +1488,7 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}}, }, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, @@ -1488,7 +1525,7 @@ spec: MacOSSettings: fleet.MacOSSettings{ CustomSettings: []fleet.MDMProfileSpec{{Path: mobileConfigPath}}, }, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 009fbbeb96..06b2be5669 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -166,10 +166,18 @@ func TestGetTeams(t *testing.T) { HostExpiryWindow: 15, }, MDM: fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2021-12-14"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("17.5"), + Deadline: optjson.SetString("2022-11-15"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("18.0"), + Deadline: optjson.SetString("2023-01-01"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(7), GracePeriodDays: optjson.SetInt(3), @@ -2225,7 +2233,7 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { AdditionalQueries: &additionalQueries, }, MDM: fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2021-12-14"), }, diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index d59af1bd5a..d0a3f13946 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -38,7 +38,7 @@ func TestFilenameValidation(t *testing.T) { assert.ErrorContains(t, err, "file name must be less than") } -func TestBasicGlobalGitOps(t *testing.T) { +func TestBasicGlobalFreeGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables _, ds := runServerWithMockedDS(t) @@ -150,6 +150,106 @@ org_settings: assert.Empty(t, enrolledSecrets) } +func TestBasicGlobalPremiumGitOps(t *testing.T) { + // Cannot run t.Parallel() because it sets environment variables + + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + _, ds := runServerWithMockedDS( + t, &service.TestServerOpts{ + License: license, + }, + ) + + ds.BatchSetMDMProfilesFunc = func( + ctx context.Context, tmID *uint, macProfiles []*fleet.MDMAppleConfigProfile, winProfiles []*fleet.MDMWindowsConfigProfile, + macDecls []*fleet.MDMAppleDeclaration, + ) error { + return nil + } + ds.BulkSetPendingMDMHostProfilesFunc = func( + ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string, + ) error { + return nil + } + ds.BatchSetScriptsFunc = func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error { return nil } + ds.NewActivityFunc = func( + ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, + ) error { + return nil + } + ds.ListGlobalPoliciesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Policy, error) { return nil, nil } + ds.ListQueriesFunc = func(ctx context.Context, opts fleet.ListQueryOptions) ([]*fleet.Query, error) { return nil, nil } + + // Mock appConfig + savedAppConfig := &fleet.AppConfig{} + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + ds.SaveAppConfigFunc = func(ctx context.Context, config *fleet.AppConfig) error { + savedAppConfig = config + return nil + } + var enrolledSecrets []*fleet.EnrollSecret + ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { + enrolledSecrets = secrets + return nil + } + ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { + return map[string]uint{labels[0]: 1}, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + return &fleet.MDMAppleDeclaration{}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return &fleet.Job{}, nil + } + + tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + + const ( + fleetServerURL = "https://fleet.example.com" + orgName = "GitOps Premium Test" + ) + t.Setenv("FLEET_SERVER_URL", fleetServerURL) + + _, err = tmpFile.WriteString( + ` +controls: + ios_updates: + deadline: "2022-02-02" + minimum_version: "17.6" + ipados_updates: + deadline: "2023-03-03" + minimum_version: "18.0" +queries: +policies: +agent_options: +org_settings: + server_settings: + server_url: $FLEET_SERVER_URL + org_info: + contact_url: https://example.com/contact + org_logo_url: "" + org_logo_url_light_background: "" + org_name: ${ORG_NAME} + secrets: +`, + ) + require.NoError(t, err) + + // Dry run + t.Setenv("ORG_NAME", orgName) + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name(), "--dry-run"}) + assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty") + + // Real run + _ = runAppForTest(t, []string{"gitops", "-f", tmpFile.Name()}) + assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName) + assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL) + assert.Empty(t, enrolledSecrets) +} + func TestBasicTeamGitOps(t *testing.T) { // Cannot run t.Parallel() because it sets environment variables license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} @@ -222,8 +322,17 @@ func TestBasicTeamGitOps(t *testing.T) { return team, nil } ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) { - require.ElementsMatch(t, labels, []string{fleet.BuiltinLabelMacOS14Plus}) - return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + require.Len(t, labels, 1) + switch labels[0] { + case fleet.BuiltinLabelMacOS14Plus: + return map[string]uint{fleet.BuiltinLabelMacOS14Plus: 1}, nil + case fleet.BuiltinLabelIOS: + return map[string]uint{fleet.BuiltinLabelIOS: 2}, nil + case fleet.BuiltinLabelIPadOS: + return map[string]uint{fleet.BuiltinLabelIPadOS: 3}, nil + default: + return nil, ¬FoundError{} + } } ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { return nil @@ -231,11 +340,16 @@ func TestBasicTeamGitOps(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } - ds.ApplyEnrollSecretsFunc = func(ctx context.Context, teamID *uint, secrets []*fleet.EnrollSecret) error { enrolledTeamSecrets = secrets return nil } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, declaration *fleet.MDMAppleDeclaration) (*fleet.MDMAppleDeclaration, error) { + return &fleet.MDMAppleDeclaration{}, nil + } + ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) { + return &fleet.Job{}, nil + } tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") require.NoError(t, err) @@ -245,6 +359,12 @@ func TestBasicTeamGitOps(t *testing.T) { _, err = tmpFile.WriteString( ` controls: + ios_updates: + deadline: "2024-10-10" + minimum_version: "18.0" + ipados_updates: + deadline: "2025-11-11" + minimum_version: "17.6" queries: policies: agent_options: @@ -726,7 +846,6 @@ team_settings: assert.Empty(t, savedTeam.Config.MDM.MacOSSetup.BootstrapPackage.Value) assert.False(t, savedTeam.Config.MDM.EnableDiskEncryption) assert.Equal(t, filepath.Base(tmpFile.Name()), *savedTeam.Filename) - } func TestBasicGlobalAndTeamGitOps(t *testing.T) { diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json index c6624c7110..399e880a93 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -103,6 +103,14 @@ "minimum_version": null, "deadline": null }, + "ios_updates": { + "minimum_version": null, + "deadline": null + }, + "ipados_updates": { + "minimum_version": null, + "deadline": null + }, "windows_updates": { "deadline_days": 7, "grace_period_days": 3 diff --git a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index 92254b6052..b76d40bd52 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -31,6 +31,12 @@ spec: macos_updates: minimum_version: null deadline: null + ios_updates: + minimum_version: null + deadline: null + ipados_updates: + minimum_version: null + deadline: null windows_updates: deadline_days: 7 grace_period_days: 3 diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 18d980b320..76a89e6493 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -56,6 +56,14 @@ "minimum_version": null, "deadline": null }, + "ios_updates": { + "minimum_version": null, + "deadline": null + }, + "ipados_updates": { + "minimum_version": null, + "deadline": null + }, "windows_updates": { "deadline_days": 7, "grace_period_days": 3 diff --git a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 3138a7d349..f6ca79a4eb 100644 --- a/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -31,6 +31,12 @@ spec: macos_updates: minimum_version: null deadline: null + ios_updates: + minimum_version: null + deadline: null + ipados_updates: + minimum_version: null + deadline: null windows_updates: deadline_days: 7 grace_period_days: 3 diff --git a/cmd/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/testdata/expectedGetTeamsJson.json index 1af52bee61..05e71c9db0 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/testdata/expectedGetTeamsJson.json @@ -35,6 +35,14 @@ "minimum_version": null, "deadline": null }, + "ios_updates": { + "minimum_version": null, + "deadline": null + }, + "ipados_updates": { + "minimum_version": null, + "deadline": null + }, "windows_updates": { "deadline_days": null, "grace_period_days": null @@ -111,6 +119,14 @@ "minimum_version": "12.3.1", "deadline": "2021-12-14" }, + "ios_updates": { + "minimum_version": "17.5", + "deadline": "2022-11-15" + }, + "ipados_updates": { + "minimum_version": "18.0", + "deadline": "2023-01-01" + }, "windows_updates": { "deadline_days": 7, "grace_period_days": 3 diff --git a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml index f10577a3af..fd1b7a5119 100644 --- a/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -16,6 +16,12 @@ spec: macos_updates: minimum_version: null deadline: null + ios_updates: + minimum_version: null + deadline: null + ipados_updates: + minimum_version: null + deadline: null windows_updates: deadline_days: null grace_period_days: null @@ -61,6 +67,12 @@ spec: macos_updates: minimum_version: "12.3.1" deadline: "2021-12-14" + ios_updates: + minimum_version: "17.5" + deadline: "2022-11-15" + macos_updates: + minimum_version: "18.0" + deadline: "2023-01-01" windows_updates: deadline_days: 7 grace_period_days: 3 diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 67bb96e8c3..fc886f7658 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -40,6 +40,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index d73894e4a1..5a57cc2500 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -40,6 +40,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index b5a4c03e5c..3e8cfb0f1a 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -25,6 +25,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null @@ -60,6 +66,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index a0d15fddd7..598261b4b8 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -25,6 +25,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null @@ -60,6 +66,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index 8a6762468c..7f450ef682 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -23,6 +23,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null @@ -34,4 +40,3 @@ spec: webhook_settings: host_status_webhook: null name: tm1 - diff --git a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index 2aac4b1481..5dfe7dae3b 100644 --- a/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -24,6 +24,12 @@ spec: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index ab16e6970d..d72ce60198 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -582,6 +582,48 @@ This activity contains the following fields: } ``` +## edited_ios_min_version + +Generated when the minimum required iOS version or deadline is modified. + +This activity contains the following fields: +- "team_id": The ID of the team that the minimum iOS version applies to, `null` if it applies to devices that are not in a team. +- "team_name": The name of the team that the minimum iOS version applies to, `null` if it applies to devices that are not in a team. +- "minimum_version": The minimum iOS version required, empty if the requirement was removed. +- "deadline": The deadline by which the minimum version requirement must be applied, empty if the requirement was removed. + +#### Example + +```json +{ + "team_id": 3, + "team_name": "iPhones", + "minimum_version": "17.5.1", + "deadline": "2023-06-01" +} +``` + +## edited_ipados_min_version + +Generated when the minimum required iPadOS version or deadline is modified. + +This activity contains the following fields: +- "team_id": The ID of the team that the minimum iPadOS version applies to, `null` if it applies to devices that are not in a team. +- "team_name": The name of the team that the minimum iPadOS version applies to, `null` if it applies to devices that are not in a team. +- "minimum_version": The minimum iPadOS version required, empty if the requirement was removed. +- "deadline": The deadline by which the minimum version requirement must be applied, empty if the requirement was removed. + +#### Example + +```json +{ + "team_id": 3, + "team_name": "iPads", + "minimum_version": "17.5.1", + "deadline": "2023-06-01" +} +``` + ## edited_windows_updates Generated when the Windows OS updates deadline or grace period is modified. diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index d433f5d246..cfed0983d1 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -769,7 +769,6 @@ func (svc *Service) mdmSSOHandleCallbackAuth(ctx context.Context, auth fleet.Aut appConfig.ServerSettings.ServerURL, appConfig.ServerSettings.ServerURL+svc.config.Server.URLPrefix+"/api/v1/fleet/mdm/sso/callback", ) - if err != nil { return "", "", "", ctxerr.Wrap(ctx, err, "validating sso response") } @@ -1081,28 +1080,53 @@ func (svc *Service) GetMDMDiskEncryptionSummary(ctx context.Context, teamID *uin }, nil } -func (svc *Service) mdmAppleEditedMacOSUpdates(ctx context.Context, teamID *uint, updates fleet.MacOSUpdates) error { +func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *uint, appleDevice fleet.AppleDevice, updates fleet.AppleOSUpdateSettings) error { + const ( + softwareUpdateType = `com.apple.configuration.softwareupdate.enforcement.specific` + softwareUpdateIdentSuffix = `-software-update-94f4bbdf-f439-4fb1-8d27-ae1bb793e105` + ) + var ( + softwareUpdateIdentifier string + osUpdatesProfileName string + labelName string + ) + switch appleDevice { + case fleet.MacOS: + softwareUpdateIdentifier = "macos" + softwareUpdateIdentSuffix + osUpdatesProfileName = mdm.FleetMacOSUpdatesProfileName + labelName = fleet.BuiltinLabelMacOS14Plus // OS update DDMs are supported on macOS 14+ devices. + case fleet.IOS: + softwareUpdateIdentifier = "ios" + softwareUpdateIdentSuffix + osUpdatesProfileName = mdm.FleetIOSUpdatesProfileName + labelName = fleet.BuiltinLabelIOS + case fleet.IPadOS: + softwareUpdateIdentifier = "ipados" + softwareUpdateIdentSuffix + osUpdatesProfileName = mdm.FleetIPadOSUpdatesProfileName + labelName = fleet.BuiltinLabelIPadOS + default: + panic(fmt.Sprintf("invalid AppleDevice: %d", appleDevice)) + } + if updates.MinimumVersion.Value == "" { // OS updates disabled, remove the profile - if err := svc.ds.DeleteMDMAppleDeclarationByName(ctx, teamID, mdm.FleetMacOSUpdatesProfileName); err != nil { + if err := svc.ds.DeleteMDMAppleDeclarationByName(ctx, teamID, osUpdatesProfileName); err != nil { return err } var globalOrTeamID uint if teamID != nil { globalOrTeamID = *teamID } + // This only sets profiles that haven't been queued by the cron to 'pending' (both removes and installs, which includes + // the OS updates we just deleted). It doesn't have a functional difference because if you don't call this function + // the cron will catch up, but it's important for the UX to mark them as pending immediately so it's reflected in the UI. if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{globalOrTeamID}, nil, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host profiles") } return nil } - // OS updates enabled, create or update the profile with the current settings + // OS updates enabled, create or update the profile with the current settings. - const ( - macOSSoftwareUpdateType = `com.apple.configuration.softwareupdate.enforcement.specific` - macOSSoftwareUpdateIdent = `macos-software-update-94f4bbdf-f439-4fb1-8d27-ae1bb793e105` - ) rawDecl := []byte(fmt.Sprintf(`{ "Identifier": %q, "Type": %q, @@ -1110,17 +1134,17 @@ func (svc *Service) mdmAppleEditedMacOSUpdates(ctx context.Context, teamID *uint "TargetOSVersion": %q, "TargetLocalDateTime": "%sT12:00:00" } -}`, macOSSoftwareUpdateIdent, macOSSoftwareUpdateType, updates.MinimumVersion.Value, updates.Deadline.Value)) - d := fleet.NewMDMAppleDeclaration(rawDecl, teamID, mdm.FleetMacOSUpdatesProfileName, macOSSoftwareUpdateType, macOSSoftwareUpdateIdent) +}`, softwareUpdateIdentifier, softwareUpdateType, updates.MinimumVersion.Value, updates.Deadline.Value)) - // associate the profile with the built-in label that ensures the host is on - // macOS 14+ to receive that profile - lblIDs, err := svc.ds.LabelIDsByName(ctx, []string{fleet.BuiltinLabelMacOS14Plus}) + d := fleet.NewMDMAppleDeclaration(rawDecl, teamID, osUpdatesProfileName, softwareUpdateType, softwareUpdateIdentifier) + + // Associate the profile with the built-in label to ensure that the profile is applied to the targeted devices. + lblIDs, err := svc.ds.LabelIDsByName(ctx, []string{labelName}) if err != nil { return err } d.LabelsIncludeAll = []fleet.ConfigurationProfileLabel{ - {LabelName: fleet.BuiltinLabelMacOS14Plus, LabelID: lblIDs[fleet.BuiltinLabelMacOS14Plus]}, + {LabelName: labelName, LabelID: lblIDs[labelName]}, } decl, err := svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d) @@ -1128,7 +1152,6 @@ func (svc *Service) mdmAppleEditedMacOSUpdates(ctx context.Context, teamID *uint return err } - // mark all hosts affected by that profile as pending if err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil { return ctxerr.Wrap(ctx, err, "bulk set pending host declarations") } diff --git a/ee/server/service/mdm_external_test.go b/ee/server/service/mdm_external_test.go index 9c3c3fbeb0..06e244622e 100644 --- a/ee/server/service/mdm_external_test.go +++ b/ee/server/service/mdm_external_test.go @@ -532,7 +532,7 @@ func TestGetOrCreatePreassignTeam(t *testing.T) { spec := &fleet.TeamSpec{ Name: "new team spec", MDM: fleet.TeamSpecMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.0"), Deadline: optjson.SetString("2024-01-01"), }, diff --git a/ee/server/service/service.go b/ee/server/service/service.go index 3a1ac66a16..34b35bd3bc 100644 --- a/ee/server/service/service.go +++ b/ee/server/service/service.go @@ -79,7 +79,7 @@ func NewService( DeleteMDMAppleBootstrapPackage: eeservice.DeleteMDMAppleBootstrapPackage, MDMWindowsEnableOSUpdates: eeservice.mdmWindowsEnableOSUpdates, MDMWindowsDisableOSUpdates: eeservice.mdmWindowsDisableOSUpdates, - MDMAppleEditedMacOSUpdates: eeservice.mdmAppleEditedMacOSUpdates, + MDMAppleEditedAppleOSUpdates: eeservice.mdmAppleEditedAppleOSUpdates, }) return eeservice, nil diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index ebdb716a07..302af0a013 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -144,7 +144,14 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T return nil, err } - var macOSMinVersionUpdated, windowsUpdatesUpdated, macOSDiskEncryptionUpdated, macOSEnableEndUserAuthUpdated bool + var ( + macOSMinVersionUpdated bool + iOSMinVersionUpdated bool + iPadOSMinVersionUpdated bool + windowsUpdatesUpdated bool + macOSDiskEncryptionUpdated bool + macOSEnableEndUserAuthUpdated bool + ) if payload.MDM != nil { if payload.MDM.MacOSUpdates != nil { if err := payload.MDM.MacOSUpdates.Validate(); err != nil { @@ -156,6 +163,26 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T team.Config.MDM.MacOSUpdates = *payload.MDM.MacOSUpdates } } + if payload.MDM.IOSUpdates != nil { + if err := payload.MDM.IOSUpdates.Validate(); err != nil { + return nil, fleet.NewInvalidArgumentError("ios_updates", err.Error()) + } + if payload.MDM.IOSUpdates.MinimumVersion.Set || payload.MDM.IOSUpdates.Deadline.Set { + iOSMinVersionUpdated = team.Config.MDM.IOSUpdates.MinimumVersion.Value != payload.MDM.IOSUpdates.MinimumVersion.Value || + team.Config.MDM.IOSUpdates.Deadline.Value != payload.MDM.IOSUpdates.Deadline.Value + team.Config.MDM.IOSUpdates = *payload.MDM.IOSUpdates + } + } + if payload.MDM.IPadOSUpdates != nil { + if err := payload.MDM.IPadOSUpdates.Validate(); err != nil { + return nil, fleet.NewInvalidArgumentError("ipados_updates", err.Error()) + } + if payload.MDM.IPadOSUpdates.MinimumVersion.Set || payload.MDM.IPadOSUpdates.Deadline.Set { + iPadOSMinVersionUpdated = team.Config.MDM.IPadOSUpdates.MinimumVersion.Value != payload.MDM.IPadOSUpdates.MinimumVersion.Value || + team.Config.MDM.IPadOSUpdates.Deadline.Value != payload.MDM.IPadOSUpdates.Deadline.Value + team.Config.MDM.IPadOSUpdates = *payload.MDM.IPadOSUpdates + } + } if payload.MDM.WindowsUpdates != nil { if err := payload.MDM.WindowsUpdates.Validate(); err != nil { @@ -249,8 +276,9 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T if err != nil { return nil, err } + if macOSMinVersionUpdated { - if err := svc.mdmAppleEditedMacOSUpdates(ctx, &team.ID, team.Config.MDM.MacOSUpdates); err != nil { + if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.MacOS, team.Config.MDM.MacOSUpdates); err != nil { return nil, ctxerr.Wrap(ctx, err, "update DDM profile on macOS updates change") } @@ -264,9 +292,46 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T Deadline: team.Config.MDM.MacOSUpdates.Deadline.Value, }, ); err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for team macos min version edited") + return nil, ctxerr.Wrap(ctx, err, "create activity for team macOS min version edited") } } + if iOSMinVersionUpdated { + if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IOS, team.Config.MDM.IOSUpdates); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update DDM profile on iOS updates change") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedIOSMinVersion{ + TeamID: &team.ID, + TeamName: &team.Name, + MinimumVersion: team.Config.MDM.IOSUpdates.MinimumVersion.Value, + Deadline: team.Config.MDM.IOSUpdates.Deadline.Value, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for team iOS min version edited") + } + } + if iPadOSMinVersionUpdated { + if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IPadOS, team.Config.MDM.IPadOSUpdates); err != nil { + return nil, ctxerr.Wrap(ctx, err, "update DDM profile on iPadOS updates change") + } + + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedIPadOSMinVersion{ + TeamID: &team.ID, + TeamName: &team.Name, + MinimumVersion: team.Config.MDM.IPadOSUpdates.MinimumVersion.Value, + Deadline: team.Config.MDM.IPadOSUpdates.Deadline.Value, + }, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "create activity for team iPadOS min version edited") + } + } + if windowsUpdatesUpdated { var deadline, grace *int if team.Config.MDM.WindowsUpdates.DeadlineDays.Valid { @@ -1078,6 +1143,16 @@ func (svc *Service) editTeamFromSpec( team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates mdmMacOSUpdatesEdited = true } + var mdmIOSUpdatesEdited bool + if spec.MDM.IOSUpdates.Deadline.Set || spec.MDM.IOSUpdates.MinimumVersion.Set { + team.Config.MDM.IOSUpdates = spec.MDM.IOSUpdates + mdmIOSUpdatesEdited = true + } + var mdmIPadOSUpdatesEdited bool + if spec.MDM.IPadOSUpdates.Deadline.Set || spec.MDM.IPadOSUpdates.MinimumVersion.Set { + team.Config.MDM.IPadOSUpdates = spec.MDM.IPadOSUpdates + mdmIPadOSUpdatesEdited = true + } if spec.MDM.WindowsUpdates.DeadlineDays.Set || spec.MDM.WindowsUpdates.GracePeriodDays.Set { team.Config.MDM.WindowsUpdates = spec.MDM.WindowsUpdates } @@ -1281,7 +1356,17 @@ func (svc *Service) editTeamFromSpec( } if mdmMacOSUpdatesEdited { - if err := svc.mdmAppleEditedMacOSUpdates(ctx, &team.ID, team.Config.MDM.MacOSUpdates); err != nil { + if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.MacOS, team.Config.MDM.MacOSUpdates); err != nil { + return err + } + } + if mdmIOSUpdatesEdited { + if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IOS, team.Config.MDM.IOSUpdates); err != nil { + return err + } + } + if mdmIPadOSUpdatesEdited { + if err := svc.mdmAppleEditedAppleOSUpdates(ctx, &team.ID, fleet.IPadOS, team.Config.MDM.IPadOSUpdates); err != nil { return err } } diff --git a/orbit/pkg/update/nudge_test.go b/orbit/pkg/update/nudge_test.go index 05d02686c1..987e9dd44a 100644 --- a/orbit/pkg/update/nudge_test.go +++ b/orbit/pkg/update/nudge_test.go @@ -33,7 +33,7 @@ func (s *nudgeTestSuite) TestUpdatesDisabled() { t := s.T() var err error cfg := &fleet.OrbitConfig{} - cfg.NudgeConfig, err = fleet.NewNudgeConfig(fleet.MacOSUpdates{MinimumVersion: optjson.SetString("11"), Deadline: optjson.SetString("2022-01-04")}) + cfg.NudgeConfig, err = fleet.NewNudgeConfig(fleet.AppleOSUpdateSettings{MinimumVersion: optjson.SetString("11"), Deadline: optjson.SetString("2022-01-04")}) require.NoError(t, err) runNudgeFn := func(execPath, configPath string) error { return nil @@ -96,7 +96,7 @@ func (s *nudgeTestSuite) TestNudgeConfigFetcherAddNudge() { require.Len(t, targets, 0) // set the config - cfg.NudgeConfig, err = fleet.NewNudgeConfig(fleet.MacOSUpdates{MinimumVersion: optjson.SetString("11"), Deadline: optjson.SetString("2022-01-04")}) + cfg.NudgeConfig, err = fleet.NewNudgeConfig(fleet.AppleOSUpdateSettings{MinimumVersion: optjson.SetString("11"), Deadline: optjson.SetString("2022-01-04")}) require.NoError(t, err) // there's an error when the remote repo doesn't have the target yet diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index a416c33787..f0865c5b89 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -22,6 +22,8 @@ type BaseItem struct { type Controls struct { BaseItem MacOSUpdates interface{} `json:"macos_updates"` + IOSUpdates interface{} `json:"ios_updates"` + IPadOSUpdates interface{} `json:"ipados_updates"` MacOSSettings interface{} `json:"macos_settings"` MacOSSetup interface{} `json:"macos_setup"` MacOSMigration interface{} `json:"macos_migration"` diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 8d9b00bffc..644c3e453e 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -181,6 +181,10 @@ func TestValidGitOpsYaml(t *testing.T) { assert.True(t, ok, "macos_setup not found") _, ok = gitops.Controls.MacOSUpdates.(map[string]interface{}) assert.True(t, ok, "macos_updates not found") + _, ok = gitops.Controls.IOSUpdates.(map[string]interface{}) + assert.True(t, ok, "ios_updates not found") + _, ok = gitops.Controls.IPadOSUpdates.(map[string]interface{}) + assert.True(t, ok, "ipados_updates not found") _, ok = gitops.Controls.WindowsEnabledAndConfigured.(bool) assert.True(t, ok, "windows_enabled_and_configured not found") _, ok = gitops.Controls.WindowsUpdates.(map[string]interface{}) diff --git a/pkg/spec/testdata/controls.yml b/pkg/spec/testdata/controls.yml index 2adff74031..5da3567921 100644 --- a/pkg/spec/testdata/controls.yml +++ b/pkg/spec/testdata/controls.yml @@ -18,6 +18,12 @@ macos_setup: macos_updates: deadline: null minimum_version: null +ios_updates: + deadline: null + minimum_version: null +ipados_updates: + deadline: null + minimum_version: null windows_enabled_and_configured: true windows_updates: deadline_days: null diff --git a/pkg/spec/testdata/global_config_no_paths.yml b/pkg/spec/testdata/global_config_no_paths.yml index 7fabc5119a..c8d68f9462 100644 --- a/pkg/spec/testdata/global_config_no_paths.yml +++ b/pkg/spec/testdata/global_config_no_paths.yml @@ -20,6 +20,12 @@ controls: # Controls added to "No team" macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_enabled_and_configured: true windows_updates: deadline_days: null diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml index 207317e640..44c4ff36b0 100644 --- a/pkg/spec/testdata/team_config_no_paths.yml +++ b/pkg/spec/testdata/team_config_no_paths.yml @@ -7,7 +7,7 @@ team_settings: failing_policies_webhook: enable_failing_policies_webhook: true destination_url: https://example.tines.com/webhook - policy_ids: [1, 2, 3, 4, 5, 6 ,7, 8, 9] + policy_ids: [1, 2, 3, 4, 5, 6, 7, 8, 9] features: enable_host_users: true enable_software_inventory: true @@ -46,6 +46,12 @@ controls: macos_updates: deadline: null minimum_version: null + ios_updates: + deadline: null + minimum_version: null + ipados_updates: + deadline: null + minimum_version: null windows_updates: deadline_days: null grace_period_days: null diff --git a/server/datastore/cached_mysql/cached_mysql_test.go b/server/datastore/cached_mysql/cached_mysql_test.go index ef628f9650..004f3360bf 100644 --- a/server/datastore/cached_mysql/cached_mysql_test.go +++ b/server/datastore/cached_mysql/cached_mysql_test.go @@ -559,7 +559,7 @@ func TestCachedTeamMDMConfig(t *testing.T) { testMDMConfig := fleet.TeamMDM{ EnableDiskEncryption: true, - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.10.10"), Deadline: optjson.SetString("1992-03-01"), }, @@ -618,7 +618,7 @@ func TestCachedTeamMDMConfig(t *testing.T) { // saving a team updates config in cache updateMDMConfig := fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2022-03-01"), }, diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 46a7ea3192..7438586de7 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -41,7 +41,7 @@ CREATE TABLE `app_config_json` ( UNIQUE KEY `id` (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_setup\": {\"bootstrap_package\": null, \"macos_setup_assistant\": null, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null}, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"windows_settings\": {\"custom_settings\": null}, \"apple_bm_default_team\": \"\", \"apple_bm_terms_expired\": false, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false}, \"scripts\": null, \"features\": {\"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `calendar_events` ( diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 19c452081a..175d49f8ee 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -591,10 +591,18 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { Name: "team1", Config: fleet.TeamConfig{ MDM: fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2025-10-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("11.11.11"), + Deadline: optjson.SetString("2024-04-04"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("12.12.12"), + Deadline: optjson.SetString("2023-03-03"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(7), GracePeriodDays: optjson.SetInt(3), @@ -614,10 +622,18 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { require.NoError(t, err) assert.Equal(t, &fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2025-10-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("11.11.11"), + Deadline: optjson.SetString("2024-04-04"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("12.12.12"), + Deadline: optjson.SetString("2023-03-03"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(7), GracePeriodDays: optjson.SetInt(3), @@ -695,7 +711,6 @@ func testTeamsNameEmoji(t *testing.T, ds *Datastore) { assert.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, emoji1, results[0].Name) - } // Ensure case-insensitive sort order for ames @@ -717,5 +732,4 @@ func testTeamsNameSort(t *testing.T, ds *Datastore) { for i, item := range teams { assert.Equal(t, item.Name, results[i].Name) } - } diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 56d312faac..8b0a814fbd 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -54,6 +54,8 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeMDMUnenrolled{}, ActivityTypeEditedMacOSMinVersion{}, + ActivityTypeEditedIOSMinVersion{}, + ActivityTypeEditedIPadOSMinVersion{}, ActivityTypeEditedWindowsUpdates{}, ActivityTypeReadHostDiskEncryptionKey{}, @@ -832,6 +834,56 @@ func (a ActivityTypeEditedWindowsUpdates) Documentation() (activity string, deta }` } +type ActivityTypeEditedIOSMinVersion struct { + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` + MinimumVersion string `json:"minimum_version"` + Deadline string `json:"deadline"` +} + +func (a ActivityTypeEditedIOSMinVersion) ActivityName() string { + return "edited_ios_min_version" +} + +func (a ActivityTypeEditedIOSMinVersion) Documentation() (activity string, details string, detailsExample string) { + return `Generated when the minimum required iOS version or deadline is modified.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the minimum iOS version applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "team_name": The name of the team that the minimum iOS version applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "minimum_version": The minimum iOS version required, empty if the requirement was removed. +- "deadline": The deadline by which the minimum version requirement must be applied, empty if the requirement was removed.`, `{ + "team_id": 3, + "team_name": "iPhones", + "minimum_version": "17.5.1", + "deadline": "2023-06-01" +}` +} + +type ActivityTypeEditedIPadOSMinVersion struct { + TeamID *uint `json:"team_id"` + TeamName *string `json:"team_name"` + MinimumVersion string `json:"minimum_version"` + Deadline string `json:"deadline"` +} + +func (a ActivityTypeEditedIPadOSMinVersion) ActivityName() string { + return "edited_ipados_min_version" +} + +func (a ActivityTypeEditedIPadOSMinVersion) Documentation() (activity string, details string, detailsExample string) { + return `Generated when the minimum required iPadOS version or deadline is modified.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the minimum iPadOS version applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "team_name": The name of the team that the minimum iPadOS version applies to, ` + "`null`" + ` if it applies to devices that are not in a team. +- "minimum_version": The minimum iPadOS version required, empty if the requirement was removed. +- "deadline": The deadline by which the minimum version requirement must be applied, empty if the requirement was removed.`, `{ + "team_id": 3, + "team_name": "iPads", + "minimum_version": "17.5.1", + "deadline": "2023-06-01" +}` +} + type ActivityTypeReadHostDiskEncryptionKey struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` diff --git a/server/fleet/app.go b/server/fleet/app.go index f0755add59..b9227dd8bb 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -148,7 +148,13 @@ type MDM struct { // backend, should be done only after careful analysis. EnabledAndConfigured bool `json:"enabled_and_configured"` - MacOSUpdates MacOSUpdates `json:"macos_updates"` + // MacOSUpdates defines the OS update settings for macOS devices. + MacOSUpdates AppleOSUpdateSettings `json:"macos_updates"` + // IOSUpdates defines the OS update settings for iOS devices. + IOSUpdates AppleOSUpdateSettings `json:"ios_updates"` + // IPadOSUpdates defines the OS update settings for iPadOS devices. + IPadOSUpdates AppleOSUpdateSettings `json:"ipados_updates"` + // WindowsUpdates defines the OS update settings for Windows devices. WindowsUpdates WindowsUpdates `json:"windows_updates"` MacOSSettings MacOSSettings `json:"macos_settings"` @@ -182,8 +188,9 @@ func (m MDM) AtLeastOnePlatformEnabledAndConfigured() bool { // format only (no prerelease or build metadata). var versionStringRegex = regexp.MustCompile(`^\d+(\.\d+)?(\.\d+)?$`) -// MacOSUpdates is part of AppConfig and defines the macOS update settings. -type MacOSUpdates struct { +// AppleOSUpdateSettings is the common type that contains the settings +// for OS updates on Apple devices. +type AppleOSUpdateSettings struct { // MinimumVersion is the required minimum operating system version. MinimumVersion optjson.String `json:"minimum_version"` // Deadline the required installation date for Nudge to enforce the required @@ -192,12 +199,12 @@ type MacOSUpdates struct { } // Configured returns a boolean indicating if updates are configured -func (m MacOSUpdates) Configured() bool { +func (m AppleOSUpdateSettings) Configured() bool { return m.Deadline.Value != "" && m.MinimumVersion.Value != "" } -func (m MacOSUpdates) Validate() error { +func (m AppleOSUpdateSettings) Validate() error { // if no settings are provided it's okay to skip further validation if m.MinimumVersion.Value == "" && m.Deadline.Value == "" { // if one is set and empty, the other must be set and empty too, otherwise diff --git a/server/fleet/app_test.go b/server/fleet/app_test.go index 46c93c0982..bec5c458e5 100644 --- a/server/fleet/app_test.go +++ b/server/fleet/app_test.go @@ -13,26 +13,26 @@ func TestMacOSUpdatesValidate(t *testing.T) { t.Run("valid", func(t *testing.T) { cases := []struct { name string - m MacOSUpdates + m AppleOSUpdateSettings }{ - {"empty", MacOSUpdates{}}, + {"empty", AppleOSUpdateSettings{}}, { "with full version", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2020-01-01"), }, }, { "without patch version", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15"), Deadline: optjson.SetString("2020-01-01"), }, }, { "only major version", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10"), Deadline: optjson.SetString("2020-01-01"), }, @@ -49,25 +49,25 @@ func TestMacOSUpdatesValidate(t *testing.T) { t.Run("invalid deadline", func(t *testing.T) { cases := []struct { name string - m MacOSUpdates + m AppleOSUpdateSettings }{ { "version but no deadline", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString(""), }, }, { "deadline with timestamp", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2020-01-01T00:00:00Z"), }, }, { "incomplete date", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2020-01"), }, @@ -84,25 +84,25 @@ func TestMacOSUpdatesValidate(t *testing.T) { t.Run("invalid version", func(t *testing.T) { cases := []struct { name string - m MacOSUpdates + m AppleOSUpdateSettings }{ { "deadline but no version", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString(""), Deadline: optjson.SetString("2020-01-01"), }, }, { "version with build info", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0 (19A583)"), Deadline: optjson.SetString("2020-01-01"), }, }, { "version with patch info", - MacOSUpdates{ + AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0-patch1"), Deadline: optjson.SetString("2020-01-01"), }, @@ -185,7 +185,7 @@ func TestMacOSUpdatesConfigured(t *testing.T) { } for _, tc := range cases { - m := MacOSUpdates{ + m := AppleOSUpdateSettings{ MinimumVersion: optjson.SetString(tc.version), Deadline: optjson.SetString(tc.deadline), } @@ -266,10 +266,8 @@ func TestAppConfigDeprecatedFields(t *testing.T) { diskEncryption, exists := mdm["enable_disk_encryption"] require.True(t, exists) require.EqualValues(t, c.wantDiskEncryption, diskEncryption) - }) } - } func TestAtLeastOnePlatformEnabledAndConfigured(t *testing.T) { diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 55e0a30c35..4347117a18 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -695,3 +695,11 @@ func FilterMacOSOnlyProfilesFromIOSIPadOS(profiles []*MDMAppleProfilePayload) [] // RefetchCommandUUIDPrefix is the prefix used for MDM commands used to refetch information from iOS/iPadOS devices. const RefetchCommandUUIDPrefix = "REFETCH-" + +type AppleDevice int + +const ( + MacOS AppleDevice = iota + IOS + IPadOS +) diff --git a/server/fleet/nudge.go b/server/fleet/nudge.go index 3b6f75c4ce..3299263bd6 100644 --- a/server/fleet/nudge.go +++ b/server/fleet/nudge.go @@ -44,7 +44,7 @@ type nudgeUpdateElements struct { MainHeader string `json:"mainHeader"` } -func NewNudgeConfig(macOSUpdates MacOSUpdates) (*NudgeConfig, error) { +func NewNudgeConfig(macOSUpdates AppleOSUpdateSettings) (*NudgeConfig, error) { deadline, err := time.Parse("2006-01-02", macOSUpdates.Deadline.Value) if err != nil { return nil, err diff --git a/server/fleet/service.go b/server/fleet/service.go index 27bfa5b187..8afa34a526 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -34,7 +34,7 @@ type EnterpriseOverrides struct { DeleteMDMAppleBootstrapPackage func(ctx context.Context, teamID *uint) error MDMWindowsEnableOSUpdates func(ctx context.Context, teamID *uint, updates WindowsUpdates) error MDMWindowsDisableOSUpdates func(ctx context.Context, teamID *uint) error - MDMAppleEditedMacOSUpdates func(ctx context.Context, teamID *uint, updates MacOSUpdates) error + MDMAppleEditedAppleOSUpdates func(ctx context.Context, teamID *uint, appleDevice AppleDevice, updates AppleOSUpdateSettings) error } type OsqueryService interface { diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 45febe6d90..9fa20d7fe9 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -33,10 +33,18 @@ type TeamPayload struct { // need to be able which part of the MDM config was provided in the request, // so the fields are pointers to structs. type TeamPayloadMDM struct { - EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` - MacOSUpdates *MacOSUpdates `json:"macos_updates"` - WindowsUpdates *WindowsUpdates `json:"windows_updates"` - MacOSSetup *MacOSSetup `json:"macos_setup"` + EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` + + // MacOSUpdates defines the OS update settings for macOS devices. + MacOSUpdates *AppleOSUpdateSettings `json:"macos_updates"` + // IOSUpdates defines the OS update settings for iOS devices. + IOSUpdates *AppleOSUpdateSettings `json:"ios_updates"` + // IPadOSUpdates defines the OS update settings for iPadOS devices. + IPadOSUpdates *AppleOSUpdateSettings `json:"ipados_updates"` + // WindowsUpdates defines the OS update settings for Windows devices. + WindowsUpdates *WindowsUpdates `json:"windows_updates"` + + MacOSSetup *MacOSSetup `json:"macos_setup"` } // Team is the data representation for the "Team" concept (group of hosts and @@ -169,11 +177,13 @@ type TeamSpecSoftware struct { } type TeamMDM struct { - EnableDiskEncryption bool `json:"enable_disk_encryption"` - MacOSUpdates MacOSUpdates `json:"macos_updates"` - WindowsUpdates WindowsUpdates `json:"windows_updates"` - MacOSSettings MacOSSettings `json:"macos_settings"` - MacOSSetup MacOSSetup `json:"macos_setup"` + EnableDiskEncryption bool `json:"enable_disk_encryption"` + MacOSUpdates AppleOSUpdateSettings `json:"macos_updates"` + IOSUpdates AppleOSUpdateSettings `json:"ios_updates"` + IPadOSUpdates AppleOSUpdateSettings `json:"ipados_updates"` + WindowsUpdates WindowsUpdates `json:"windows_updates"` + MacOSSettings MacOSSettings `json:"macos_settings"` + MacOSSetup MacOSSetup `json:"macos_setup"` WindowsSettings WindowsSettings `json:"windows_settings"` // NOTE: TeamSpecMDM must be kept in sync with TeamMDM. @@ -224,7 +234,13 @@ func (t *TeamMDM) Copy() *TeamMDM { type TeamSpecMDM struct { EnableDiskEncryption optjson.Bool `json:"enable_disk_encryption"` - MacOSUpdates MacOSUpdates `json:"macos_updates"` + // MacOSUpdates defines the OS update settings for macOS devices. + MacOSUpdates AppleOSUpdateSettings `json:"macos_updates"` + // IOSUpdates defines the OS update settings for iOS devices. + IOSUpdates AppleOSUpdateSettings `json:"ios_updates"` + // IPadOSUpdates defines the OS update settings for iPadOS devices. + IPadOSUpdates AppleOSUpdateSettings `json:"ipados_updates"` + // WindowsUpdates defines the OS update settings for Windows devices. WindowsUpdates WindowsUpdates `json:"windows_updates"` // A map is used for the macos settings so that we can easily detect if its diff --git a/server/fleet/teams_test.go b/server/fleet/teams_test.go index 0a3533c009..a9de206787 100644 --- a/server/fleet/teams_test.go +++ b/server/fleet/teams_test.go @@ -269,7 +269,7 @@ func TestTeamMDMCopy(t *testing.T) { t.Run("copy value fields", func(t *testing.T) { tm := &TeamMDM{ EnableDiskEncryption: true, - MacOSUpdates: MacOSUpdates{ + MacOSUpdates: AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.4"), Deadline: optjson.SetString("2020-01-01"), }, diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go index 041aeb960d..93af98eb3d 100644 --- a/server/mdm/mdm.go +++ b/server/mdm/mdm.go @@ -101,6 +101,14 @@ const ( // FleetMacOSUpdatesProfileName is the name of the DDM profile used by Fleet // to configure macOS OS updates. FleetMacOSUpdatesProfileName = "Fleet macOS OS Updates" + + // FleetIOSUpdatesProfileName is the name of the DDM profile used by Fleet + // to configure iOS OS updates. + FleetIOSUpdatesProfileName = "Fleet iOS OS Updates" + + // FleetIPadOSUpdatesProfileName is the name of the DDM profile used by Fleet + // to configure iPadOS OS updates. + FleetIPadOSUpdatesProfileName = "Fleet iPadOS OS Updates" ) // FleetReservedProfileNames returns a map of PayloadDisplayName or profile @@ -111,6 +119,8 @@ func FleetReservedProfileNames() map[string]struct{} { FleetFileVaultProfileName: {}, FleetWindowsOSUpdatesProfileName: {}, FleetMacOSUpdatesProfileName: {}, + FleetIOSUpdatesProfileName: {}, + FleetIPadOSUpdatesProfileName: {}, FleetCAConfigProfileName: {}, } } @@ -130,5 +140,9 @@ func ListFleetReservedMacOSProfileNames() []string { // ListFleetReservedMacOSDeclarationNames returns a list of declaration names // that are reserved by Fleet for Apple DDM declarations. func ListFleetReservedMacOSDeclarationNames() []string { - return []string{FleetMacOSUpdatesProfileName} + return []string{ + FleetMacOSUpdatesProfileName, + FleetIOSUpdatesProfileName, + FleetIPadOSUpdatesProfileName, + } } diff --git a/server/service/appconfig.go b/server/service/appconfig.go index f05d861eda..04079821f7 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -558,27 +558,26 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle } } - // if the macOS minimum version requirement changed, create the corresponding - // activity - if oldAppConfig.MDM.MacOSUpdates.MinimumVersion.Value != appConfig.MDM.MacOSUpdates.MinimumVersion.Value || - oldAppConfig.MDM.MacOSUpdates.Deadline.Value != appConfig.MDM.MacOSUpdates.Deadline.Value { - if license.IsPremium() { - // macOS updates are premium feature - if err := svc.EnterpriseOverrides.MDMAppleEditedMacOSUpdates(ctx, nil, appConfig.MDM.MacOSUpdates); err != nil { - return nil, ctxerr.Wrap(ctx, err, "update DDM profile after macOS updates change") - } - } - - if err := svc.NewActivity( - ctx, - authz.UserFromContext(ctx), - fleet.ActivityTypeEditedMacOSMinVersion{ - MinimumVersion: appConfig.MDM.MacOSUpdates.MinimumVersion.Value, - Deadline: appConfig.MDM.MacOSUpdates.Deadline.Value, - }, - ); err != nil { - return nil, ctxerr.Wrap(ctx, err, "create activity for app config macos min version modification") - } + // + // Process OS updates config changes for Apple devices. + // + if err := svc.processAppleOSUpdateSettings(ctx, license, fleet.MacOS, + oldAppConfig.MDM.MacOSUpdates, + appConfig.MDM.MacOSUpdates, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "process macOS OS updates config change") + } + if err := svc.processAppleOSUpdateSettings(ctx, license, fleet.IOS, + oldAppConfig.MDM.IOSUpdates, + appConfig.MDM.IOSUpdates, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "process iOS OS updates config change") + } + if err := svc.processAppleOSUpdateSettings(ctx, license, fleet.IPadOS, + oldAppConfig.MDM.IPadOSUpdates, + appConfig.MDM.IPadOSUpdates, + ); err != nil { + return nil, ctxerr.Wrap(ctx, err, "process iPadOS OS updates config change") } // if the Windows updates requirements changed, create the corresponding @@ -670,6 +669,47 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle return obfuscatedAppConfig, nil } +// processAppleOSUpdateSettings updates the OS updates configuration if the minimum version+deadline are updated. +func (svc *Service) processAppleOSUpdateSettings( + ctx context.Context, + license *fleet.LicenseInfo, + appleDevice fleet.AppleDevice, + oldOSUpdateSettings fleet.AppleOSUpdateSettings, + newOSUpdateSettings fleet.AppleOSUpdateSettings, +) error { + if oldOSUpdateSettings.MinimumVersion.Value != newOSUpdateSettings.MinimumVersion.Value || + oldOSUpdateSettings.Deadline.Value != newOSUpdateSettings.Deadline.Value { + if license.IsPremium() { + if err := svc.EnterpriseOverrides.MDMAppleEditedAppleOSUpdates(ctx, nil, appleDevice, newOSUpdateSettings); err != nil { + return ctxerr.Wrap(ctx, err, "update DDM profile after Apple OS updates change") + } + } + + var activity fleet.ActivityDetails + switch appleDevice { + case fleet.MacOS: + activity = fleet.ActivityTypeEditedMacOSMinVersion{ + MinimumVersion: newOSUpdateSettings.MinimumVersion.Value, + Deadline: newOSUpdateSettings.Deadline.Value, + } + case fleet.IOS: + activity = fleet.ActivityTypeEditedIOSMinVersion{ + MinimumVersion: newOSUpdateSettings.MinimumVersion.Value, + Deadline: newOSUpdateSettings.Deadline.Value, + } + case fleet.IPadOS: + activity = fleet.ActivityTypeEditedIPadOSMinVersion{ + MinimumVersion: newOSUpdateSettings.MinimumVersion.Value, + Deadline: newOSUpdateSettings.Deadline.Value, + } + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), activity); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for app config apple min version modification") + } + } + return nil +} + func (svc *Service) HasCustomSetupAssistantConfigurationWebURL(ctx context.Context, teamID *uint) (bool, error) { az, ok := authz_ctx.FromContext(ctx) if !ok || !az.Checked() { @@ -784,12 +824,24 @@ func (svc *Service) validateMDM( } // MacOSUpdates - updatingVersion := mdm.MacOSUpdates.MinimumVersion.Value != "" && + updatingMacOSVersion := mdm.MacOSUpdates.MinimumVersion.Value != "" && mdm.MacOSUpdates.MinimumVersion != oldMdm.MacOSUpdates.MinimumVersion - updatingDeadline := mdm.MacOSUpdates.Deadline.Value != "" && + updatingMacOSDeadline := mdm.MacOSUpdates.Deadline.Value != "" && mdm.MacOSUpdates.Deadline != oldMdm.MacOSUpdates.Deadline + // IOSUpdates + updatingIOSVersion := mdm.IOSUpdates.MinimumVersion.Value != "" && + mdm.IOSUpdates.MinimumVersion != oldMdm.IOSUpdates.MinimumVersion + updatingIOSDeadline := mdm.IOSUpdates.Deadline.Value != "" && + mdm.IOSUpdates.Deadline != oldMdm.IOSUpdates.Deadline + // IPadOSUpdates + updatingIPadOSVersion := mdm.IPadOSUpdates.MinimumVersion.Value != "" && + mdm.IPadOSUpdates.MinimumVersion != oldMdm.IPadOSUpdates.MinimumVersion + updatingIPadOSDeadline := mdm.IPadOSUpdates.Deadline.Value != "" && + mdm.IPadOSUpdates.Deadline != oldMdm.IPadOSUpdates.Deadline - if updatingVersion || updatingDeadline { + if updatingMacOSVersion || updatingMacOSDeadline || + updatingIOSVersion || updatingIOSDeadline || + updatingIPadOSVersion || updatingIPadOSDeadline { // TODO: Should we validate MDM configured on here too? if !license.IsPremium() { @@ -800,6 +852,12 @@ func (svc *Service) validateMDM( if err := mdm.MacOSUpdates.Validate(); err != nil { invalid.Append("macos_updates", err.Error()) } + if err := mdm.IOSUpdates.Validate(); err != nil { + invalid.Append("ios_updates", err.Error()) + } + if err := mdm.IPadOSUpdates.Validate(); err != nil { + invalid.Append("ipados_updates", err.Error()) + } // WindowsUpdates updatingWindowsUpdates := !mdm.WindowsUpdates.Equal(oldMdm.WindowsUpdates) diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 99f356d459..8a2905458d 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -859,7 +859,9 @@ func TestMDMAppleConfig(t *testing.T) { licenseTier: "free", expectedMDM: fleet.MDM{ MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -889,7 +891,9 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ AppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -904,7 +908,9 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ AppleBMDefaultTeam: "foobar", MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -925,7 +931,9 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ EndUserAuthentication: fleet.MDMEndUserAuthentication{SSOProviderSettings: fleet.SSOProviderSettings{EntityID: "foo"}}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -949,7 +957,9 @@ func TestMDMAppleConfig(t *testing.T) { IDPName: "onelogin", }}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, @@ -1007,7 +1017,9 @@ func TestMDMAppleConfig(t *testing.T) { expectedMDM: fleet.MDM{ EnableDiskEncryption: optjson.Bool{Set: true, Valid: true, Value: false}, MacOSSetup: fleet.MacOSSetup{BootstrapPackage: optjson.String{Set: true}, MacOSSetupAssistant: optjson.String{Set: true}, EnableReleaseDeviceManually: optjson.SetBool(false)}, - MacOSUpdates: fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ CustomSettings: optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index d34222fa17..4b734593e7 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2805,7 +2805,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ // set "pending-install" profiles to "verifying" or "failed" // depending on the status of the DeviceManagement command status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status) - detail := fmt.Sprintf("%s. Make sure the host is on macOS 13 or higher.", apple_mdm.FmtErrorChain(cmdResult.ErrorChain)) + detail := fmt.Sprintf("%s. Make sure the host is on macOS 13+, iOS 17+, iPadOS 17+.", apple_mdm.FmtErrorChain(cmdResult.ErrorChain)) err := svc.ds.MDMAppleSetPendingDeclarationsAs(r.Context, cmdResult.UDID, status, detail) return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack") diff --git a/server/service/client.go b/server/service/client.go index 82fee91585..8a9c909739 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -1239,6 +1239,32 @@ func (c *Client) DoGitOps( if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil { macOSUpdates["deadline"] = "" } + // Put in default values for ios_updates + if config.Controls.IOSUpdates != nil { + mdmAppConfig["ios_updates"] = config.Controls.IOSUpdates + } else { + mdmAppConfig["ios_updates"] = map[string]interface{}{} + } + iOSUpdates := mdmAppConfig["ios_updates"].(map[string]interface{}) + if minimumVersion, ok := iOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + iOSUpdates["minimum_version"] = "" + } + if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil { + iOSUpdates["deadline"] = "" + } + // Put in default values for ipados_updates + if config.Controls.IPadOSUpdates != nil { + mdmAppConfig["ipados_updates"] = config.Controls.IPadOSUpdates + } else { + mdmAppConfig["ipados_updates"] = map[string]interface{}{} + } + iPadOSUpdates := mdmAppConfig["ipados_updates"].(map[string]interface{}) + if minimumVersion, ok := iPadOSUpdates["minimum_version"]; !ok || minimumVersion == nil { + iPadOSUpdates["minimum_version"] = "" + } + if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil { + iPadOSUpdates["deadline"] = "" + } // Put in default values for macos_setup if config.Controls.MacOSSetup != nil { mdmAppConfig["macos_setup"] = config.Controls.MacOSSetup diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index dc110cdc40..1231960bef 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -169,6 +169,14 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { "minimum_version": "10.15.0", "deadline": "2021-01-01", }, + "ios_updates": map[string]any{ + "minimum_version": "17.5.1", + "deadline": "2024-07-23", + }, + "ipados_updates": map[string]any{ + "minimum_version": "18.0", + "deadline": "2024-08-24", + }, }, }, }, @@ -188,10 +196,18 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { AdditionalQueries: ptr.RawMessage(json.RawMessage(`{"foo": "bar"}`)), }, team.Config.Features) require.Equal(t, fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("17.5.1"), + Deadline: optjson.SetString("2024-07-23"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("18.0"), + Deadline: optjson.SetString("2024-08-24"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}, @@ -285,10 +301,18 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { require.NoError(t, err) require.Equal(t, applyResp.TeamIDsByName[teamName], team.ID) require.Equal(t, fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("17.5.1"), + Deadline: optjson.SetString("2024-07-23"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("18.0"), + Deadline: optjson.SetString("2024-08-24"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1), @@ -307,10 +331,18 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { var getTmResp getTeamResponse s.DoJSON("GET", "/api/latest/fleet/teams/"+fmt.Sprint(team.ID), nil, http.StatusOK, &getTmResp) require.Equal(t, fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("17.5.1"), + Deadline: optjson.SetString("2024-07-23"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("18.0"), + Deadline: optjson.SetString("2024-08-24"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1), @@ -331,10 +363,18 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { require.True(t, len(listTmResp.Teams) > 0) require.Equal(t, team.ID, listTmResp.Teams[0].ID) require.Equal(t, fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("17.5.1"), + Deadline: optjson.SetString("2024-07-23"), + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("18.0"), + Deadline: optjson.SetString("2024-08-24"), + }, WindowsUpdates: fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1), @@ -2133,7 +2173,15 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { var getTmResp getTeamResponse s.DoJSON("GET", "/api/latest/fleet/teams/"+fmt.Sprint(team.ID), nil, http.StatusOK, &getTmResp) require.Equal(t, fleet.TeamMDM{ - MacOSUpdates: fleet.MacOSUpdates{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.String{Set: true}, + Deadline: optjson.String{Set: true}, + }, + IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.String{Set: true}, + Deadline: optjson.String{Set: true}, + }, + IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, }, @@ -2173,7 +2221,7 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { tmResp = teamResponse{} s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ "mdm": map[string]any{ - "macos_updates": &fleet.MacOSUpdates{ + "macos_updates": &fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), }, @@ -2279,7 +2327,7 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { }, http.StatusUnprocessableEntity, &tmResp) } -func (s *integrationEnterpriseTestSuite) assertMacOSUpdatesDeclaration(teamID *uint, expected *fleet.MacOSUpdates) { +func (s *integrationEnterpriseTestSuite) assertAppleOSUpdatesDeclaration(teamID *uint, profileName string, expected *fleet.AppleOSUpdateSettings) { t := s.T() if teamID == nil { teamID = ptr.Uint(0) @@ -2288,7 +2336,7 @@ func (s *integrationEnterpriseTestSuite) assertMacOSUpdatesDeclaration(teamID *u var declUUID string mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { err := sqlx.GetContext(context.Background(), q, &declUUID, - `SELECT declaration_uuid FROM mdm_apple_declarations WHERE team_id = ? AND name = ?`, teamID, mdm.FleetMacOSUpdatesProfileName) + `SELECT declaration_uuid FROM mdm_apple_declarations WHERE team_id = ? AND name = ?`, teamID, profileName) if expected == nil { require.Error(t, err) return nil @@ -2307,10 +2355,9 @@ func (s *integrationEnterpriseTestSuite) assertMacOSUpdatesDeclaration(teamID *u require.Contains(t, string(decl.RawJSON), fmt.Sprintf(`"TargetLocalDateTime": "%sT12:00:00"`, expected.Deadline.Value)) } -func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { +func (s *integrationEnterpriseTestSuite) TestAppleOSUpdatesTeamConfig() { t := s.T() - // Create a team team := &fleet.Team{ Name: t.Name(), Description: "Team description", @@ -2322,41 +2369,92 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { team.ID = tmResp.Team.ID // no OS updates settings at the moment - s.assertMacOSUpdatesDeclaration(&team.ID, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, nil) - // modify the team's config - updates := &fleet.MacOSUpdates{ + // modify the team's config (macOS first) + macOSUpdates := &fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2021-01-01"), } s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ "mdm": map[string]any{ - "macos_updates": updates, + "macos_updates": macOSUpdates, }, }, http.StatusOK, &tmResp) require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2021-01-01"}`, team.ID, team.Name), 0) - s.assertMacOSUpdatesDeclaration(&team.ID, updates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, nil) - // only update the deadline - updates = &fleet.MacOSUpdates{ - MinimumVersion: optjson.SetString("10.15.0"), - Deadline: optjson.SetString("2025-10-01"), + // modify the team's config (now iOS and iPadOS) + iOSUpdates := &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("11.11.11"), + Deadline: optjson.SetString("2022-02-02"), + } + iPadOSUpdates := &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("12.12.12"), + Deadline: optjson.SetString("2023-03-03"), } s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ "mdm": map[string]any{ - "macos_updates": updates, + "ios_updates": iOSUpdates, + "ipados_updates": iPadOSUpdates, + }, + }, http.StatusOK, &tmResp) + require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, "2021-01-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2022-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2023-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2021-01-01"}`, team.ID, team.Name), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "11.11.11", "deadline": "2022-02-02"}`, team.ID, team.Name), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "12.12.12", "deadline": "2023-03-03"}`, team.ID, team.Name), 0) + + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates) + + // only update the deadlines + macOSUpdates = &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("10.15.0"), + Deadline: optjson.SetString("2025-10-01"), + } + iOSUpdates = &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("11.11.11"), + Deadline: optjson.SetString("2024-02-02"), + } + iPadOSUpdates = &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("12.12.12"), + Deadline: optjson.SetString("2024-03-03"), + } + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "macos_updates": macOSUpdates, + "ios_updates": iOSUpdates, + "ipados_updates": iPadOSUpdates, }, }, http.StatusOK, &tmResp) require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) - lastActivity := s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2025-10-01"}`, team.ID, team.Name), 0) + require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value) + macOSLastActivity := s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "10.15.0", "deadline": "2025-10-01"}`, team.ID, team.Name), 0) + iOSLastActivity := s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "11.11.11", "deadline": "2024-02-02"}`, team.ID, team.Name), 0) + iPadOSLastActivity := s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "12.12.12", "deadline": "2024-03-03"}`, team.ID, team.Name), 0) - s.assertMacOSUpdatesDeclaration(&team.ID, updates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates) - // setting the windows updates doesn't alter the macos updates + // setting the windows updates doesn't alter the apple updates tmResp = teamResponse{} s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ "mdm": map[string]any{ @@ -2368,13 +2466,21 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, http.StatusOK, &tmResp) require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value) require.Equal(t, 10, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) require.Equal(t, 2, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) - // did not create a new activity for macos updates - s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), "", lastActivity) - lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), ``, 0) + // did not create a new activity for os updates + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), "", macOSLastActivity) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), "", iOSLastActivity) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), "", iPadOSLastActivity) + lastActivity := s.lastActivityMatches(fleet.ActivityTypeEditedWindowsUpdates{}.ActivityName(), ``, 0) - s.assertMacOSUpdatesDeclaration(&team.ID, updates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates) // sending a nil MDM or MacOSUpdate config doesn't modify anything s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2387,10 +2493,16 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, http.StatusOK, &tmResp) require.Equal(t, "10.15.0", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2025-10-01", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, "11.11.11", tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-02-02", tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "12.12.12", tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-03-03", tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value) // no new activity is created s.lastActivityMatches("", "", lastActivity) - s.assertMacOSUpdatesDeclaration(&team.ID, updates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates) // sending macos settings but no macos_updates does not change the macos updates s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2405,22 +2517,40 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { // no new activity is created s.lastActivityMatches("", "", lastActivity) - s.assertMacOSUpdatesDeclaration(&team.ID, updates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, macOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, iOSUpdates) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, iPadOSUpdates) - // sending empty MacOSUpdate fields empties both fields + // sending empty apple os updates fields empties both fields and removes the DDM profiles s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ "mdm": map[string]any{ "macos_updates": map[string]any{ "minimum_version": "", "deadline": nil, }, + "ios_updates": map[string]any{ + "minimum_version": "", + "deadline": nil, + }, + "ipados_updates": map[string]any{ + "minimum_version": "", + "deadline": nil, + }, }, }, http.StatusOK, &tmResp) require.Empty(t, tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) require.Empty(t, tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) - s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0) + require.Empty(t, tmResp.Team.Config.MDM.IOSUpdates.MinimumVersion.Value) + require.Empty(t, tmResp.Team.Config.MDM.IOSUpdates.Deadline.Value) + require.Empty(t, tmResp.Team.Config.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Empty(t, tmResp.Team.Config.MDM.IPadOSUpdates.Deadline.Value) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "minimum_version": "", "deadline": ""}`, team.ID, team.Name), 0) - s.assertMacOSUpdatesDeclaration(&team.ID, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetMacOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(&team.ID, mdm.FleetIPadOSUpdatesProfileName, nil) // error checks: @@ -2433,6 +2563,22 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, }, }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ios_updates": map[string]any{ + "minimum_version": "10.15.0", + "deadline": "2021-01-01T00:00:00Z", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ipados_updates": map[string]any{ + "minimum_version": "10.15.0", + "deadline": "2021-01-01T00:00:00Z", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) // try to set an invalid minimum version s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2443,6 +2589,22 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, }, }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ios_updates": map[string]any{ + "minimum_version": "10.15.0 (19A583)", + "deadline": "2021-01-01T00:00:00Z", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ipados_updates": map[string]any{ + "minimum_version": "10.15.0 (19A583)", + "deadline": "2021-01-01T00:00:00Z", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) // try to set a deadline but not a minimum version s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2452,6 +2614,20 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, }, }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ios_updates": map[string]any{ + "deadline": "2021-01-01T00:00:00Z", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ipados_updates": map[string]any{ + "deadline": "2021-01-01T00:00:00Z", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) // try to set an empty deadline but not a minimum version s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2461,6 +2637,20 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, }, }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ios_updates": map[string]any{ + "deadline": "", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ipados_updates": map[string]any{ + "deadline": "", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) // try to set a minimum version but not a deadline s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2470,6 +2660,20 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, }, }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ios_updates": map[string]any{ + "minimum_version": "10.15.0 (19A583)", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ipados_updates": map[string]any{ + "minimum_version": "10.15.0 (19A583)", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) // try to set an empty minimum version but not a deadline s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ @@ -2479,6 +2683,20 @@ func (s *integrationEnterpriseTestSuite) TestMacOSUpdatesTeamConfig() { }, }, }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ios_updates": map[string]any{ + "minimum_version": "", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), map[string]any{ + "mdm": map[string]any{ + "ipados_updates": map[string]any{ + "minimum_version": "", + }, + }, + }, http.StatusUnprocessableEntity, &tmResp) } func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { @@ -2868,7 +3086,7 @@ func (s *integrationEnterpriseTestSuite) TestMDMWindowsUpdates() { s.lastActivityMatches("", ``, lastActivity) } -func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { +func (s *integrationEnterpriseTestSuite) TestMDMAppleOSUpdates() { t := s.T() // keep the last activity, to detect newly created ones @@ -2887,7 +3105,7 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { // get the appconfig, nothing changed acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - require.Equal(t, fleet.MacOSUpdates{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, acResp.MDM.MacOSUpdates) + require.Equal(t, fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, acResp.MDM.MacOSUpdates) // no activity got created activitiesResp = listActivitiesResponse{} @@ -2904,6 +3122,16 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "deadline": "2022-01-01" } }}`) + checkInvalidConfig(`{"mdm": { + "ios_updates": { + "deadline": "2022-01-01" + } + }}`) + checkInvalidConfig(`{"mdm": { + "ipados_updates": { + "deadline": "2022-01-01" + } + }}`) // missing deadline checkInvalidConfig(`{"mdm": { @@ -2911,6 +3139,16 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "minimum_version": "12.1.1" } }}`) + checkInvalidConfig(`{"mdm": { + "ios_updates": { + "minimum_version": "12.1.1" + } + }}`) + checkInvalidConfig(`{"mdm": { + "ipados_updates": { + "minimum_version": "12.1.1" + } + }}`) // invalid deadline checkInvalidConfig(`{"mdm": { @@ -2919,6 +3157,18 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "deadline": "2022" } }}`) + checkInvalidConfig(`{"mdm": { + "ios_updates": { + "minimum_version": "12.1.1", + "deadline": "2022" + } + }}`) + checkInvalidConfig(`{"mdm": { + "ipados_updates": { + "minimum_version": "12.1.1", + "deadline": "2022" + } + }}`) // deadline includes timestamp checkInvalidConfig(`{"mdm": { @@ -2927,6 +3177,18 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "deadline": "2022-01-01T00:00:00Z" } }}`) + checkInvalidConfig(`{"mdm": { + "ios_updates": { + "minimum_version": "12.1.1", + "deadline": "2022-01-01T00:00:00Z" + } + }}`) + checkInvalidConfig(`{"mdm": { + "ipados_updates": { + "minimum_version": "12.1.1", + "deadline": "2022-01-01T00:00:00Z" + } + }}`) // minimum_version includes build info checkInvalidConfig(`{"mdm": { @@ -2935,6 +3197,18 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "deadline": "2022-01-01" } }}`) + checkInvalidConfig(`{"mdm": { + "ios_updates": { + "minimum_version": "12.1.1 (ABCD)", + "deadline": "2022-01-01" + } + }}`) + checkInvalidConfig(`{"mdm": { + "ipados_updates": { + "minimum_version": "12.1.1 (ABCD)", + "deadline": "2022-01-01" + } + }}`) // valid config acResp := appConfigResponse{} @@ -2943,23 +3217,47 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "macos_updates": { "minimum_version": "12.3.1", "deadline": "2022-01-01" + }, + "ios_updates": { + "minimum_version": "13.13.13", + "deadline": "2023-03-03" + }, + "ipados_updates": { + "minimum_version": "14.14.14", + "deadline": "2024-04-04" } } }`), http.StatusOK, &acResp) require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2022-01-01", acResp.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, "13.13.13", acResp.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2023-03-03", acResp.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "14.14.14", acResp.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-04-04", acResp.MDM.IPadOSUpdates.Deadline.Value) // edited macos min version activity got created - s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2022-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0) - s.assertMacOSUpdatesDeclaration(nil, &fleet.MacOSUpdates{ + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2022-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), `{"deadline":"2023-03-03", "minimum_version":"13.13.13", "team_id": null, "team_name": null}`, 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), `{"deadline":"2024-04-04", "minimum_version":"14.14.14", "team_id": null, "team_name": null}`, 0) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2022-01-01"), }) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2023-03-03"), + }) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.14.14"), Deadline: optjson.SetString("2024-04-04"), + }) // get the appconfig acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2022-01-01", acResp.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, "13.13.13", acResp.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2023-03-03", acResp.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "14.14.14", acResp.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2024-04-04", acResp.MDM.IPadOSUpdates.Deadline.Value) // update the deadline acResp = appConfigResponse{} @@ -2968,17 +3266,37 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { "macos_updates": { "minimum_version": "12.3.1", "deadline": "2024-01-01" + }, + "ios_updates": { + "minimum_version": "13.13.13", + "deadline": "2025-05-05" + }, + "ipados_updates": { + "minimum_version": "14.14.14", + "deadline": "2026-06-06" } } }`), http.StatusOK, &acResp) require.Equal(t, "12.3.1", acResp.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, "2024-01-01", acResp.MDM.MacOSUpdates.Deadline.Value) + require.Equal(t, "13.13.13", acResp.MDM.IOSUpdates.MinimumVersion.Value) + require.Equal(t, "2025-05-05", acResp.MDM.IOSUpdates.Deadline.Value) + require.Equal(t, "14.14.14", acResp.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Equal(t, "2026-06-06", acResp.MDM.IPadOSUpdates.Deadline.Value) // another edited macos min version activity got created - lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2024-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0) - s.assertMacOSUpdatesDeclaration(nil, &fleet.MacOSUpdates{ + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"2024-01-01", "minimum_version":"12.3.1", "team_id": null, "team_name": null}`, 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), `{"deadline":"2025-05-05", "minimum_version":"13.13.13", "team_id": null, "team_name": null}`, 0) + lastActivity = s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), `{"deadline":"2026-06-06", "minimum_version":"14.14.14", "team_id": null, "team_name": null}`, 0) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01"), }) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2025-05-05"), + }) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.14.14"), Deadline: optjson.SetString("2026-06-06"), + }) // update something unrelated - the transparency url acResp = appConfigResponse{} @@ -2988,43 +3306,81 @@ func (s *integrationEnterpriseTestSuite) TestMDMMacOSUpdates() { // no activity got created s.lastActivityMatches("", ``, lastActivity) - s.assertMacOSUpdatesDeclaration(nil, &fleet.MacOSUpdates{ + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.3.1"), Deadline: optjson.SetString("2024-01-01"), }) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("13.13.13"), Deadline: optjson.SetString("2025-05-05"), + }) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.14.14"), Deadline: optjson.SetString("2026-06-06"), + }) - // clear the macos requirement + // clear the apple OS requirements acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_updates": { "minimum_version": "", "deadline": "" + }, + "ios_updates": { + "minimum_version": "", + "deadline": "" + }, + "ipados_updates": { + "minimum_version": "", + "deadline": "" } } }`), http.StatusOK, &acResp) require.Empty(t, acResp.MDM.MacOSUpdates.MinimumVersion.Value) require.Empty(t, acResp.MDM.MacOSUpdates.Deadline.Value) + require.Empty(t, acResp.MDM.IOSUpdates.MinimumVersion.Value) + require.Empty(t, acResp.MDM.IOSUpdates.Deadline.Value) + require.Empty(t, acResp.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Empty(t, acResp.MDM.IPadOSUpdates.Deadline.Value) // edited macos min version activity got created with empty requirement - lastActivity = s.lastActivityMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0) - s.assertMacOSUpdatesDeclaration(nil, nil) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedMacOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0) + lastActivity = s.lastActivityOfTypeMatches(fleet.ActivityTypeEditedIPadOSMinVersion{}.ActivityName(), `{"deadline":"", "minimum_version":"", "team_id": null, "team_name": null}`, 0) - // update again with empty macos requirement + // check DDM profiles were removed + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, nil) + + // update again with empty apple OS requirements acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_updates": { "minimum_version": "", "deadline": "" + }, + "ios_updates": { + "minimum_version": "", + "deadline": "" + }, + "ipados_updates": { + "minimum_version": "", + "deadline": "" } } }`), http.StatusOK, &acResp) require.Empty(t, acResp.MDM.MacOSUpdates.MinimumVersion.Value) require.Empty(t, acResp.MDM.MacOSUpdates.Deadline.Value) + require.Empty(t, acResp.MDM.IOSUpdates.MinimumVersion.Value) + require.Empty(t, acResp.MDM.IOSUpdates.Deadline.Value) + require.Empty(t, acResp.MDM.IPadOSUpdates.MinimumVersion.Value) + require.Empty(t, acResp.MDM.IPadOSUpdates.Deadline.Value) - // no activity got created + // no activity or DDM profiles were created s.lastActivityMatches("", ``, lastActivity) - s.assertMacOSUpdatesDeclaration(nil, nil) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetMacOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIOSUpdatesProfileName, nil) + s.assertAppleOSUpdatesDeclaration(nil, mdm.FleetIPadOSUpdatesProfileName, nil) } func (s *integrationEnterpriseTestSuite) TestSSOJITProvisioning() { diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 2b1a9f0dce..318f615ad0 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -364,7 +364,7 @@ func (s *integrationMDMTestSuite) TestAppleProfileManagement() { }`), http.StatusOK) s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ - MacOSUpdates: &fleet.MacOSUpdates{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ Deadline: optjson.SetString("1992-01-01"), MinimumVersion: optjson.SetString("13.1.1"), }, @@ -2865,28 +2865,33 @@ func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { {ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID}, {ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil}, {ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID}, - {ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil, + { + ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil, LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, - {ProfileUUID: uuidAppleWithLabel, Platform: "darwin", Name: "apple-profile-with-labels", Identifier: "ident-with-labels", TeamID: nil, + { + ProfileUUID: uuidAppleWithLabel, Platform: "darwin", Name: "apple-profile-with-labels", Identifier: "ident-with-labels", TeamID: nil, LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, - {ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil, + { + ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil, LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, - {ProfileUUID: uuidAppleDDMTeamWithLabel, Platform: "darwin", Name: "apple-team-decl-with-labels", Identifier: "ident-team-decl-with-labels", TeamID: &testTeam.ID, + { + ProfileUUID: uuidAppleDDMTeamWithLabel, Platform: "darwin", Name: "apple-team-decl-with-labels", Identifier: "ident-team-decl-with-labels", TeamID: &testTeam.ID, LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, - {ProfileUUID: uuidWindowsTeamWithLabel, Platform: "windows", Name: "win-team-profile-with-labels", TeamID: &testTeam.ID, + { + ProfileUUID: uuidWindowsTeamWithLabel, Platform: "windows", Name: "win-team-profile-with-labels", TeamID: &testTeam.ID, LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, @@ -4282,7 +4287,7 @@ func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() { DeadlineDays: optjson.SetInt(4), GracePeriodDays: optjson.SetInt(1), }, - MacOSUpdates: &fleet.MacOSUpdates{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ Deadline: optjson.SetString("2023-12-31"), MinimumVersion: optjson.SetString("13.3.8"), }, diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 04876a5a2d..3ada175919 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -327,7 +327,6 @@ func (s *integrationMDMTestSuite) SetupSuite() { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) require.NoError(s.T(), json.NewEncoder(w).Encode(s.mockedDownloadFleetdmMeta)) - } })) s.T().Setenv("FLEET_DEV_DOWNLOAD_FLEETDM_URL", downloadFleetdmSrv.URL) @@ -5423,7 +5422,7 @@ func (s *integrationMDMTestSuite) TestOrbitConfigNudgeSettings() { resp = orbitGetConfigResponse{} s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp) - wantCfg, err := fleet.NewNudgeConfig(fleet.MacOSUpdates{Deadline: optjson.SetString("2022-01-04"), MinimumVersion: optjson.SetString("12.1.3")}) + wantCfg, err := fleet.NewNudgeConfig(fleet.AppleOSUpdateSettings{Deadline: optjson.SetString("2022-01-04"), MinimumVersion: optjson.SetString("12.1.3")}) require.NoError(t, err) require.Equal(t, wantCfg, resp.NudgeConfig) require.Equal(t, wantCfg.OSVersionRequirements[0].RequiredInstallationDate.String(), "2022-01-04 04:00:00 +0000 UTC") @@ -5451,7 +5450,7 @@ func (s *integrationMDMTestSuite) TestOrbitConfigNudgeSettings() { var tmResp teamResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ - MacOSUpdates: &fleet.MacOSUpdates{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ Deadline: optjson.SetString("1992-01-01"), MinimumVersion: optjson.SetString("13.1.1"), }, @@ -5461,7 +5460,7 @@ func (s *integrationMDMTestSuite) TestOrbitConfigNudgeSettings() { resp = orbitGetConfigResponse{} s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp) - wantCfg, err = fleet.NewNudgeConfig(fleet.MacOSUpdates{Deadline: optjson.SetString("1992-01-01"), MinimumVersion: optjson.SetString("13.1.1")}) + wantCfg, err = fleet.NewNudgeConfig(fleet.AppleOSUpdateSettings{Deadline: optjson.SetString("1992-01-01"), MinimumVersion: optjson.SetString("13.1.1")}) require.NoError(t, err) require.Equal(t, wantCfg, resp.NudgeConfig) require.Equal(t, wantCfg.OSVersionRequirements[0].RequiredInstallationDate.String(), "1992-01-01 04:00:00 +0000 UTC") @@ -5483,7 +5482,7 @@ func (s *integrationMDMTestSuite) TestOrbitConfigNudgeSettings() { resp = orbitGetConfigResponse{} s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h2.OrbitNodeKey)), http.StatusOK, &resp) - wantCfg, err = fleet.NewNudgeConfig(fleet.MacOSUpdates{Deadline: optjson.SetString("2022-01-04"), MinimumVersion: optjson.SetString("12.1.3")}) + wantCfg, err = fleet.NewNudgeConfig(fleet.AppleOSUpdateSettings{Deadline: optjson.SetString("2022-01-04"), MinimumVersion: optjson.SetString("12.1.3")}) require.NoError(t, err) require.Equal(t, wantCfg, resp.NudgeConfig) require.Equal(t, wantCfg.OSVersionRequirements[0].RequiredInstallationDate.String(), "2022-01-04 04:00:00 +0000 UTC") @@ -8293,7 +8292,6 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { // lock the host without viewing the PIN s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) - } func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { @@ -9219,7 +9217,6 @@ func (s *integrationMDMTestSuite) TestAPNsPushCron() { err = SendPushesToPendingDevices(ctx, s.ds, s.mdmCommander, s.logger) require.NoError(t, err) require.Len(t, recordedPushes, 0) - } func (s *integrationMDMTestSuite) TestMDMRequestWithoutCerts() { diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 933d1dd3db..fb717b35d2 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -100,12 +100,14 @@ github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMDefaultTeam string github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMEnabledAndConfigured bool github.com/fleetdm/fleet/v4/server/fleet/MDM AppleBMTermsExpired bool github.com/fleetdm/fleet/v4/server/fleet/MDM EnabledAndConfigured bool -github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSUpdates fleet.MacOSUpdates -github.com/fleetdm/fleet/v4/server/fleet/MacOSUpdates MinimumVersion optjson.String +github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSUpdates fleet.AppleOSUpdateSettings +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings MinimumVersion optjson.String github.com/fleetdm/fleet/v4/pkg/optjson/String Set bool github.com/fleetdm/fleet/v4/pkg/optjson/String Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/String Value string -github.com/fleetdm/fleet/v4/server/fleet/MacOSUpdates Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/MDM IOSUpdates fleet.AppleOSUpdateSettings +github.com/fleetdm/fleet/v4/server/fleet/MDM IPadOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsUpdates fleet.WindowsUpdates github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates DeadlineDays optjson.Int github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt index cd1278ff04..9c92a3696e 100644 --- a/tools/cloner-check/generated_files/teammdm.txt +++ b/tools/cloner-check/generated_files/teammdm.txt @@ -1,10 +1,12 @@ github.com/fleetdm/fleet/v4/server/fleet/TeamMDM EnableDiskEncryption bool -github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSUpdates fleet.MacOSUpdates -github.com/fleetdm/fleet/v4/server/fleet/MacOSUpdates MinimumVersion optjson.String +github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSUpdates fleet.AppleOSUpdateSettings +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings MinimumVersion optjson.String github.com/fleetdm/fleet/v4/pkg/optjson/String Set bool github.com/fleetdm/fleet/v4/pkg/optjson/String Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/String Value string -github.com/fleetdm/fleet/v4/server/fleet/MacOSUpdates Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/TeamMDM IOSUpdates fleet.AppleOSUpdateSettings +github.com/fleetdm/fleet/v4/server/fleet/TeamMDM IPadOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsUpdates fleet.WindowsUpdates github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates DeadlineDays optjson.Int github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool From 544d5b20c497d484014567c45ad6adf03f386b1b Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Wed, 24 Jul 2024 14:42:53 -0300 Subject: [PATCH 06/11] increase Apple SCEP renewal period to 180 days (#20697) related to #19684 # 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] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/19684-renew-scep-180 | 1 + server/service/apple_mdm.go | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changes/19684-renew-scep-180 diff --git a/changes/19684-renew-scep-180 b/changes/19684-renew-scep-180 new file mode 100644 index 0000000000..131c08ff51 --- /dev/null +++ b/changes/19684-renew-scep-180 @@ -0,0 +1 @@ +* Increase threshold to renew Apple SCEP certificates for MDM enrollments to 180 days. diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 4b734593e7..5e0d149698 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -3258,7 +3258,7 @@ func ReconcileAppleProfiles( // scepCertRenewalThresholdDays defines the number of days before a SCEP // certificate must be renewed. -const scepCertRenewalThresholdDays = 30 +const scepCertRenewalThresholdDays = 180 // maxCertsRenewalPerRun specifies the maximum number of certificates to renew // in a single cron run. @@ -3267,8 +3267,8 @@ const scepCertRenewalThresholdDays = 30 // day, and we have room for 24,000 * scepCertRenewalThresholdDays total // renewals. // -// For a default of 30 days as a threshold this gives us room for a fleet of -// 720,000 devices expiring at the same time. +// For a default of 180 days as a threshold this gives us room for a fleet of +// ~4 million devices expiring at the same time. const maxCertsRenewalPerRun = 100 func RenewSCEPCertificates( From 90a1ac9faa40bd1268a7a43ab2449c4327a90ee3 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Wed, 24 Jul 2024 19:46:24 +0200 Subject: [PATCH 07/11] iOS and iPadOS device details refetch (#20678) Part 1 of #19447 - iOS and iPadOS device details refetch can now be triggered with the existing `POST /api/latest/fleet/hosts/:id/refetch` endpoint # Checklist for submitter - [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] Added/updated tests - [x] Manual QA for all new/changed functionality --- changes/19447-ios-ipados-software | 1 + cmd/fleet/cron.go | 23 +----- server/mdm/apple/commander.go | 27 +++++++ server/service/apple_mdm.go | 1 + server/service/hosts.go | 38 ++++++++- server/service/integration_mdm_test.go | 108 +++++++++++++++++++------ 6 files changed, 148 insertions(+), 50 deletions(-) create mode 100644 changes/19447-ios-ipados-software diff --git a/changes/19447-ios-ipados-software b/changes/19447-ios-ipados-software new file mode 100644 index 0000000000..755f37b0c6 --- /dev/null +++ b/changes/19447-ios-ipados-software @@ -0,0 +1 @@ +- iOS and iPadOS device details refetch can now be triggered with the existing `POST /api/latest/fleet/hosts/:id/refetch` endpoint. diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 4e8d547fe4..03d3621b19 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -1305,28 +1305,7 @@ func newIPhoneIPadRefetcher( } logger.Log("msg", "sending commands to refetch", "count", len(uuids), "lookup-duration", time.Since(start)) commandUUID := fleet.RefetchCommandUUIDPrefix + uuid.NewString() - if err := commander.EnqueueCommand(ctx, uuids, fmt.Sprintf(` - - - - Command - - Queries - - DeviceName - DeviceCapacity - AvailableDeviceCapacity - OSVersion - WiFiMAC - ProductName - - RequestType - DeviceInformation - - CommandUUID - %s - -`, commandUUID)); err != nil { + if err := commander.DeviceInformation(ctx, uuids, commandUUID); err != nil { return ctxerr.Wrap(ctx, err, "send DeviceInformation commands to ios and ipados devices") } return nil diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 43ea5d7143..7c246e0743 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -273,6 +273,33 @@ func (svc *MDMAppleCommander) DeviceConfigured(ctx context.Context, hostUUID, cm return svc.EnqueueCommand(ctx, []string{hostUUID}, raw) } +func (svc *MDMAppleCommander) DeviceInformation(ctx context.Context, hostUUIDs []string, cmdUUID string) error { + raw := fmt.Sprintf(` + + + + Command + + Queries + + DeviceName + DeviceCapacity + AvailableDeviceCapacity + OSVersion + WiFiMAC + ProductName + + RequestType + DeviceInformation + + CommandUUID + %s + +`, cmdUUID) + + return svc.EnqueueCommand(ctx, hostUUIDs, raw) +} + // EnqueueCommand takes care of enqueuing the commands and sending push // notifications to the devices. // diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 5e0d149698..fa32591168 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2752,6 +2752,7 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ host.PrimaryMac = wifiMac host.HardwareModel = productName host.DetailUpdatedAt = time.Now() + host.RefetchRequested = false if err := svc.ds.UpdateHost(r.Context, host); err != nil { return nil, ctxerr.Wrap(r.Context, err, "failed to update host") } diff --git a/server/service/hosts.go b/server/service/hosts.go index 4de8dd091a..18f6831d82 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "encoding/csv" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -30,6 +31,7 @@ import ( "github.com/fleetdm/fleet/v4/server/worker" "github.com/go-kit/log/level" "github.com/gocarina/gocsv" + "github.com/google/uuid" ) // HostDetailResponse is the response struct that contains the full host information @@ -1008,12 +1010,15 @@ func refetchHostEndpoint(ctx context.Context, request interface{}, svc fleet.Ser } func (svc *Service) RefetchHost(ctx context.Context, id uint) error { + var host *fleet.Host + // iOS and iPadOS refetch are not authenticated with device token because these devices do not have Fleet Desktop if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) { - if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + var err error + if err = svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return err } - host, err := svc.ds.HostLite(ctx, id) + host, err = svc.ds.HostLite(ctx, id) if err != nil { return ctxerr.Wrap(ctx, err, "find host for refetch") } @@ -1025,6 +1030,17 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { } } + if host != nil && (host.Platform == "ios" || host.Platform == "ipados") { + err := svc.verifyMDMConfiguredAndConnected(ctx, host) + if err != nil { + return err + } + err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchCommandUUIDPrefix+uuid.NewString()) + if err != nil { + return ctxerr.Wrap(ctx, err, "refetch host with MDM") + } + } + if err := svc.ds.UpdateHostRefetchRequested(ctx, id, true); err != nil { return ctxerr.Wrap(ctx, err, "save host") } @@ -1032,6 +1048,24 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { return nil } +func (svc *Service) verifyMDMConfiguredAndConnected(ctx context.Context, host *fleet.Host) error { + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } + return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") + } + connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet") + } + if !connected { + return ctxerr.Wrap(ctx, + fleet.NewInvalidArgumentError("id", "Host does not have MDM turned on.")) + } + return nil +} + func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) { if !opts.ExcludeSoftware { if err := svc.ds.LoadHostSoftware(ctx, host, opts.IncludeCVEScores); err != nil { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 3ada175919..eb6bf02ed1 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -771,6 +771,43 @@ func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testi return fleetHost, mdmDevice } +func (s *integrationMDMTestSuite) createAppleMobileHostThenEnrollMDM(platform string) (*fleet.Host, *mdmtest.TestAppleMDMClient) { + ctx := context.Background() + t := s.T() + + // create a host with minimal information and the serial, no uuid/osquery id + // (as when created via DEP sync). + dbZeroTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) + serialNumber := mdmtest.RandSerialNumber() + fleetHost, err := s.ds.NewHost(ctx, &fleet.Host{ + HardwareSerial: serialNumber, + Platform: platform, + LastEnrolledAt: dbZeroTime, + DetailUpdatedAt: dbZeroTime, + RefetchRequested: true, + }) + require.NoError(t, err) + require.Equal(t, dbZeroTime, fleetHost.LastEnrolledAt) + + // Perform the MDM enrollment. + mdmEnrollInfo := mdmtest.AppleEnrollInfo{ + SCEPChallenge: s.scepChallenge, + SCEPURL: s.server.URL + apple_mdm.SCEPPath, + MDMURL: s.server.URL + apple_mdm.MDMPath, + } + model := "iPhone14,6" + if platform == "ipados" { + model = "iPad13,18" + } + mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmEnrollInfo, model) + mdmDevice.SerialNumber = serialNumber + err = mdmDevice.Enroll() + require.NoError(t, err) + + return fleetHost, mdmDevice + +} + func createWindowsHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestWindowsMDMClient) { host := createOrbitEnrolledHost(t, "windows", "h1", ds) mdmDevice := mdmtest.NewTestMDMClientWindowsProgramatic(fleetServerURL, *host.OrbitNodeKey) @@ -9311,37 +9348,56 @@ func (s *integrationMDMTestSuite) TestInvalidCommandUUID() { func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() { t := s.T() - ctx := context.Background() - // create a host with minimal information and the serial, no uuid/osquery id - // (as when created via DEP sync). - dbZeroTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC) - serialNumber := mdmtest.RandSerialNumber() - h, err := s.ds.NewHost(ctx, &fleet.Host{ - HardwareSerial: serialNumber, - Platform: "ios", - LastEnrolledAt: dbZeroTime, - DetailUpdatedAt: dbZeroTime, - RefetchRequested: true, - }) - require.NoError(t, err) - require.Equal(t, dbZeroTime, h.LastEnrolledAt) - - // Perform the MDM enrollment. - mdmEnrollInfo := mdmtest.AppleEnrollInfo{ - SCEPChallenge: s.scepChallenge, - SCEPURL: s.server.URL + apple_mdm.SCEPPath, - MDMURL: s.server.URL + apple_mdm.MDMPath, - } - mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmEnrollInfo, "iPhone14,6") - mdmDevice.SerialNumber = serialNumber - err = mdmDevice.Enroll() - require.NoError(t, err) + h, _ := s.createAppleMobileHostThenEnrollMDM("ios") // fetch the host, it will match the one created above // (NOTE: cannot check the returned OrbitNodeKey, this field is not part of the response) var hostResp getHostResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp) require.Equal(t, h.ID, hostResp.Host.ID) - require.NotEqual(t, dbZeroTime, hostResp.Host.LastEnrolledAt) + require.NotEqual(t, h.LastEnrolledAt, hostResp.Host.LastEnrolledAt) + + h, _ = s.createAppleMobileHostThenEnrollMDM("ipados") + + // fetch the host, it will match the one created above + // (NOTE: cannot check the returned OrbitNodeKey, this field is not part of the response) + hostResp = getHostResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp) + require.Equal(t, h.ID, hostResp.Host.ID) + require.NotEqual(t, h.LastEnrolledAt, hostResp.Host.LastEnrolledAt) + +} + +func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() { + t := s.T() + + // Try to refetch host that is not MDM enrolled + serialNumber := mdmtest.RandSerialNumber() + fleetHost, err := s.ds.NewHost(context.Background(), &fleet.Host{ + HardwareSerial: serialNumber, + Platform: "ipados", + LastEnrolledAt: time.Now(), + DetailUpdatedAt: time.Now(), + RefetchRequested: true, + }) + require.NoError(t, err) + r := s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", fleetHost.ID), nil, http.StatusUnprocessableEntity, "error", + "host is not enrolled in MDM") + assert.Contains(t, extractServerErrorText(r.Body), "Host does not have MDM turned on") + + // Try to refetch an MDM enrolled host + host, mdmClient := s.createAppleMobileHostThenEnrollMDM("ios") + _ = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK) + + // Check the MDM command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "DeviceInformation", cmd.Command.RequestType) + + var hostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) + assert.Equal(t, host.ID, hostResp.Host.ID) + assert.True(t, host.RefetchRequested) } From 6f45ff4e5a7f1b29e54389ccb6e6f79fb61a94fb Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:07:46 -0400 Subject: [PATCH 08/11] Usage stats reference docs: Add items (#20666) We forgot to document the items we added as part of this PR: - https://github.com/fleetdm/fleet/pull/19078 --- docs/Using Fleet/Usage-statistics.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/Using Fleet/Usage-statistics.md b/docs/Using Fleet/Usage-statistics.md index 16e5339e6d..28413ee174 100644 --- a/docs/Using Fleet/Usage-statistics.md +++ b/docs/Using Fleet/Usage-statistics.md @@ -28,6 +28,12 @@ Below is the JSON payload that is sent to Fleet Device Management Inc: "numWeeklyActiveUsers": 999, "numWeeklyPolicyViolationDaysActual": 999, "numWeeklyPolicyViolationDaysPossible": 999, + "numSoftwareVersions": 999, + "numHostSoftwares": 999, + "numSoftwareTitles": 999, + "numHostSoftwareInstalledPaths": 999, + "numSoftwareCPEs": 999, + "numSoftwareCVEs": 999, "hostsEnrolledByOperatingSystem": { "darwin": [ { From d99e1cf23d228b337a3b4efa171e6859a0fbe531 Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:10:19 -0500 Subject: [PATCH 09/11] Update communications.md (#20607) Closes https://github.com/fleetdm/confidential/issues/7300 --- handbook/company/communications.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/handbook/company/communications.md b/handbook/company/communications.md index 57a1b5be52..f8744b87d8 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -235,7 +235,7 @@ Fleet uses skip-level 1:1 meetings as a recurring pulse check to encourage [valu 3. Link the skip-level agenda in the calendar event description before saving. -### Zoom +## Zoom We use [Zoom](https://zoom.us) for virtual meetings at Fleet, and it is important that every team member feels comfortable hosting, joining, and scheduling Zoom meetings. By default, Zoom settings are the same for all Fleet team members, but you can change your personal settings on your [profile settings](https://zoom.us/profile/setting) page. @@ -271,6 +271,15 @@ Here are some tips for troubleshooting Gong: For those with a Gong seat or scheduling a call with someone in attendance that has a Gong seat who does not wish for their Zoom call with an external party to record, make sure your calendar event title contains `[no shadows]`. You can also read the [complete list of exclusion rules](https://docs.google.com/document/d/1OOxLajvqf-on5I8viN7k6aCzqEWS2B24_mE47OefutE/edit?usp=sharing). +### Sharing a local Zoom recording + +In some instances, you may need to record a call locally (i.e. save the recording on your computer and not in the cloud ☁️). You can use the following steps to upload the call recording: +1. Log into [Zoom](https://zoom.us/recording) using SSO (Single-Sign-On) and go to "Recordings". +2. After the recording is finished processing (which sometimes can take a couple of hours), you will see the hotdog (or "overflow menu") menu appear. Select the call you want to share and use the hotdog menu to download all files. +3. Rename the mp4 file to match the meeting name and prefix it with the date of the recording (e.g. "YYYY-MM-DD *Name of the calendar event*"). +4. Upload the mp4 recording to the [whiteboards folder](https://drive.google.com/drive/u/0/folders/1prO98fmB2WKzpubZ2-z0sju9dQ4ijpNE) in Google Drive. + + ## Levels of confidentiality Fleet uses these levels to standardize a commitment to minimal esotericism across the company. From 600617f4c47d2de3f6261146ae098796c644a3f4 Mon Sep 17 00:00:00 2001 From: Alex Mitchell <105945793+alexmitchelliii@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:14:24 -0500 Subject: [PATCH 10/11] Update communications.md (#20550) Remove references to hello --------- Co-authored-by: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> --- handbook/company/communications.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handbook/company/communications.md b/handbook/company/communications.md index f8744b87d8..8d8a7c1f60 100644 --- a/handbook/company/communications.md +++ b/handbook/company/communications.md @@ -745,7 +745,7 @@ In responding to security questionnaires, Fleet endeavors to provide full transp ## Getting a contract signed -If a contract is ready for signature and requires no review or revision, the requestor logins into DocuSign using hello@ from the 1Password vault and routes the agreement to the CEO for signature. +If a contract is ready for signature and requires no review or revision, log into DocuSign (credentials in 1Password) and route the agreement to the CEO for signature. When a contract is going to be routed for signature by someone outside of Fleet (i.e. the vendor or customer), the requestor is responsible for working with the other party to make sure the document gets routed to the CEO for signature. @@ -753,7 +753,7 @@ The SLA for contract signature is **2 business days**. Please do not follow up o _**Note:** Signature open time for the CEO is not currently measured, to avoid the overhead of creating separate signature issues to measure open and close time. This may change as signature volume increases._ -> _**Note:** If a contract is ready for signature and requires no review or revision, the requestor logins into DocuSign using hello@ from the 1Password and routes the agreement to the CEO for signature._ +> _**Note:** If a contract is ready for signature and requires no review or revision, log into DocuSign (credentials in 1Password) and route the agreement to the CEO for signature._ Please use [Fleet's billing email address](https://fleetdm.com/handbook/company/communications#email-relays) for all contracts, and never use individual emails except for signature. If the page to sign includes any individual emails in the docusign contract, please remove it before routing to the CEO for signature. From 010bc54677082198b3a9bf9f44818f6177904e5b Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:27:34 -0400 Subject: [PATCH 11/11] Fleet UI: Update profile activities to not include host platform (#20696) --- .../ActivityItem/ActivityItem.tsx | 59 ++++--------------- 1 file changed, 10 insertions(+), 49 deletions(-) diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 9889bf98a5..387fc30dde 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -36,19 +36,16 @@ const PREMIUM_ACTIVITIES = new Set([ const getProfileMessageSuffix = ( isPremiumTier: boolean, - platform: "apple" | "windows", teamName?: string | null ) => { - const platformDisplayName = - platform === "apple" ? "macOS, iOS, and iPadOS" : "Windows"; - let messageSuffix = <>all {platformDisplayName} hosts; + let messageSuffix = <>hosts; if (isPremiumTier) { messageSuffix = teamName ? ( <> - {platformDisplayName} hosts assigned to the {teamName} team + the {teamName} team ) : ( - <>{platformDisplayName} hosts with no team + <>hosts with no team ); } return messageSuffix; @@ -359,12 +356,7 @@ const TAGGED_TEMPLATES = { ) : ( <>a configuration profile )}{" "} - to{" "} - {getProfileMessageSuffix( - isPremiumTier, - "apple", - activity.details?.team_name - )} + to {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)} . ); @@ -383,12 +375,7 @@ const TAGGED_TEMPLATES = { <>a configuration profile )}{" "} from{" "} - {getProfileMessageSuffix( - isPremiumTier, - "apple", - activity.details?.team_name - )} - . + {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}. ); }, @@ -399,7 +386,6 @@ const TAGGED_TEMPLATES = { edited configuration profiles for{" "} {getProfileMessageSuffix( isPremiumTier, - "apple", activity.details?.team_name )}{" "} via fleetctl. @@ -419,12 +405,7 @@ const TAGGED_TEMPLATES = { ) : ( <>a configuration profile )}{" "} - to{" "} - {getProfileMessageSuffix( - isPremiumTier, - "windows", - activity.details?.team_name - )} + to {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)} . ); @@ -443,12 +424,7 @@ const TAGGED_TEMPLATES = { <>a configuration profile )}{" "} from{" "} - {getProfileMessageSuffix( - isPremiumTier, - "windows", - activity.details?.team_name - )} - . + {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}. ); }, @@ -459,7 +435,6 @@ const TAGGED_TEMPLATES = { edited configuration profiles for{" "} {getProfileMessageSuffix( isPremiumTier, - "windows", activity.details?.team_name )}{" "} via fleetctl. @@ -779,12 +754,7 @@ const TAGGED_TEMPLATES = { added declaration (DDM) profile {activity.details?.profile_name} {" "} - to{" "} - {getProfileMessageSuffix( - isPremiumTier, - "apple", - activity.details?.team_name - )} + to {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)} . ); @@ -795,12 +765,7 @@ const TAGGED_TEMPLATES = { {" "} removed declaration (DDM) profile{" "} {activity.details?.profile_name} from{" "} - {getProfileMessageSuffix( - isPremiumTier, - "apple", - activity.details?.team_name - )} - . + {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}. ); }, @@ -810,11 +775,7 @@ const TAGGED_TEMPLATES = { {" "} edited declaration (DDM) profiles{" "} {activity.details?.profile_name} for{" "} - {getProfileMessageSuffix( - isPremiumTier, - "apple", - activity.details?.team_name - )}{" "} + {getProfileMessageSuffix(isPremiumTier, activity.details?.team_name)}{" "} via fleetctl. );