Regenerate schema.sql

This commit is contained in:
Martin Angers 2024-03-27 08:19:21 -04:00
commit b449900602
131 changed files with 8430 additions and 687 deletions

View File

@ -0,0 +1 @@
- Fix a bug where `null` or excluded `smtp_settings` caused a UI 500.

View File

@ -0,0 +1,5 @@
Added integration with Google Calendar.
- Fleet admins can enable Google Calendar integration by using a Google service account with domain-wide delegation.
- Calendar integration is enabled at the team level for specific team policies.
- If the policy is failing, a calendar event will be put on the host user's calendar for the 3rd Tuesday of the month.
- During the event, Fleet will fire a webhook. IT admins should use this webhook to trigger a script or MDM command that will remediate the issue.

View File

@ -0,0 +1 @@
- Fix a bug where valid MDM enrollments would show up as unmanaged (EnrollmentState 3)

699
cmd/fleet/calendar_cron.go Normal file
View File

@ -0,0 +1,699 @@
package main
import (
"context"
"errors"
"fmt"
"slices"
"sync"
"time"
"github.com/fleetdm/fleet/v4/ee/server/calendar"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/schedule"
"github.com/go-kit/log"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
)
func newCalendarSchedule(
ctx context.Context,
instanceID string,
ds fleet.Datastore,
logger kitlog.Logger,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronCalendar)
defaultInterval = 5 * time.Minute
)
logger = kitlog.With(logger, "cron", name)
s := schedule.New(
ctx, name, instanceID, defaultInterval, ds, ds,
schedule.WithAltLockID("calendar"),
schedule.WithLogger(logger),
schedule.WithJob(
"calendar_events_cleanup",
func(ctx context.Context) error {
return cronCalendarEventsCleanup(ctx, ds, logger)
},
),
schedule.WithJob(
"calendar_events",
func(ctx context.Context) error {
return cronCalendarEvents(ctx, ds, logger)
},
),
)
return s, nil
}
func cronCalendarEvents(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return fmt.Errorf("load app config: %w", err)
}
if len(appConfig.Integrations.GoogleCalendar) == 0 {
return nil
}
googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0]
domain := googleCalendarIntegrationConfig.Domain
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{
User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
},
}, fleet.ListOptions{})
if err != nil {
return fmt.Errorf("list teams: %w", err)
}
for _, team := range teams {
if err := cronCalendarEventsForTeam(
ctx, ds, googleCalendarIntegrationConfig, *team, appConfig.OrgInfo.OrgName, domain, logger,
); err != nil {
level.Info(logger).Log("msg", "events calendar cron", "team_id", team.ID, "err", err)
}
}
return nil
}
func createUserCalendarFromConfig(ctx context.Context, config *fleet.GoogleCalendarIntegration, logger kitlog.Logger) fleet.UserCalendar {
googleCalendarConfig := calendar.GoogleCalendarConfig{
Context: ctx,
IntegrationConfig: config,
Logger: log.With(logger, "component", "google_calendar"),
}
return calendar.NewGoogleCalendar(&googleCalendarConfig)
}
func cronCalendarEventsForTeam(
ctx context.Context,
ds fleet.Datastore,
calendarConfig *fleet.GoogleCalendarIntegration,
team fleet.Team,
orgName string,
domain string,
logger kitlog.Logger,
) error {
if team.Config.Integrations.GoogleCalendar == nil ||
!team.Config.Integrations.GoogleCalendar.Enable {
return nil
}
policies, err := ds.GetCalendarPolicies(ctx, team.ID)
if err != nil {
return fmt.Errorf("get calendar policy ids: %w", err)
}
if len(policies) == 0 {
return nil
}
logger = kitlog.With(logger, "team_id", team.ID)
//
// NOTEs:
// - We ignore hosts that are passing all policies and do not have an associated email.
// - We get only one host per email that's failing policies (the one with lower host id).
// - On every host, we get only the first email that matches the domain (sorted lexicographically).
//
policyIDs := make([]uint, 0, len(policies))
for _, policy := range policies {
policyIDs = append(policyIDs, policy.ID)
}
hosts, err := ds.GetTeamHostsPolicyMemberships(ctx, domain, team.ID, policyIDs)
if err != nil {
return fmt.Errorf("get team hosts failing policies: %w", err)
}
var (
passingHosts []fleet.HostPolicyMembershipData
failingHosts []fleet.HostPolicyMembershipData
failingHostsWithoutAssociatedEmail []fleet.HostPolicyMembershipData
)
for _, host := range hosts {
if host.Passing { // host is passing all configured policies
if host.Email != "" {
passingHosts = append(passingHosts, host)
}
} else { // host is failing some of the configured policies
if host.Email == "" {
failingHostsWithoutAssociatedEmail = append(failingHostsWithoutAssociatedEmail, host)
} else {
failingHosts = append(failingHosts, host)
}
}
}
level.Debug(logger).Log(
"msg", "summary",
"team_id", team.ID,
"passing_hosts", len(passingHosts),
"failing_hosts", len(failingHosts),
"failing_hosts_without_associated_email", len(failingHostsWithoutAssociatedEmail),
)
// Remove calendar events from hosts that are passing the calendar policies.
//
// We execute this first to remove any calendar events for a user that is now passing
// policies on one of its hosts, and possibly create a new calendar event if they have
// another failing host on the same team.
start := time.Now()
removeCalendarEventsFromPassingHosts(ctx, ds, calendarConfig, passingHosts, logger)
level.Debug(logger).Log(
"msg", "passing_hosts", "took", time.Since(start),
)
// Process hosts that are failing calendar policies.
start = time.Now()
processCalendarFailingHosts(ctx, ds, calendarConfig, orgName, failingHosts, logger)
level.Debug(logger).Log(
"msg", "failing_hosts", "took", time.Since(start),
)
// At last we want to log the hosts that are failing and don't have an associated email.
logHostsWithoutAssociatedEmail(
domain,
failingHostsWithoutAssociatedEmail,
logger,
)
return nil
}
func processCalendarFailingHosts(
ctx context.Context,
ds fleet.Datastore,
calendarConfig *fleet.GoogleCalendarIntegration,
orgName string,
hosts []fleet.HostPolicyMembershipData,
logger kitlog.Logger,
) {
hosts = filterHostsWithSameEmail(hosts)
const consumers = 20
hostsCh := make(chan fleet.HostPolicyMembershipData)
var wg sync.WaitGroup
for i := 0; i < consumers; i++ {
wg.Add(+1)
go func() {
defer wg.Done()
for host := range hostsCh {
logger := log.With(logger, "host_id", host.HostID)
hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEventByEmail(ctx, host.Email)
expiredEvent := false
if err == nil {
if hostCalendarEvent.HostID != host.HostID {
// This calendar event belongs to another host with this associated email,
// thus we skip this entry.
continue // continue with next host
}
if hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusPending {
// This can happen if the host went offline (and never returned results)
// after setting the webhook as pending.
continue // continue with next host
}
now := time.Now()
webhookAlreadyFired := hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusSent
if webhookAlreadyFired && sameDate(now, calendarEvent.StartTime) {
// If the webhook already fired today and the policies are still failing
// we give a grace period of one day for the host before we schedule a new event.
continue // continue with next host
}
if calendarEvent.EndTime.Before(now) {
expiredEvent = true
}
}
userCalendar := createUserCalendarFromConfig(ctx, calendarConfig, logger)
if err := userCalendar.Configure(host.Email); err != nil {
level.Error(logger).Log("msg", "configure user calendar", "err", err)
continue // continue with next host
}
switch {
case err == nil && !expiredEvent:
if err := processFailingHostExistingCalendarEvent(
ctx, ds, userCalendar, orgName, hostCalendarEvent, calendarEvent, host,
); err != nil {
level.Info(logger).Log("msg", "process failing host existing calendar event", "err", err)
continue // continue with next host
}
case fleet.IsNotFound(err) || expiredEvent:
if err := processFailingHostCreateCalendarEvent(
ctx, ds, userCalendar, orgName, host,
); err != nil {
level.Info(logger).Log("msg", "process failing host create calendar event", "err", err)
continue // continue with next host
}
default:
level.Error(logger).Log("msg", "get calendar event from db", "err", err)
continue // continue with next host
}
}
}()
}
for _, host := range hosts {
hostsCh <- host
}
close(hostsCh)
wg.Wait()
}
func filterHostsWithSameEmail(hosts []fleet.HostPolicyMembershipData) []fleet.HostPolicyMembershipData {
minHostPerEmail := make(map[string]fleet.HostPolicyMembershipData)
for _, host := range hosts {
minHost, ok := minHostPerEmail[host.Email]
if !ok {
minHostPerEmail[host.Email] = host
continue
}
if host.HostID < minHost.HostID {
minHostPerEmail[host.Email] = host
}
}
filtered := make([]fleet.HostPolicyMembershipData, 0, len(minHostPerEmail))
for _, host := range minHostPerEmail {
filtered = append(filtered, host)
}
return filtered
}
func processFailingHostExistingCalendarEvent(
ctx context.Context,
ds fleet.Datastore,
calendar fleet.UserCalendar,
orgName string,
hostCalendarEvent *fleet.HostCalendarEvent,
calendarEvent *fleet.CalendarEvent,
host fleet.HostPolicyMembershipData,
) error {
updatedEvent := calendarEvent
updated := false
now := time.Now()
if shouldReloadCalendarEvent(now, calendarEvent, hostCalendarEvent) {
var err error
updatedEvent, _, err = calendar.GetAndUpdateEvent(calendarEvent, func(conflict bool) string {
return generateCalendarEventBody(orgName, host.HostDisplayName, conflict)
})
if err != nil {
return fmt.Errorf("get event calendar on db: %w", err)
}
// Even if fields haven't changed we want to update the calendar_events.updated_at below.
updated = true
//
// TODO(lucas): Check changing updatedEvent to UTC before consuming.
//
}
if updated {
if err := ds.UpdateCalendarEvent(ctx,
calendarEvent.ID,
updatedEvent.StartTime,
updatedEvent.EndTime,
updatedEvent.Data,
); err != nil {
return fmt.Errorf("updating event calendar on db: %w", err)
}
}
eventInFuture := now.Before(updatedEvent.StartTime)
if eventInFuture {
// Nothing else to do as event is in the future.
return nil
}
if now.After(updatedEvent.EndTime) {
return fmt.Errorf(
"unexpected event in the past: now=%s, start_time=%s, end_time=%s",
now, updatedEvent.StartTime, updatedEvent.EndTime,
)
}
//
// Event happening now.
//
if hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusSent {
return nil
}
online, err := isHostOnline(ctx, ds, host.HostID)
if err != nil {
return fmt.Errorf("host online check: %w", err)
}
if !online {
// If host is offline then there's nothing to do.
return nil
}
if err := ds.UpdateHostCalendarWebhookStatus(ctx, host.HostID, fleet.CalendarWebhookStatusPending); err != nil {
return fmt.Errorf("update host calendar webhook status: %w", err)
}
// TODO(lucas): If this doesn't work at scale, then implement a special refetch
// for policies only.
if err := ds.UpdateHostRefetchRequested(ctx, host.HostID, true); err != nil {
return fmt.Errorf("refetch host: %w", err)
}
return nil
}
func shouldReloadCalendarEvent(now time.Time, calendarEvent *fleet.CalendarEvent, hostCalendarEvent *fleet.HostCalendarEvent) bool {
// Check the user calendar every 30 minutes (and not every cron run)
// to reduce load on both Fleet and the calendar service.
if time.Since(calendarEvent.UpdatedAt) > 30*time.Minute {
return true
}
// If the event is supposed to be happening now, we want to check if the user moved/deleted the
// event on the last minute.
if eventHappeningNow(now, calendarEvent) && hostCalendarEvent.WebhookStatus == fleet.CalendarWebhookStatusNone {
return true
}
return false
}
func eventHappeningNow(now time.Time, calendarEvent *fleet.CalendarEvent) bool {
return !now.Before(calendarEvent.StartTime) && now.Before(calendarEvent.EndTime)
}
func sameDate(t1 time.Time, t2 time.Time) bool {
y1, m1, d1 := t1.Date()
y2, m2, d2 := t2.Date()
return y1 == y2 && m1 == m2 && d1 == d2
}
func processFailingHostCreateCalendarEvent(
ctx context.Context,
ds fleet.Datastore,
userCalendar fleet.UserCalendar,
orgName string,
host fleet.HostPolicyMembershipData,
) error {
calendarEvent, err := attemptCreatingEventOnUserCalendar(orgName, host, userCalendar)
if err != nil {
return fmt.Errorf("create event on user calendar: %w", err)
}
if _, err := ds.CreateOrUpdateCalendarEvent(ctx, host.Email, calendarEvent.StartTime, calendarEvent.EndTime, calendarEvent.Data, host.HostID, fleet.CalendarWebhookStatusNone); err != nil {
return fmt.Errorf("create calendar event on db: %w", err)
}
return nil
}
func attemptCreatingEventOnUserCalendar(
orgName string,
host fleet.HostPolicyMembershipData,
userCalendar fleet.UserCalendar,
) (*fleet.CalendarEvent, error) {
year, month, today := time.Now().Date()
preferredDate := getPreferredCalendarEventDate(year, month, today)
for {
calendarEvent, err := userCalendar.CreateEvent(
preferredDate, func(conflict bool) string {
return generateCalendarEventBody(orgName, host.HostDisplayName, conflict)
},
)
var dee fleet.DayEndedError
switch {
case err == nil:
return calendarEvent, nil
case errors.As(err, &dee):
preferredDate = addBusinessDay(preferredDate)
continue
default:
return nil, fmt.Errorf("create event on user calendar: %w", err)
}
}
}
func getPreferredCalendarEventDate(year int, month time.Month, today int) time.Time {
const (
// 3rd Tuesday of Month
preferredWeekDay = time.Tuesday
preferredOrdinal = 3
)
firstDayOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
offset := int(preferredWeekDay - firstDayOfMonth.Weekday())
if offset < 0 {
offset += 7
}
preferredDate := firstDayOfMonth.AddDate(0, 0, offset+(7*(preferredOrdinal-1)))
if today > preferredDate.Day() {
// We are past the preferred date, so we move to next month and calculate again.
month := month + 1
if month == 13 {
month = 1
year += 1
}
return getPreferredCalendarEventDate(year, month, 1)
}
return preferredDate
}
func addBusinessDay(date time.Time) time.Time {
nextBusinessDay := 1
switch weekday := date.Weekday(); weekday {
case time.Friday:
nextBusinessDay += 2
case time.Saturday:
nextBusinessDay += 1
}
return date.AddDate(0, 0, nextBusinessDay)
}
func removeCalendarEventsFromPassingHosts(
ctx context.Context,
ds fleet.Datastore,
calendarConfig *fleet.GoogleCalendarIntegration,
hosts []fleet.HostPolicyMembershipData,
logger kitlog.Logger,
) {
hostIDsByEmail := make(map[string][]uint)
for _, host := range hosts {
hostIDsByEmail[host.Email] = append(hostIDsByEmail[host.Email], host.HostID)
}
type emailWithHosts struct {
email string
hostIDs []uint
}
emails := make([]emailWithHosts, 0, len(hostIDsByEmail))
for email, hostIDs := range hostIDsByEmail {
emails = append(emails, emailWithHosts{
email: email,
hostIDs: hostIDs,
})
}
const consumers = 20
emailsCh := make(chan emailWithHosts)
var wg sync.WaitGroup
for i := 0; i < consumers; i++ {
wg.Add(+1)
go func() {
defer wg.Done()
for email := range emailsCh {
hostCalendarEvent, calendarEvent, err := ds.GetHostCalendarEventByEmail(ctx, email.email)
switch {
case err == nil:
if ok := slices.Contains(email.hostIDs, hostCalendarEvent.HostID); !ok {
// None of the hosts belong to this calendar event.
continue
}
case fleet.IsNotFound(err):
continue
default:
level.Error(logger).Log("msg", "get calendar event from DB", "err", err)
continue
}
userCalendar := createUserCalendarFromConfig(ctx, calendarConfig, logger)
if err := deleteCalendarEvent(ctx, ds, userCalendar, calendarEvent); err != nil {
level.Error(logger).Log("msg", "delete user calendar event", "err", err)
continue
}
}
}()
}
for _, emailWithHostIDs := range emails {
emailsCh <- emailWithHostIDs
}
close(emailsCh)
wg.Wait()
}
func logHostsWithoutAssociatedEmail(
domain string,
hosts []fleet.HostPolicyMembershipData,
logger kitlog.Logger,
) {
if len(hosts) == 0 {
return
}
var hostIDs []uint
for _, host := range hosts {
hostIDs = append(hostIDs, host.HostID)
}
// Logging as debug because this might get logged every 5 minutes.
level.Debug(logger).Log(
"msg", fmt.Sprintf("no %s Google account associated with the hosts", domain),
"host_ids", fmt.Sprintf("%+v", hostIDs),
)
}
func generateCalendarEventBody(orgName, hostDisplayName string, conflict bool) string {
conflictStr := ""
if conflict {
conflictStr = " because there was no remaining availability"
}
return fmt.Sprintf(`Please leave your computer on and connected to power.
Expect an automated restart.
%s reserved this time to fix %s%s.`, orgName, hostDisplayName, conflictStr,
)
}
func isHostOnline(ctx context.Context, ds fleet.Datastore, hostID uint) (bool, error) {
hostLite, err := ds.HostLiteByID(ctx, hostID)
if err != nil {
return false, fmt.Errorf("get host lite: %w", err)
}
status := (&fleet.Host{
DistributedInterval: hostLite.DistributedInterval,
ConfigTLSRefresh: hostLite.ConfigTLSRefresh,
SeenTime: hostLite.SeenTime,
}).Status(time.Now())
switch status {
case fleet.StatusOnline, fleet.StatusNew:
return true, nil
case fleet.StatusOffline, fleet.StatusMIA, fleet.StatusMissing:
return false, nil
default:
return false, fmt.Errorf("unknown host status: %s", status)
}
}
func cronCalendarEventsCleanup(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return fmt.Errorf("load app config: %w", err)
}
var userCalendar fleet.UserCalendar
if len(appConfig.Integrations.GoogleCalendar) > 0 {
googleCalendarIntegrationConfig := appConfig.Integrations.GoogleCalendar[0]
userCalendar = createUserCalendarFromConfig(ctx, googleCalendarIntegrationConfig, logger)
}
// If global setting is disabled, we remove all calendar events from the DB
// (we cannot delete the events from the user calendar because there's no configuration anymore).
if userCalendar == nil {
if err := deleteAllCalendarEvents(ctx, ds, nil, nil); err != nil {
return fmt.Errorf("delete all calendar events: %w", err)
}
// We've deleted all calendar events, nothing else to do.
return nil
}
//
// Feature is configured globally, but now we have to check team by team.
//
teams, err := ds.ListTeams(ctx, fleet.TeamFilter{
User: &fleet.User{
GlobalRole: ptr.String(fleet.RoleAdmin),
},
}, fleet.ListOptions{})
if err != nil {
return fmt.Errorf("list teams: %w", err)
}
for _, team := range teams {
if err := deleteTeamCalendarEvents(ctx, ds, userCalendar, *team); err != nil {
level.Info(logger).Log("msg", "delete team calendar events", "team_id", team.ID, "err", err)
}
}
//
// Delete calendar events from DB that haven't been updated for a while
// (e.g. host was transferred to another team or global).
//
outOfDateCalendarEvents, err := ds.ListOutOfDateCalendarEvents(ctx, time.Now().Add(-48*time.Hour))
if err != nil {
return fmt.Errorf("list out of date calendar events: %w", err)
}
for _, outOfDateCalendarEvent := range outOfDateCalendarEvents {
if err := deleteCalendarEvent(ctx, ds, userCalendar, outOfDateCalendarEvent); err != nil {
return fmt.Errorf("delete user calendar event: %w", err)
}
}
return nil
}
func deleteAllCalendarEvents(
ctx context.Context,
ds fleet.Datastore,
userCalendar fleet.UserCalendar,
teamID *uint,
) error {
calendarEvents, err := ds.ListCalendarEvents(ctx, teamID)
if err != nil {
return fmt.Errorf("list calendar events: %w", err)
}
for _, calendarEvent := range calendarEvents {
if err := deleteCalendarEvent(ctx, ds, userCalendar, calendarEvent); err != nil {
return fmt.Errorf("delete user calendar event: %w", err)
}
}
return nil
}
func deleteTeamCalendarEvents(
ctx context.Context,
ds fleet.Datastore,
userCalendar fleet.UserCalendar,
team fleet.Team,
) error {
if team.Config.Integrations.GoogleCalendar != nil &&
team.Config.Integrations.GoogleCalendar.Enable {
// Feature is enabled, nothing to cleanup.
return nil
}
return deleteAllCalendarEvents(ctx, ds, userCalendar, &team.ID)
}
func deleteCalendarEvent(ctx context.Context, ds fleet.Datastore, userCalendar fleet.UserCalendar, calendarEvent *fleet.CalendarEvent) error {
if userCalendar != nil {
// Only delete events from the user's calendar if the event is in the future.
if eventInFuture := time.Now().Before(calendarEvent.StartTime); eventInFuture {
if err := userCalendar.Configure(calendarEvent.Email); err != nil {
return fmt.Errorf("connect to user calendar: %w", err)
}
if err := userCalendar.DeleteEvent(calendarEvent); err != nil {
return fmt.Errorf("delete calendar event: %w", err)
}
}
}
if err := ds.DeleteCalendarEvent(ctx, calendarEvent.ID); err != nil {
return fmt.Errorf("delete db calendar event: %w", err)
}
return nil
}

View File

@ -0,0 +1,637 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/fleetdm/fleet/v4/ee/server/calendar"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
kitlog "github.com/go-kit/log"
"github.com/stretchr/testify/require"
)
func TestGetPreferredCalendarEventDate(t *testing.T) {
t.Parallel()
date := func(year int, month time.Month, day int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
for _, tc := range []struct {
name string
year int
month time.Month
daysStart int
daysEnd int
expected time.Time
}{
{
name: "March 2024 (before 3rd Tuesday)",
year: 2024,
month: 3,
daysStart: 1,
daysEnd: 19,
expected: date(2024, 3, 19),
},
{
name: "March 2024 (past 3rd Tuesday)",
year: 2024,
month: 3,
daysStart: 20,
daysEnd: 31,
expected: date(2024, 4, 16),
},
{
name: "April 2024 (before 3rd Tuesday)",
year: 2024,
month: 4,
daysStart: 1,
daysEnd: 16,
expected: date(2024, 4, 16),
},
{
name: "April 2024 (after 3rd Tuesday)",
year: 2024,
month: 4,
daysStart: 17,
daysEnd: 30,
expected: date(2024, 5, 21),
},
{
name: "May 2024 (before 3rd Tuesday)",
year: 2024,
month: 5,
daysStart: 1,
daysEnd: 21,
expected: date(2024, 5, 21),
},
{
name: "May 2024 (after 3rd Tuesday)",
year: 2024,
month: 5,
daysStart: 22,
daysEnd: 31,
expected: date(2024, 6, 18),
},
{
name: "Dec 2024 (before 3rd Tuesday)",
year: 2024,
month: 12,
daysStart: 1,
daysEnd: 17,
expected: date(2024, 12, 17),
},
{
name: "Dec 2024 (after 3rd Tuesday)",
year: 2024,
month: 12,
daysStart: 18,
daysEnd: 31,
expected: date(2025, 1, 21),
},
} {
t.Run(tc.name, func(t *testing.T) {
for day := tc.daysStart; day <= tc.daysEnd; day++ {
actual := getPreferredCalendarEventDate(tc.year, tc.month, day)
require.NotEqual(t, actual.Weekday(), time.Saturday)
require.NotEqual(t, actual.Weekday(), time.Sunday)
require.Equal(t, tc.expected, actual)
}
})
}
}
// TestEventForDifferentHost tests case when event exists, but for a different host. Nothing should happen.
// The old event will eventually be cleaned up by the cleanup job, and afterward a new event will be created.
func TestEventForDifferentHost(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
ctx := context.Background()
logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
Integrations: fleet.Integrations{
GoogleCalendar: []*fleet.GoogleCalendarIntegration{
{},
},
},
}, nil
}
teamID1 := uint(1)
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
return []*fleet.Team{
{
ID: teamID1,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
},
},
},
},
}, nil
}
policyID1 := uint(10)
ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
require.Equal(t, teamID1, teamID)
return []fleet.PolicyCalendarData{
{
ID: policyID1,
Name: "Policy 1",
},
}, nil
}
hostID1 := uint(100)
hostID2 := uint(101)
userEmail1 := "user@example.com"
ds.GetTeamHostsPolicyMembershipsFunc = func(
ctx context.Context, domain string, teamID uint, policyIDs []uint,
) ([]fleet.HostPolicyMembershipData, error) {
require.Equal(t, teamID1, teamID)
require.Equal(t, []uint{policyID1}, policyIDs)
return []fleet.HostPolicyMembershipData{
{
HostID: hostID1,
Email: userEmail1,
Passing: false,
},
}, nil
}
// Return an existing event, but for a different host
eventTime := time.Now().Add(time.Hour)
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
require.Equal(t, userEmail1, email)
calEvent := &fleet.CalendarEvent{
ID: 1,
Email: email,
StartTime: eventTime,
EndTime: eventTime,
}
hcEvent := &fleet.HostCalendarEvent{
ID: 1,
HostID: hostID2,
CalendarEventID: 1,
WebhookStatus: fleet.CalendarWebhookStatusNone,
}
return hcEvent, calEvent, nil
}
err := cronCalendarEvents(ctx, ds, logger)
require.NoError(t, err)
}
func TestCalendarEventsMultipleHosts(t *testing.T) {
ds := new(mock.Store)
ctx := context.Background()
logger := kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
t.Cleanup(func() {
calendar.ClearMockEvents()
})
// TODO(lucas): Test!
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
requestBodyBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
t.Logf("webhook request: %s\n", requestBodyBytes)
}))
t.Cleanup(func() {
webhookServer.Close()
})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
Integrations: fleet.Integrations{
GoogleCalendar: []*fleet.GoogleCalendarIntegration{
{
Domain: "example.com",
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: "calendar-mock@example.com",
},
},
},
},
}, nil
}
teamID1 := uint(1)
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
return []*fleet.Team{
{
ID: teamID1,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: webhookServer.URL,
},
},
},
},
}, nil
}
policyID1 := uint(10)
policyID2 := uint(11)
ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
require.Equal(t, teamID1, teamID)
return []fleet.PolicyCalendarData{
{
ID: policyID1,
Name: "Policy 1",
},
{
ID: policyID2,
Name: "Policy 2",
},
}, nil
}
hostID1, userEmail1 := uint(100), "user1@example.com"
hostID2, userEmail2 := uint(101), "user2@example.com"
hostID3, userEmail3 := uint(102), "user3@other.com"
hostID4, userEmail4 := uint(103), "user4@other.com"
ds.GetTeamHostsPolicyMembershipsFunc = func(
ctx context.Context, domain string, teamID uint, policyIDs []uint,
) ([]fleet.HostPolicyMembershipData, error) {
require.Equal(t, teamID1, teamID)
require.Equal(t, []uint{policyID1, policyID2}, policyIDs)
return []fleet.HostPolicyMembershipData{
{
HostID: hostID1,
Email: userEmail1,
Passing: false,
},
{
HostID: hostID2,
Email: userEmail2,
Passing: true,
},
{
HostID: hostID3,
Email: userEmail3,
Passing: false,
},
{
HostID: hostID4,
Email: userEmail4,
Passing: true,
},
}, nil
}
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
return nil, nil, notFoundErr{}
}
ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context,
email string,
startTime, endTime time.Time,
data []byte,
hostID uint,
webhookStatus fleet.CalendarWebhookStatus,
) (*fleet.CalendarEvent, error) {
switch email {
case userEmail1:
require.Equal(t, hostID1, hostID)
case userEmail2:
require.Equal(t, hostID2, hostID)
case userEmail3:
require.Equal(t, hostID3, hostID)
case userEmail4:
require.Equal(t, hostID4, hostID)
}
require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus)
require.NotEmpty(t, data)
require.NotZero(t, startTime)
require.NotZero(t, endTime)
// Currently, the returned calendar event is unused.
return nil, nil
}
err := cronCalendarEvents(ctx, ds, logger)
require.NoError(t, err)
}
type notFoundErr struct{}
func (n notFoundErr) IsNotFound() bool {
return true
}
func (n notFoundErr) Error() string {
return "not found"
}
func TestCalendarEvents1KHosts(t *testing.T) {
ds := new(mock.Store)
ctx := context.Background()
var logger kitlog.Logger
if os.Getenv("CALENDAR_TEST_LOGGING") != "" {
logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stdout))
} else {
logger = kitlog.NewNopLogger()
}
t.Cleanup(func() {
calendar.ClearMockEvents()
})
// TODO(lucas): Use for the test.
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST", r.Method)
requestBodyBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
t.Logf("webhook request: %s\n", requestBodyBytes)
}))
t.Cleanup(func() {
webhookServer.Close()
})
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{
Integrations: fleet.Integrations{
GoogleCalendar: []*fleet.GoogleCalendarIntegration{
{
Domain: "example.com",
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: "calendar-mock@example.com",
},
},
},
},
}, nil
}
teamID1 := uint(1)
teamID2 := uint(2)
teamID3 := uint(3)
teamID4 := uint(4)
teamID5 := uint(5)
ds.ListTeamsFunc = func(ctx context.Context, filter fleet.TeamFilter, opt fleet.ListOptions) ([]*fleet.Team, error) {
return []*fleet.Team{
{
ID: teamID1,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: webhookServer.URL,
},
},
},
},
{
ID: teamID2,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: webhookServer.URL,
},
},
},
},
{
ID: teamID3,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: webhookServer.URL,
},
},
},
},
{
ID: teamID4,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: webhookServer.URL,
},
},
},
},
{
ID: teamID5,
Config: fleet.TeamConfig{
Integrations: fleet.TeamIntegrations{
GoogleCalendar: &fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: webhookServer.URL,
},
},
},
},
}, nil
}
policyID1 := uint(10)
policyID2 := uint(11)
policyID3 := uint(12)
policyID4 := uint(13)
policyID5 := uint(14)
policyID6 := uint(15)
policyID7 := uint(16)
policyID8 := uint(17)
policyID9 := uint(18)
policyID10 := uint(19)
ds.GetCalendarPoliciesFunc = func(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
switch teamID {
case teamID1:
return []fleet.PolicyCalendarData{
{
ID: policyID1,
Name: "Policy 1",
},
{
ID: policyID2,
Name: "Policy 2",
},
}, nil
case teamID2:
return []fleet.PolicyCalendarData{
{
ID: policyID3,
Name: "Policy 3",
},
{
ID: policyID4,
Name: "Policy 4",
},
}, nil
case teamID3:
return []fleet.PolicyCalendarData{
{
ID: policyID5,
Name: "Policy 5",
},
{
ID: policyID6,
Name: "Policy 6",
},
}, nil
case teamID4:
return []fleet.PolicyCalendarData{
{
ID: policyID7,
Name: "Policy 7",
},
{
ID: policyID8,
Name: "Policy 8",
},
}, nil
case teamID5:
return []fleet.PolicyCalendarData{
{
ID: policyID9,
Name: "Policy 9",
},
{
ID: policyID10,
Name: "Policy 10",
},
}, nil
default:
return nil, notFoundErr{}
}
}
hosts := make([]fleet.HostPolicyMembershipData, 0, 1000)
for i := 0; i < 1000; i++ {
hosts = append(hosts, fleet.HostPolicyMembershipData{
Email: fmt.Sprintf("user%d@example.com", i),
Passing: i%2 == 0,
HostID: uint(i),
HostDisplayName: fmt.Sprintf("display_name%d", i),
HostHardwareSerial: fmt.Sprintf("serial%d", i),
})
}
ds.GetTeamHostsPolicyMembershipsFunc = func(
ctx context.Context, domain string, teamID uint, policyIDs []uint,
) ([]fleet.HostPolicyMembershipData, error) {
var start, end int
switch teamID {
case teamID1:
start, end = 0, 200
case teamID2:
start, end = 200, 400
case teamID3:
start, end = 400, 600
case teamID4:
start, end = 600, 800
case teamID5:
start, end = 800, 1000
}
return hosts[start:end], nil
}
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
return nil, nil, notFoundErr{}
}
eventsCreated := 0
var eventsCreatedMu sync.Mutex
eventPerHost := make(map[uint]*fleet.CalendarEvent)
ds.CreateOrUpdateCalendarEventFunc = func(ctx context.Context,
email string,
startTime, endTime time.Time,
data []byte,
hostID uint,
webhookStatus fleet.CalendarWebhookStatus,
) (*fleet.CalendarEvent, error) {
require.Equal(t, fmt.Sprintf("user%d@example.com", hostID), email)
eventsCreatedMu.Lock()
eventsCreated += 1
eventPerHost[hostID] = &fleet.CalendarEvent{
ID: hostID,
Email: email,
StartTime: startTime,
EndTime: endTime,
Data: data,
UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{
CreateTimestamp: fleet.CreateTimestamp{
CreatedAt: time.Now(),
},
UpdateTimestamp: fleet.UpdateTimestamp{
UpdatedAt: time.Now(),
},
},
}
eventsCreatedMu.Unlock()
require.Equal(t, fleet.CalendarWebhookStatusNone, webhookStatus)
require.NotEmpty(t, data)
require.NotZero(t, startTime)
require.NotZero(t, endTime)
// Currently, the returned calendar event is unused.
return nil, nil
}
err := cronCalendarEvents(ctx, ds, logger)
require.NoError(t, err)
createdCalendarEvents := calendar.ListGoogleMockEvents()
require.Equal(t, eventsCreated, 500)
require.Len(t, createdCalendarEvents, 500)
hosts = make([]fleet.HostPolicyMembershipData, 0, 1000)
for i := 0; i < 1000; i++ {
hosts = append(hosts, fleet.HostPolicyMembershipData{
Email: fmt.Sprintf("user%d@example.com", i),
Passing: true,
HostID: uint(i),
HostDisplayName: fmt.Sprintf("display_name%d", i),
HostHardwareSerial: fmt.Sprintf("serial%d", i),
})
}
ds.GetHostCalendarEventByEmailFunc = func(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
hostID, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(email, "user"), "@example.com"))
require.NoError(t, err)
if hostID%2 == 0 {
return nil, nil, notFoundErr{}
}
require.Contains(t, eventPerHost, uint(hostID))
return &fleet.HostCalendarEvent{
ID: uint(hostID),
HostID: uint(hostID),
CalendarEventID: uint(hostID),
WebhookStatus: fleet.CalendarWebhookStatusNone,
}, eventPerHost[uint(hostID)], nil
}
ds.DeleteCalendarEventFunc = func(ctx context.Context, calendarEventID uint) error {
return nil
}
err = cronCalendarEvents(ctx, ds, logger)
require.NoError(t, err)
createdCalendarEvents = calendar.ListGoogleMockEvents()
require.Len(t, createdCalendarEvents, 0)
}

View File

@ -768,6 +768,18 @@ the way that the Fleet server works.
} }
} }
if license.IsPremium() {
if err := cronSchedules.StartCronSchedule(
func() (fleet.CronSchedule, error) {
return newCalendarSchedule(
ctx, instanceID, ds, logger,
)
},
); err != nil {
initFatal(err, "failed to register calendar schedule")
}
}
level.Info(logger).Log("msg", fmt.Sprintf("started cron schedules: %s", strings.Join(cronSchedules.ScheduleNames(), ", "))) level.Info(logger).Log("msg", fmt.Sprintf("started cron schedules: %s", strings.Join(cronSchedules.ScheduleNames(), ", ")))
// StartCollectors starts a goroutine per collector, using ctx to cancel. // StartCollectors starts a goroutine per collector, using ctx to cancel.

View File

@ -145,7 +145,13 @@ func TestApplyTeamSpecs(t *testing.T) {
agentOpts := json.RawMessage(`{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}}`) agentOpts := json.RawMessage(`{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}}`)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{AgentOptions: &agentOpts, MDM: fleet.MDM{EnabledAndConfigured: true}}, nil return &fleet.AppConfig{
AgentOptions: &agentOpts,
MDM: fleet.MDM{EnabledAndConfigured: true},
Integrations: fleet.Integrations{
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
},
}, nil
} }
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) { ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
@ -459,6 +465,46 @@ spec:
HostPercentage: 25, HostPercentage: 25,
}, *teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook, }, *teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook,
) )
// Apply calendar integration
filename = writeTmpYml(
t, `
apiVersion: v1
kind: team
spec:
team:
name: team1
integrations:
google_calendar:
enable_calendar_events: true
webhook_url: https://example.com/webhook
`,
)
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
require.NotNil(t, teamsByName["team1"].Config.Integrations.GoogleCalendar)
assert.Equal(
t, fleet.TeamGoogleCalendarIntegration{
Enable: true,
WebhookURL: "https://example.com/webhook",
}, *teamsByName["team1"].Config.Integrations.GoogleCalendar,
)
// Apply calendar integration -- invalid webhook destination
filename = writeTmpYml(
t, `
apiVersion: v1
kind: team
spec:
team:
name: team1
integrations:
google_calendar:
enable_calendar_events: true
webhook_url: bozo
`,
)
_, err = runAppNoChecks([]string{"apply", "-f", filename})
assert.ErrorContains(t, err, "invalid URI for request")
} }
func writeTmpYml(t *testing.T, contents string) string { func writeTmpYml(t *testing.T, contents string) string {

View File

@ -340,6 +340,7 @@ func TestGetHosts(t *testing.T) {
AuthorEmail: "alice@example.com", AuthorEmail: "alice@example.com",
Resolution: ptr.String("Some resolution"), Resolution: ptr.String("Some resolution"),
TeamID: ptr.Uint(1), TeamID: ptr.Uint(1),
CalendarEventsEnabled: true,
}, },
Response: "passes", Response: "passes",
}, },
@ -354,6 +355,7 @@ func TestGetHosts(t *testing.T) {
AuthorEmail: "alice@example.com", AuthorEmail: "alice@example.com",
Resolution: nil, Resolution: nil,
TeamID: nil, TeamID: nil,
CalendarEventsEnabled: false,
}, },
Response: "fails", Response: "fails",
}, },

View File

@ -360,6 +360,8 @@ func TestFullGlobalGitOps(t *testing.T) {
assert.Len(t, appliedScripts, 1) assert.Len(t, appliedScripts, 1)
assert.Len(t, appliedMacProfiles, 1) assert.Len(t, appliedMacProfiles, 1)
assert.Len(t, appliedWinProfiles, 1) assert.Len(t, appliedWinProfiles, 1)
require.Len(t, savedAppConfig.Integrations.GoogleCalendar, 1)
assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"])
} }
func TestFullTeamGitOps(t *testing.T) { func TestFullTeamGitOps(t *testing.T) {
@ -389,6 +391,9 @@ func TestFullTeamGitOps(t *testing.T) {
EnabledAndConfigured: true, EnabledAndConfigured: true,
WindowsEnabledAndConfigured: true, WindowsEnabledAndConfigured: true,
}, },
Integrations: fleet.Integrations{
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
},
}, nil }, nil
} }
@ -536,6 +541,8 @@ func TestFullTeamGitOps(t *testing.T) {
assert.Len(t, appliedWinProfiles, 1) assert.Len(t, appliedWinProfiles, 1)
assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable)
assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
// Now clear the settings // Now clear the settings
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml") tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
@ -569,6 +576,9 @@ team_settings:
assert.Equal(t, secret, enrolledSecrets[0].Secret) assert.Equal(t, secret, enrolledSecrets[0].Secret)
assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable) assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable)
assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL) assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
assert.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
assert.False(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
assert.Empty(t, savedTeam.Config.Integrations.GoogleCalendar)
assert.Empty(t, savedTeam.Config.MDM.MacOSSettings.CustomSettings) assert.Empty(t, savedTeam.Config.MDM.MacOSSettings.CustomSettings)
assert.Empty(t, savedTeam.Config.MDM.WindowsSettings.CustomSettings.Value) assert.Empty(t, savedTeam.Config.MDM.WindowsSettings.CustomSettings.Value)
assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.Deadline.Value) assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.Deadline.Value)

View File

@ -79,7 +79,8 @@
}, },
"integrations": { "integrations": {
"jira": null, "jira": null,
"zendesk": null "zendesk": null,
"google_calendar": null
}, },
"mdm": { "mdm": {
"apple_bm_terms_expired": false, "apple_bm_terms_expired": false,

View File

@ -11,6 +11,7 @@ spec:
enable_host_users: true enable_host_users: true
enable_software_inventory: false enable_software_inventory: false
integrations: integrations:
google_calendar: null
jira: null jira: null
zendesk: null zendesk: null
mdm: mdm:

View File

@ -120,7 +120,8 @@
}, },
"integrations": { "integrations": {
"jira": null, "jira": null,
"zendesk": null "zendesk": null,
"google_calendar": null
}, },
"update_interval": { "update_interval": {
"osquery_detail": "1h0m0s", "osquery_detail": "1h0m0s",

View File

@ -11,6 +11,7 @@ spec:
enable_host_users: true enable_host_users: true
enable_software_inventory: false enable_software_inventory: false
integrations: integrations:
google_calendar: null
jira: null jira: null
zendesk: null zendesk: null
mdm: mdm:

View File

@ -22,7 +22,8 @@
}, },
"integrations": { "integrations": {
"jira": null, "jira": null,
"zendesk": null "zendesk": null,
"google_calendar": null
}, },
"features": { "features": {
"enable_host_users": true, "enable_host_users": true,
@ -93,7 +94,8 @@
}, },
"integrations": { "integrations": {
"jira": null, "jira": null,
"zendesk": null "zendesk": null,
"google_calendar": null
}, },
"features": { "features": {
"enable_host_users": false, "enable_host_users": false,

View File

@ -9,6 +9,8 @@ spec:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_updates: macos_updates:
@ -50,6 +52,8 @@ spec:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: true host_expiry_enabled: true
host_expiry_window: 15 host_expiry_window: 15
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_updates: macos_updates:

View File

@ -76,7 +76,8 @@
"team_id": 1, "team_id": 1,
"updated_at": "0001-01-01T00:00:00Z", "updated_at": "0001-01-01T00:00:00Z",
"created_at": "0001-01-01T00:00:00Z", "created_at": "0001-01-01T00:00:00Z",
"critical": false "critical": false,
"calendar_events_enabled": true
}, },
{ {
"id": 2, "id": 2,
@ -91,7 +92,8 @@
"team_id": null, "team_id": null,
"updated_at": "0001-01-01T00:00:00Z", "updated_at": "0001-01-01T00:00:00Z",
"created_at": "0001-01-01T00:00:00Z", "created_at": "0001-01-01T00:00:00Z",
"critical": false "critical": false,
"calendar_events_enabled": false
} }
], ],
"status": "offline", "status": "offline",

View File

@ -62,6 +62,7 @@ spec:
created_at: "0001-01-01T00:00:00Z" created_at: "0001-01-01T00:00:00Z"
updated_at: "0001-01-01T00:00:00Z" updated_at: "0001-01-01T00:00:00Z"
critical: false critical: false
calendar_events_enabled: true
- author_email: "alice@example.com" - author_email: "alice@example.com"
author_id: 1 author_id: 1
author_name: Alice author_name: Alice
@ -75,6 +76,7 @@ spec:
created_at: "0001-01-01T00:00:00Z" created_at: "0001-01-01T00:00:00Z"
updated_at: "0001-01-01T00:00:00Z" updated_at: "0001-01-01T00:00:00Z"
critical: false critical: false
calendar_events_enabled: false
policy_updated_at: "0001-01-01T00:00:00Z" policy_updated_at: "0001-01-01T00:00:00Z"
public_ip: "" public_ip: ""
primary_ip: "" primary_ip: ""

View File

@ -137,6 +137,12 @@ org_settings:
integrations: integrations:
jira: [] jira: []
zendesk: [] zendesk: []
google_calendar:
- domain: example.com
api_key_json: {
"client_email": "service@example.com",
"private_key": "google_calendar_private_key",
}
mdm: mdm:
apple_bm_default_team: "" apple_bm_default_team: ""
end_user_authentication: end_user_authentication:

View File

@ -15,6 +15,10 @@ team_settings:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: true host_expiry_enabled: true
host_expiry_window: 30 host_expiry_window: 30
integrations:
google_calendar:
enable_calendar_events: true
webhook_url: https://example.com/google_calendar_webhook
agent_options: agent_options:
command_line_flags: command_line_flags:
distributed_denylist_duration: 0 distributed_denylist_duration: 0
@ -89,6 +93,7 @@ policies:
description: This policy should always fail. description: This policy should always fail.
resolution: There is no resolution for this policy. resolution: There is no resolution for this policy.
query: SELECT 1 FROM osquery_info WHERE start_time < 0; query: SELECT 1 FROM osquery_info WHERE start_time < 0;
calendar_events_enabled: true
- name: Passing policy - name: Passing policy
platform: linux,windows,darwin,chrome platform: linux,windows,darwin,chrome
description: This policy should always pass. description: This policy should always pass.

View File

@ -11,6 +11,7 @@ spec:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations: integrations:
google_calendar: null
jira: null jira: null
zendesk: null zendesk: null
mdm: mdm:

View File

@ -11,6 +11,7 @@ spec:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations: integrations:
google_calendar: null
jira: null jira: null
zendesk: null zendesk: null
mdm: mdm:

View File

@ -9,6 +9,8 @@ spec:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_settings: macos_settings:
@ -41,6 +43,8 @@ spec:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_settings: macos_settings:

View File

@ -9,6 +9,8 @@ spec:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_settings: macos_settings:
@ -41,6 +43,8 @@ spec:
host_expiry_settings: host_expiry_settings:
host_expiry_enabled: false host_expiry_enabled: false
host_expiry_window: 0 host_expiry_window: 0
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_settings: macos_settings:

View File

@ -9,6 +9,8 @@ spec:
features: features:
enable_host_users: false enable_host_users: false
enable_software_inventory: false enable_software_inventory: false
integrations:
google_calendar: null
mdm: mdm:
enable_disk_encryption: false enable_disk_encryption: false
macos_settings: macos_settings:

View File

@ -0,0 +1,573 @@
package calendar
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/googleapi"
"google.golang.org/api/option"
)
// The calendar package has the following features for testing:
// 1. High level UserCalendar interface and Low level GoogleCalendarAPI interface can have a custom implementations.
// 2. Setting "client_email" to "calendar-mock@example.com" in the API key will use a mock in-memory implementation GoogleCalendarMockAPI of GoogleCalendarAPI.
// 3. Setting FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING environment variable to "1" will strip the "plus addressing" from the user email, effectively allowing a single user
// to create multiple events in the same calendar. This is useful for load testing. For example: john+test@example.com becomes john@example.com
const (
eventTitle = "💻🚫Downtime"
startHour = 9
endHour = 17
eventLength = 30 * time.Minute
calendarID = "primary"
mockEmail = "calendar-mock@example.com"
loadEmail = "calendar-load@example.com"
)
var (
calendarScopes = []string{
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.settings.readonly",
}
plusAddressing = os.Getenv("FLEET_GOOGLE_CALENDAR_PLUS_ADDRESSING") == "1"
plusAddressingRegex = regexp.MustCompile(`\+.*@`)
)
type GoogleCalendarConfig struct {
Context context.Context
IntegrationConfig *fleet.GoogleCalendarIntegration
Logger kitlog.Logger
// Should be nil for production
API GoogleCalendarAPI
}
// GoogleCalendar is an implementation of the UserCalendar interface that uses the
// Google Calendar API to manage events.
type GoogleCalendar struct {
config *GoogleCalendarConfig
currentUserEmail string
adjustedUserEmail string
location *time.Location
}
func NewGoogleCalendar(config *GoogleCalendarConfig) *GoogleCalendar {
switch {
case config.API != nil:
// Use the provided API.
case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == loadEmail:
config.API = &GoogleCalendarLoadAPI{Logger: config.Logger}
case config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail] == mockEmail:
config.API = &GoogleCalendarMockAPI{config.Logger}
default:
config.API = &GoogleCalendarLowLevelAPI{logger: config.Logger}
}
return &GoogleCalendar{
config: config,
}
}
type GoogleCalendarAPI interface {
Configure(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error
GetSetting(name string) (*calendar.Setting, error)
ListEvents(timeMin, timeMax string) (*calendar.Events, error)
CreateEvent(event *calendar.Event) (*calendar.Event, error)
GetEvent(id, eTag string) (*calendar.Event, error)
DeleteEvent(id string) error
}
type eventDetails struct {
ID string `json:"id"`
ETag string `json:"etag"`
}
type GoogleCalendarLowLevelAPI struct {
service *calendar.Service
logger kitlog.Logger
}
// Configure creates a new Google Calendar service using the provided credentials.
func (lowLevelAPI *GoogleCalendarLowLevelAPI) Configure(
ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string,
) error {
// Create a new calendar service
conf := &jwt.Config{
Email: serviceAccountEmail,
Scopes: calendarScopes,
PrivateKey: []byte(privateKey),
TokenURL: google.JWTTokenURL,
Subject: userToImpersonateEmail,
}
client := conf.Client(ctx)
service, err := calendar.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
return err
}
lowLevelAPI.service = service
return nil
}
func adjustEmail(email string) string {
if plusAddressing {
return plusAddressingRegex.ReplaceAllString(email, "@")
}
return email
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) {
result, err := lowLevelAPI.withRetry(
func() (any, error) {
return lowLevelAPI.service.Settings.Get(name).Do()
},
)
return result.(*calendar.Setting), err
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
result, err := lowLevelAPI.withRetry(
func() (any, error) {
return lowLevelAPI.service.Events.Insert(calendarID, event).Do()
},
)
return result.(*calendar.Event), err
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) {
result, err := lowLevelAPI.withRetry(
func() (any, error) {
return lowLevelAPI.service.Events.Get(calendarID, id).IfNoneMatch(eTag).Do()
},
)
return result.(*calendar.Event), err
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) {
result, err := lowLevelAPI.withRetry(
func() (any, error) {
// Default maximum number of events returned is 250, which should be sufficient for most calendars.
return lowLevelAPI.service.Events.List(calendarID).
EventTypes("default").
OrderBy("startTime").
SingleEvents(true).
TimeMin(timeMin).
TimeMax(timeMax).
ShowDeleted(false).
Do()
},
)
return result.(*calendar.Events), err
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
_, err := lowLevelAPI.withRetry(
func() (any, error) {
return nil, lowLevelAPI.service.Events.Delete(calendarID, id).Do()
},
)
return err
}
func (lowLevelAPI *GoogleCalendarLowLevelAPI) withRetry(fn func() (any, error)) (any, error) {
retryStrategy := backoff.NewExponentialBackOff()
retryStrategy.MaxElapsedTime = 10 * time.Minute
var result any
err := backoff.Retry(
func() error {
var err error
result, err = fn()
if err != nil {
if isRateLimited(err) {
level.Debug(lowLevelAPI.logger).Log("msg", "rate limited by Google calendar API", "err", err)
return err
}
return backoff.Permanent(err)
}
return nil
}, retryStrategy,
)
return result, err
}
func (c *GoogleCalendar) Configure(userEmail string) error {
adjustedUserEmail := adjustEmail(userEmail)
err := c.config.API.Configure(
c.config.Context, c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarEmail],
c.config.IntegrationConfig.ApiKey[fleet.GoogleCalendarPrivateKey], adjustedUserEmail,
)
if err != nil {
return ctxerr.Wrap(c.config.Context, err, "creating Google calendar service")
}
c.currentUserEmail = userEmail
c.adjustedUserEmail = adjustedUserEmail
// Clear the timezone offset so that it will be recalculated
c.location = nil
return nil
}
func (c *GoogleCalendar) GetAndUpdateEvent(event *fleet.CalendarEvent, genBodyFn func(conflict bool) string) (
*fleet.CalendarEvent, bool, error,
) {
// We assume that the Fleet event has not already ended. We will simply return it if it has not been modified.
details, err := c.unmarshalDetails(event)
if err != nil {
return nil, false, err
}
gEvent, err := c.config.API.GetEvent(details.ID, details.ETag)
var deleted bool
switch {
// http.StatusNotModified is returned sometimes, but not always, so we need to check ETag explicitly later
case googleapi.IsNotModified(err):
return event, false, nil
// http.StatusNotFound should be very rare -- Google keeps events for a while after they are deleted
case isNotFound(err):
deleted = true
case err != nil:
return nil, false, ctxerr.Wrap(c.config.Context, err, "retrieving Google calendar event")
}
if !deleted && gEvent.Status != "cancelled" {
if details.ETag != "" && details.ETag == gEvent.Etag {
// Event was not modified
return event, false, nil
}
if gEvent.End == nil || (gEvent.End.DateTime == "" && gEvent.End.Date == "") {
// We should not see this error. If we do, we can work around by treating event as deleted.
return nil, false, ctxerr.Errorf(c.config.Context, "missing end date/time for Google calendar event: %s", gEvent.Id)
}
if gEvent.End.DateTime == "" {
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
// We won't handle all-day events at this time, and treat the event as deleted.
err = c.DeleteEvent(event)
if err != nil {
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
}
deleted = true
}
var endTime *time.Time
if !deleted {
endTime, err = c.parseDateTime(gEvent.End)
if err != nil {
return nil, false, err
}
if !endTime.After(time.Now()) {
// If event already ended, it is effectively deleted
// Delete this event to prevent confusion. This operation should be rare.
err = c.DeleteEvent(event)
if err != nil {
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which is in the past", "err", err)
}
deleted = true
}
}
if !deleted {
if gEvent.Start == nil || (gEvent.Start.DateTime == "" && gEvent.Start.Date == "") {
// We should not see this error. If we do, we can work around by treating event as deleted.
return nil, false, ctxerr.Errorf(c.config.Context, "missing start date/time for Google calendar event: %s", gEvent.Id)
}
if gEvent.Start.DateTime == "" {
// User has modified the event to be an all-day event. All-day events are problematic because they depend on the user's timezone.
// We won't handle all-day events at this time, and treat the event as deleted.
err = c.DeleteEvent(event)
if err != nil {
level.Warn(c.config.Logger).Log("msg", "deleting Google calendar event which was changed to all-day event", "err", err)
}
deleted = true
}
}
if !deleted {
startTime, err := c.parseDateTime(gEvent.Start)
if err != nil {
return nil, false, err
}
fleetEvent, err := c.googleEventToFleetEvent(*startTime, *endTime, gEvent)
if err != nil {
return nil, false, err
}
return fleetEvent, true, nil
}
}
newStartDate := calculateNewEventDate(event.StartTime)
fleetEvent, err := c.CreateEvent(newStartDate, genBodyFn)
if err != nil {
return nil, false, err
}
return fleetEvent, true, nil
}
func calculateNewEventDate(oldStartDate time.Time) time.Time {
// Note: we do not handle time changes (daylight savings time, etc.) -- assuming 1 day is always 24 hours.
newStartDate := oldStartDate.Add(24 * time.Hour)
if newStartDate.Weekday() == time.Saturday {
newStartDate = newStartDate.Add(48 * time.Hour)
} else if newStartDate.Weekday() == time.Sunday {
newStartDate = newStartDate.Add(24 * time.Hour)
}
return newStartDate
}
func (c *GoogleCalendar) parseDateTime(eventDateTime *calendar.EventDateTime) (*time.Time, error) {
var t time.Time
var err error
if eventDateTime.TimeZone != "" {
loc := getLocation(eventDateTime.TimeZone, c.config)
t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc)
} else {
t, err = time.Parse(time.RFC3339, eventDateTime.DateTime)
}
if err != nil {
return nil, ctxerr.Wrap(
c.config.Context, err, fmt.Sprintf("parsing Google calendar event time: %s", eventDateTime.DateTime),
)
}
return &t, nil
}
func isNotFound(err error) bool {
if err == nil {
return false
}
var ae *googleapi.Error
ok := errors.As(err, &ae)
return ok && ae.Code == http.StatusNotFound
}
func isAlreadyDeleted(err error) bool {
if err == nil {
return false
}
var ae *googleapi.Error
ok := errors.As(err, &ae)
return ok && ae.Code == http.StatusGone
}
func isRateLimited(err error) bool {
if err == nil {
return false
}
var ae *googleapi.Error
ok := errors.As(err, &ae)
return ok && (ae.Code == http.StatusTooManyRequests ||
(ae.Code == http.StatusForbidden &&
(ae.Message == "Rate Limit Exceeded" || ae.Message == "User Rate Limit Exceeded" || ae.Message == "Calendar usage limits exceeded." || strings.HasPrefix(ae.Message, "Quota exceeded"))))
}
func (c *GoogleCalendar) unmarshalDetails(event *fleet.CalendarEvent) (*eventDetails, error) {
var details eventDetails
err := json.Unmarshal(event.Data, &details)
if err != nil {
return nil, ctxerr.Wrap(c.config.Context, err, "unmarshaling Google calendar event details")
}
if details.ID == "" {
return nil, ctxerr.Errorf(c.config.Context, "missing Google calendar event ID")
}
// ETag is optional, but we need it to check if the event was modified
return &details, nil
}
func (c *GoogleCalendar) CreateEvent(dayOfEvent time.Time, genBodyFn func(conflict bool) string) (*fleet.CalendarEvent, error) {
return c.createEvent(dayOfEvent, genBodyFn, time.Now)
}
// createEvent creates a new event on the calendar on the given date. timeNow is a function that returns the current time.
// timeNow can be overwritten for testing
func (c *GoogleCalendar) createEvent(
dayOfEvent time.Time, genBodyFn func(conflict bool) string, timeNow func() time.Time,
) (*fleet.CalendarEvent, error) {
var err error
if c.location == nil {
c.location, err = getTimezone(c)
if err != nil {
return nil, err
}
}
dayStart := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), startHour, 0, 0, 0, c.location)
dayEnd := time.Date(dayOfEvent.Year(), dayOfEvent.Month(), dayOfEvent.Day(), endHour, 0, 0, 0, c.location)
now := timeNow().In(c.location)
if dayEnd.Before(now) {
// The workday has already ended.
return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "cannot schedule an event for a day that has already ended"})
}
// Adjust day start if workday already started
if !dayStart.After(now) {
dayStart = now.Truncate(eventLength)
if dayStart.Before(now) {
dayStart = dayStart.Add(eventLength)
}
if !dayStart.Before(dayEnd) {
return nil, ctxerr.Wrap(c.config.Context, fleet.DayEndedError{Msg: "no time available for event"})
}
}
eventStart := dayStart
eventEnd := dayStart.Add(eventLength)
events, err := c.config.API.ListEvents(dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339))
if err != nil {
return nil, ctxerr.Wrap(c.config.Context, err, "listing Google calendar events")
}
var conflict bool
for _, gEvent := range events.Items {
// Ignore cancelled events
if gEvent.Status == "cancelled" {
continue
}
// Ignore all day events
if gEvent.Start == nil || gEvent.Start.DateTime == "" || gEvent.End == nil || gEvent.End.DateTime == "" {
continue
}
// Ignore events that the user has declined
var declined bool
for _, attendee := range gEvent.Attendees {
if attendee.Email == c.adjustedUserEmail {
// The user has declined the event, so this time is open for scheduling
if attendee.ResponseStatus == "declined" {
declined = true
break
}
}
}
if declined {
continue
}
// Ignore events that will end before our event
endTime, err := c.parseDateTime(gEvent.End)
if err != nil {
return nil, err
}
if !endTime.After(eventStart) {
continue
}
startTime, err := c.parseDateTime(gEvent.Start)
if err != nil {
return nil, err
}
if startTime.Before(eventEnd) {
// Event occurs during our event, so we need to adjust.
var isLastSlot bool
eventStart, eventEnd, isLastSlot, conflict = adjustEventTimes(*endTime, dayEnd)
if isLastSlot {
break
}
continue
}
// Since events are sorted by startTime, all subsequent events are after our event, so we can stop processing
break
}
event := &calendar.Event{}
event.Start = &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)}
event.End = &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)}
event.Summary = eventTitle
event.Description = genBodyFn(conflict)
event, err = c.config.API.CreateEvent(event)
if err != nil {
return nil, ctxerr.Wrap(c.config.Context, err, "creating Google calendar event")
}
// Convert Google event to Fleet event
fleetEvent, err := c.googleEventToFleetEvent(eventStart, eventEnd, event)
if err != nil {
return nil, err
}
level.Debug(c.config.Logger).Log(
"msg", "created Google calendar event", "user", c.adjustedUserEmail, "startTime", eventStart, "timezone", c.location.String(),
)
return fleetEvent, nil
}
func adjustEventTimes(endTime time.Time, dayEnd time.Time) (eventStart time.Time, eventEnd time.Time, isLastSlot bool, conflict bool) {
eventStart = endTime.Truncate(eventLength)
if eventStart.Before(endTime) {
eventStart = eventStart.Add(eventLength)
}
eventEnd = eventStart.Add(eventLength)
// If we are at the end of the day, pick the last slot
if eventEnd.After(dayEnd) {
eventEnd = dayEnd
eventStart = eventEnd.Add(-eventLength)
isLastSlot = true
conflict = true
} else if eventEnd.Equal(dayEnd) {
isLastSlot = true
}
return eventStart, eventEnd, isLastSlot, conflict
}
func getTimezone(gCal *GoogleCalendar) (*time.Location, error) {
config := gCal.config
setting, err := config.API.GetSetting("timezone")
if err != nil {
return nil, ctxerr.Wrap(config.Context, err, "retrieving Google calendar timezone")
}
return getLocation(setting.Value, config), nil
}
func getLocation(name string, config *GoogleCalendarConfig) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
// Could not load location, use EST
level.Warn(config.Logger).Log("msg", "parsing Google calendar timezone", "timezone", name, "err", err)
loc, _ = time.LoadLocation("America/New_York")
}
return loc
}
func (c *GoogleCalendar) googleEventToFleetEvent(startTime time.Time, endTime time.Time, event *calendar.Event) (
*fleet.CalendarEvent, error,
) {
fleetEvent := &fleet.CalendarEvent{}
fleetEvent.StartTime = startTime
fleetEvent.EndTime = endTime
fleetEvent.Email = c.currentUserEmail
details := &eventDetails{
ID: event.Id,
ETag: event.Etag,
}
detailsJson, err := json.Marshal(details)
if err != nil {
return nil, ctxerr.Wrap(c.config.Context, err, "marshaling Google calendar event details")
}
fleetEvent.Data = detailsJson
return fleetEvent, nil
}
func (c *GoogleCalendar) DeleteEvent(event *fleet.CalendarEvent) error {
details, err := c.unmarshalDetails(event)
if err != nil {
return err
}
err = c.config.API.DeleteEvent(details.ID)
switch {
case isAlreadyDeleted(err):
return nil
case err != nil:
return ctxerr.Wrap(c.config.Context, err, "deleting Google calendar event")
}
return nil
}

View File

@ -0,0 +1,132 @@
package calendar
import (
"context"
"github.com/fleetdm/fleet/v4/ee/server/calendar/load_test"
"github.com/fleetdm/fleet/v4/server/fleet"
kitlog "github.com/go-kit/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"net/http/httptest"
"os"
"testing"
"time"
)
type googleCalendarIntegrationTestSuite struct {
suite.Suite
server *httptest.Server
dbFile *os.File
}
func (s *googleCalendarIntegrationTestSuite) SetupSuite() {
dbFile, err := os.CreateTemp("", "calendar.db")
s.Require().NoError(err)
handler, err := calendartest.Configure(dbFile.Name())
s.Require().NoError(err)
server := httptest.NewUnstartedServer(handler)
server.Listener.Addr()
server.Start()
s.server = server
}
func (s *googleCalendarIntegrationTestSuite) TearDownSuite() {
if s.dbFile != nil {
s.dbFile.Close()
_ = os.Remove(s.dbFile.Name())
}
if s.server != nil {
s.server.Close()
}
calendartest.Close()
}
// TestGoogleCalendarIntegration tests should be able to be run in parallel, but this is not natively supported by suites: https://github.com/stretchr/testify/issues/187
// There are workarounds that can be explored.
func TestGoogleCalendarIntegration(t *testing.T) {
testingSuite := new(googleCalendarIntegrationTestSuite)
suite.Run(t, testingSuite)
}
func (s *googleCalendarIntegrationTestSuite) TestCreateGetDeleteEvent() {
t := s.T()
userEmail := "user1@example.com"
config := &GoogleCalendarConfig{
Context: context.Background(),
IntegrationConfig: &fleet.GoogleCalendarIntegration{
Domain: "example.com",
ApiKey: map[string]string{
"client_email": loadEmail,
"private_key": s.server.URL,
},
},
Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)),
}
gCal := NewGoogleCalendar(config)
err := gCal.Configure(userEmail)
require.NoError(t, err)
genBodyFn := func(bool) string {
return "Test event"
}
eventDate := time.Now().Add(48 * time.Hour)
event, err := gCal.CreateEvent(eventDate, genBodyFn)
require.NoError(t, err)
assert.Equal(t, startHour, event.StartTime.Hour())
assert.Equal(t, 0, event.StartTime.Minute())
eventRsp, updated, err := gCal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.False(t, updated)
assert.Equal(t, event, eventRsp)
err = gCal.DeleteEvent(event)
assert.NoError(t, err)
// delete again
err = gCal.DeleteEvent(event)
assert.NoError(t, err)
// Try to get deleted event
eventRsp, updated, err = gCal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event.StartTime.UTC().Truncate(24*time.Hour), eventRsp.StartTime.UTC().Truncate(24*time.Hour))
}
func (s *googleCalendarIntegrationTestSuite) TestFillUpCalendar() {
t := s.T()
userEmail := "user2@example.com"
config := &GoogleCalendarConfig{
Context: context.Background(),
IntegrationConfig: &fleet.GoogleCalendarIntegration{
Domain: "example.com",
ApiKey: map[string]string{
"client_email": loadEmail,
"private_key": s.server.URL,
},
},
Logger: kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(os.Stdout)),
}
gCal := NewGoogleCalendar(config)
err := gCal.Configure(userEmail)
require.NoError(t, err)
genBodyFn := func(bool) string {
return "Test event"
}
eventDate := time.Now().Add(48 * time.Hour)
event, err := gCal.CreateEvent(eventDate, genBodyFn)
require.NoError(t, err)
assert.Equal(t, startHour, event.StartTime.Hour())
assert.Equal(t, 0, event.StartTime.Minute())
currentEventTime := event.StartTime
for i := 0; i < 20; i++ {
if !(currentEventTime.Hour() == endHour-1 && currentEventTime.Minute() == 30) {
currentEventTime = currentEventTime.Add(30 * time.Minute)
}
event, err = gCal.CreateEvent(eventDate, genBodyFn)
require.NoError(t, err)
assert.Equal(t, currentEventTime.UTC(), event.StartTime.UTC())
}
}

View File

@ -0,0 +1,234 @@
package calendar
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
kitlog "github.com/go-kit/log"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/googleapi"
"io"
"net/http"
"net/url"
"os"
)
// GoogleCalendarLoadAPI is used for load testing.
type GoogleCalendarLoadAPI struct {
Logger kitlog.Logger
baseUrl string
userToImpersonate string
ctx context.Context
client *http.Client
}
// Configure creates a new Google Calendar service using the provided credentials.
func (lowLevelAPI *GoogleCalendarLoadAPI) Configure(ctx context.Context, _ string, privateKey string, userToImpersonate string) error {
if lowLevelAPI.Logger == nil {
lowLevelAPI.Logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarLoadAPI", "user", userToImpersonate)
}
lowLevelAPI.baseUrl = privateKey
lowLevelAPI.userToImpersonate = userToImpersonate
lowLevelAPI.ctx = ctx
if lowLevelAPI.client == nil {
lowLevelAPI.client = fleethttp.NewClient()
}
return nil
}
func (lowLevelAPI *GoogleCalendarLoadAPI) GetSetting(name string) (*calendar.Setting, error) {
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/settings")
if err != nil {
return nil, err
}
query := reqUrl.Query()
query.Set("name", name)
query.Set("email", lowLevelAPI.userToImpersonate)
reqUrl.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil)
if err != nil {
return nil, err
}
rsp, err := lowLevelAPI.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = rsp.Body.Close()
}()
if rsp.StatusCode != http.StatusOK {
var data []byte
if rsp.Body != nil {
data, _ = io.ReadAll(rsp.Body)
}
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
}
var setting calendar.Setting
body, err := io.ReadAll(rsp.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &setting)
if err != nil {
return nil, err
}
return &setting, nil
}
func (lowLevelAPI *GoogleCalendarLoadAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
body, err := json.Marshal(event)
if err != nil {
return nil, err
}
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/add")
if err != nil {
return nil, err
}
query := reqUrl.Query()
query.Set("email", lowLevelAPI.userToImpersonate)
reqUrl.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "POST", reqUrl.String(), bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
rsp, err := lowLevelAPI.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = rsp.Body.Close()
}()
if rsp.StatusCode != http.StatusCreated {
var data []byte
if rsp.Body != nil {
data, _ = io.ReadAll(rsp.Body)
}
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
}
var rspEvent calendar.Event
body, err = io.ReadAll(rsp.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &rspEvent)
if err != nil {
return nil, err
}
return &rspEvent, nil
}
func (lowLevelAPI *GoogleCalendarLoadAPI) GetEvent(id, _ string) (*calendar.Event, error) {
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events")
if err != nil {
return nil, err
}
query := reqUrl.Query()
query.Set("id", id)
reqUrl.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil)
if err != nil {
return nil, err
}
rsp, err := lowLevelAPI.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = rsp.Body.Close()
}()
if rsp.StatusCode == http.StatusNotFound {
return nil, &googleapi.Error{Code: http.StatusNotFound}
}
if rsp.StatusCode != http.StatusOK {
var data []byte
if rsp.Body != nil {
data, _ = io.ReadAll(rsp.Body)
}
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
}
var rspEvent calendar.Event
body, err := io.ReadAll(rsp.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &rspEvent)
if err != nil {
return nil, err
}
return &rspEvent, nil
}
func (lowLevelAPI *GoogleCalendarLoadAPI) ListEvents(timeMin string, timeMax string) (*calendar.Events, error) {
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/list")
if err != nil {
return nil, err
}
query := reqUrl.Query()
query.Set("timemin", timeMin)
query.Set("timemax", timeMax)
query.Set("email", lowLevelAPI.userToImpersonate)
reqUrl.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "GET", reqUrl.String(), nil)
if err != nil {
return nil, err
}
rsp, err := lowLevelAPI.client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = rsp.Body.Close()
}()
if rsp.StatusCode != http.StatusOK {
var data []byte
if rsp.Body != nil {
data, _ = io.ReadAll(rsp.Body)
}
return nil, fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
}
var events calendar.Events
body, err := io.ReadAll(rsp.Body)
if err != nil {
return nil, err
}
err = json.Unmarshal(body, &events)
if err != nil {
return nil, err
}
return &events, nil
}
func (lowLevelAPI *GoogleCalendarLoadAPI) DeleteEvent(id string) error {
reqUrl, err := url.Parse(lowLevelAPI.baseUrl + "/events/delete")
if err != nil {
return err
}
query := reqUrl.Query()
query.Set("id", id)
reqUrl.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(lowLevelAPI.ctx, "DELETE", reqUrl.String(), nil)
if err != nil {
return err
}
rsp, err := lowLevelAPI.client.Do(req)
if err != nil {
return err
}
defer func() {
_ = rsp.Body.Close()
}()
if rsp.StatusCode == http.StatusGone {
return &googleapi.Error{Code: http.StatusGone}
}
if rsp.StatusCode != http.StatusOK {
var data []byte
if rsp.Body != nil {
data, _ = io.ReadAll(rsp.Body)
}
return fmt.Errorf("unexpected status code: %d with body: %s", rsp.StatusCode, string(data))
}
return nil
}

View File

@ -0,0 +1,95 @@
package calendar
import (
"context"
"errors"
"net/http"
"os"
"strconv"
"sync"
"time"
kitlog "github.com/go-kit/log"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/googleapi"
)
type GoogleCalendarMockAPI struct {
logger kitlog.Logger
}
var (
mockEvents = make(map[string]*calendar.Event)
mu sync.Mutex
id uint64
)
const latency = 500 * time.Millisecond
// Configure creates a new Google Calendar service using the provided credentials.
func (lowLevelAPI *GoogleCalendarMockAPI) Configure(_ context.Context, _ string, _ string, userToImpersonate string) error {
if lowLevelAPI.logger == nil {
lowLevelAPI.logger = kitlog.With(kitlog.NewLogfmtLogger(os.Stderr), "mock", "GoogleCalendarMockAPI", "user", userToImpersonate)
}
return nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) GetSetting(name string) (*calendar.Setting, error) {
time.Sleep(latency)
lowLevelAPI.logger.Log("msg", "GetSetting", "name", name)
if name == "timezone" {
return &calendar.Setting{
Id: "timezone",
Value: "America/Chicago",
}, nil
}
return nil, errors.New("setting not supported")
}
func (lowLevelAPI *GoogleCalendarMockAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
time.Sleep(latency)
mu.Lock()
defer mu.Unlock()
id += 1
event.Id = strconv.FormatUint(id, 10)
lowLevelAPI.logger.Log("msg", "CreateEvent", "id", event.Id, "start", event.Start.DateTime)
mockEvents[event.Id] = event
return event, nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) GetEvent(id, _ string) (*calendar.Event, error) {
time.Sleep(latency)
mu.Lock()
defer mu.Unlock()
event, ok := mockEvents[id]
if !ok {
return nil, &googleapi.Error{Code: http.StatusNotFound}
}
lowLevelAPI.logger.Log("msg", "GetEvent", "id", id, "start", event.Start.DateTime)
return event, nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) ListEvents(string, string) (*calendar.Events, error) {
time.Sleep(latency)
lowLevelAPI.logger.Log("msg", "ListEvents")
return &calendar.Events{}, nil
}
func (lowLevelAPI *GoogleCalendarMockAPI) DeleteEvent(id string) error {
time.Sleep(latency)
mu.Lock()
defer mu.Unlock()
lowLevelAPI.logger.Log("msg", "DeleteEvent", "id", id)
delete(mockEvents, id)
return nil
}
func ListGoogleMockEvents() map[string]*calendar.Event {
return mockEvents
}
func ClearMockEvents() {
mu.Lock()
defer mu.Unlock()
mockEvents = make(map[string]*calendar.Event)
}

View File

@ -0,0 +1,650 @@
package calendar
import (
"context"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/kit/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/googleapi"
"net/http"
"os"
"testing"
"time"
)
const (
baseServiceEmail = "service@example.com"
basePrivateKey = "private-key"
baseUserEmail = "user@example.com"
)
var (
baseCtx = context.Background()
logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stdout))
)
type MockGoogleCalendarLowLevelAPI struct {
ConfigureFunc func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error
GetSettingFunc func(name string) (*calendar.Setting, error)
ListEventsFunc func(timeMin, timeMax string) (*calendar.Events, error)
CreateEventFunc func(event *calendar.Event) (*calendar.Event, error)
GetEventFunc func(id, eTag string) (*calendar.Event, error)
DeleteEventFunc func(id string) error
}
func (m *MockGoogleCalendarLowLevelAPI) Configure(
ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string,
) error {
return m.ConfigureFunc(ctx, serviceAccountEmail, privateKey, userToImpersonateEmail)
}
func (m *MockGoogleCalendarLowLevelAPI) GetSetting(name string) (*calendar.Setting, error) {
return m.GetSettingFunc(name)
}
func (m *MockGoogleCalendarLowLevelAPI) ListEvents(timeMin, timeMax string) (*calendar.Events, error) {
return m.ListEventsFunc(timeMin, timeMax)
}
func (m *MockGoogleCalendarLowLevelAPI) CreateEvent(event *calendar.Event) (*calendar.Event, error) {
return m.CreateEventFunc(event)
}
func (m *MockGoogleCalendarLowLevelAPI) GetEvent(id, eTag string) (*calendar.Event, error) {
return m.GetEventFunc(id, eTag)
}
func (m *MockGoogleCalendarLowLevelAPI) DeleteEvent(id string) error {
return m.DeleteEventFunc(id)
}
func TestGoogleCalendar_Configure(t *testing.T) {
t.Parallel()
mockAPI := &MockGoogleCalendarLowLevelAPI{}
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
assert.Equal(t, baseCtx, ctx)
assert.Equal(t, baseServiceEmail, serviceAccountEmail)
assert.Equal(t, basePrivateKey, privateKey)
assert.Equal(t, baseUserEmail, userToImpersonateEmail)
return nil
}
// Happy path test
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(baseUserEmail)
assert.NoError(t, err)
// Configure error test
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
return assert.AnError
}
err = cal.Configure(baseUserEmail)
assert.ErrorIs(t, err, assert.AnError)
}
func TestGoogleCalendar_ConfigurePlusAddressing(t *testing.T) {
// Do not run this test in t.Parallel(), since it involves modifying a global variable
plusAddressing = true
t.Cleanup(
func() {
plusAddressing = false
},
)
email := "user+my_test+email@example.com"
mockAPI := &MockGoogleCalendarLowLevelAPI{}
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
assert.Equal(t, baseCtx, ctx)
assert.Equal(t, baseServiceEmail, serviceAccountEmail)
assert.Equal(t, basePrivateKey, privateKey)
assert.Equal(t, "user@example.com", userToImpersonateEmail)
return nil
}
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(email)
assert.NoError(t, err)
}
func makeConfig(mockAPI *MockGoogleCalendarLowLevelAPI) *GoogleCalendarConfig {
if mockAPI != nil && mockAPI.ConfigureFunc == nil {
mockAPI.ConfigureFunc = func(ctx context.Context, serviceAccountEmail, privateKey, userToImpersonateEmail string) error {
return nil
}
}
config := &GoogleCalendarConfig{
Context: context.Background(),
IntegrationConfig: &fleet.GoogleCalendarIntegration{
ApiKey: map[string]string{
fleet.GoogleCalendarEmail: baseServiceEmail,
fleet.GoogleCalendarPrivateKey: basePrivateKey,
},
},
Logger: logger,
API: mockAPI,
}
return config
}
func TestGoogleCalendar_DeleteEvent(t *testing.T) {
t.Parallel()
mockAPI := &MockGoogleCalendarLowLevelAPI{}
mockAPI.DeleteEventFunc = func(id string) error {
assert.Equal(t, "event-id", id)
return nil
}
// Happy path test
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(baseUserEmail)
assert.NoError(t, err)
err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)})
assert.NoError(t, err)
// API error test
mockAPI.DeleteEventFunc = func(id string) error {
return assert.AnError
}
err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)})
assert.ErrorIs(t, err, assert.AnError)
// Event already deleted
mockAPI.DeleteEventFunc = func(id string) error {
return &googleapi.Error{Code: http.StatusGone}
}
err = cal.DeleteEvent(&fleet.CalendarEvent{Data: []byte(`{"ID":"event-id"}`)})
assert.NoError(t, err)
}
func TestGoogleCalendar_unmarshalDetails(t *testing.T) {
t.Parallel()
var gCal = NewGoogleCalendar(makeConfig(&MockGoogleCalendarLowLevelAPI{}))
err := gCal.Configure(baseUserEmail)
assert.NoError(t, err)
details, err := gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id","etag":"event-eTag"}`)})
assert.NoError(t, err)
assert.Equal(t, "event-id", details.ID)
assert.Equal(t, "event-eTag", details.ETag)
// Missing ETag is OK
details, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"id":"event-id"}`)})
assert.NoError(t, err)
assert.Equal(t, "event-id", details.ID)
assert.Equal(t, "", details.ETag)
// Bad JSON
_, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"bozo`)})
assert.Error(t, err)
// Missing id
_, err = gCal.unmarshalDetails(&fleet.CalendarEvent{Data: []byte(`{"myId":"event-id","etag":"event-eTag"}`)})
assert.Error(t, err)
}
func TestGoogleCalendar_GetAndUpdateEvent(t *testing.T) {
t.Parallel()
mockAPI := &MockGoogleCalendarLowLevelAPI{}
const baseETag = "event-eTag"
const baseEventID = "event-id"
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
assert.Equal(t, baseEventID, id)
assert.Equal(t, baseETag, eTag)
return &calendar.Event{
Etag: baseETag, // ETag matches -- no modifications to event
}, nil
}
genBodyFn := func(bool) string {
t.Error("genBodyFn should not be called")
return "event-body"
}
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(baseUserEmail)
assert.NoError(t, err)
eventStartTime := time.Now().UTC()
event := &fleet.CalendarEvent{
StartTime: eventStartTime,
EndTime: time.Now().Add(time.Hour),
Data: []byte(`{"ID":"` + baseEventID + `","ETag":"` + baseETag + `"}`),
}
// ETag matches
retrievedEvent, updated, err := cal.GetAndUpdateEvent(event, genBodyFn)
assert.NoError(t, err)
assert.False(t, updated)
assert.Equal(t, event, retrievedEvent)
// http.StatusNotModified response (ETag matches)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return nil, &googleapi.Error{Code: http.StatusNotModified}
}
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.NoError(t, err)
assert.False(t, updated)
assert.Equal(t, event, retrievedEvent)
// Cannot unmarshal details
eventBadDetails := &fleet.CalendarEvent{
StartTime: time.Now(),
EndTime: time.Now().Add(time.Hour),
Data: []byte(`{"bozo`),
}
_, _, err = cal.GetAndUpdateEvent(eventBadDetails, genBodyFn)
assert.Error(t, err)
// API error test
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return nil, assert.AnError
}
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.ErrorIs(t, err, assert.AnError)
// Event has been modified
startTime := time.Now().Add(time.Minute).Truncate(time.Second)
endTime := time.Now().Add(time.Hour).Truncate(time.Second)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
}, nil
}
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
require.NotNil(t, retrievedEvent)
assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC())
assert.Equal(t, baseUserEmail, retrievedEvent.Email)
gCal, _ := cal.(*GoogleCalendar)
details, err := gCal.unmarshalDetails(retrievedEvent)
require.NoError(t, err)
assert.Equal(t, "new-eTag", details.ETag)
assert.Equal(t, baseEventID, details.ID)
// missing end time
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: ""},
}, nil
}
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.Error(t, err)
// missing start time
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
}, nil
}
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.Error(t, err)
// Bad time format
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: "bozo"},
}, nil
}
_, _, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.Error(t, err)
// Event has been modified, with custom timezone.
tzId := "Africa/Kinshasa"
location, _ := time.LoadLocation(tzId)
startTime = time.Now().Add(time.Minute).Truncate(time.Second).In(location)
endTime = time.Now().Add(time.Hour).Truncate(time.Second).In(location)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{DateTime: startTime.UTC().Format(time.RFC3339), TimeZone: tzId},
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339), TimeZone: tzId},
}, nil
}
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
assert.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
require.NotNil(t, retrievedEvent)
assert.Equal(t, startTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, endTime.UTC(), retrievedEvent.EndTime.UTC())
assert.Equal(t, baseUserEmail, retrievedEvent.Email)
// 404 response (deleted)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return nil, &googleapi.Error{Code: http.StatusNotFound}
}
mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) {
return &calendar.Setting{Value: "UTC"}, nil
}
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return &calendar.Events{}, nil
}
genBodyFn = func(conflict bool) string {
assert.False(t, conflict)
return "event-body"
}
eventCreated := false
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
assert.Equal(t, eventTitle, event.Summary)
assert.Equal(t, genBodyFn(false), event.Description)
event.Id = baseEventID
event.Etag = baseETag
eventCreated = true
return event, nil
}
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.True(t, updated)
assert.NotEqual(t, event, retrievedEvent)
require.NotNil(t, retrievedEvent)
assert.Equal(t, baseUserEmail, retrievedEvent.Email)
newEventDate := calculateNewEventDate(eventStartTime)
expectedStartTime := time.Date(newEventDate.Year(), newEventDate.Month(), newEventDate.Day(), startHour, 0, 0, 0, time.UTC)
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
assert.True(t, eventCreated)
// cancelled (deleted)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{DateTime: startTime.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
Status: "cancelled",
}, nil
}
eventCreated = false
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.True(t, updated)
require.NotNil(t, retrievedEvent)
assert.NotEqual(t, event, retrievedEvent)
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
assert.True(t, eventCreated)
// all day event (deleted)
mockAPI.DeleteEventFunc = func(id string) error {
assert.Equal(t, baseEventID, id)
return nil
}
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{Date: startTime.Format("2006-01-02")},
End: &calendar.EventDateTime{DateTime: endTime.Format(time.RFC3339)},
}, nil
}
eventCreated = false
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.True(t, updated)
require.NotNil(t, retrievedEvent)
assert.NotEqual(t, event, retrievedEvent)
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
assert.True(t, eventCreated)
// moved in the past event (deleted)
mockAPI.GetEventFunc = func(id, eTag string) (*calendar.Event, error) {
return &calendar.Event{
Id: baseEventID,
Etag: "new-eTag",
Start: &calendar.EventDateTime{DateTime: startTime.Add(-2 * time.Hour).Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: endTime.Add(-2 * time.Hour).Format(time.RFC3339)},
}, nil
}
eventCreated = false
retrievedEvent, updated, err = cal.GetAndUpdateEvent(event, genBodyFn)
require.NoError(t, err)
assert.True(t, updated)
require.NotNil(t, retrievedEvent)
assert.NotEqual(t, event, retrievedEvent)
assert.Equal(t, expectedStartTime.UTC(), retrievedEvent.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), retrievedEvent.EndTime.UTC())
assert.True(t, eventCreated)
}
func TestGoogleCalendar_CreateEvent(t *testing.T) {
t.Parallel()
mockAPI := &MockGoogleCalendarLowLevelAPI{}
const baseEventID = "event-id"
const baseETag = "event-eTag"
const eventBody = "event-body"
var cal fleet.UserCalendar = NewGoogleCalendar(makeConfig(mockAPI))
err := cal.Configure(baseUserEmail)
assert.NoError(t, err)
tzId := "Africa/Kinshasa"
mockAPI.GetSettingFunc = func(name string) (*calendar.Setting, error) {
return &calendar.Setting{Value: tzId}, nil
}
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return &calendar.Events{}, nil
}
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
assert.Equal(t, eventTitle, event.Summary)
assert.Equal(t, eventBody, event.Description)
event.Id = baseEventID
event.Etag = baseETag
return event, nil
}
genBodyFn := func(conflict bool) string {
assert.False(t, conflict)
return eventBody
}
genBodyConflictFn := func(conflict bool) string {
assert.True(t, conflict)
return eventBody
}
// Happy path test -- empty calendar
date := time.Now().Add(48 * time.Hour)
location, _ := time.LoadLocation(tzId)
expectedStartTime := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
_, expectedOffset := expectedStartTime.Zone()
event, err := cal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
assert.Equal(t, baseUserEmail, event.Email)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
_, offset := event.StartTime.Zone()
assert.Equal(t, expectedOffset, offset)
_, offset = event.EndTime.Zone()
assert.Equal(t, expectedOffset, offset)
gCal, _ := cal.(*GoogleCalendar)
details, err := gCal.unmarshalDetails(event)
require.NoError(t, err)
assert.Equal(t, baseETag, details.ETag)
assert.Equal(t, baseEventID, details.ID)
// Workday already ended
date = time.Now().Add(-48 * time.Hour)
_, err = cal.CreateEvent(date, genBodyFn)
assert.ErrorAs(t, err, &fleet.DayEndedError{})
// There is no time left in the day to schedule an event
date = time.Now().Add(48 * time.Hour)
timeNow := func() time.Time {
now := time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 45, 0, 0, location)
return now
}
_, err = gCal.createEvent(date, genBodyFn, timeNow)
assert.ErrorAs(t, err, &fleet.DayEndedError{})
// Workday already started
date = time.Now().Add(48 * time.Hour)
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
timeNow = func() time.Time {
return expectedStartTime
}
event, err = gCal.createEvent(date, genBodyFn, timeNow)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
// Busy calendar
date = time.Now().Add(48 * time.Hour)
dayStart := time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
dayEnd := time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location)
gEvents := &calendar.Events{}
// Cancelled event
gEvent := &calendar.Event{
Id: "cancelled-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
Status: "cancelled",
}
gEvents.Items = append(gEvents.Items, gEvent)
// All day events
gEvent = &calendar.Event{
Id: "all-day-event-id",
Start: &calendar.EventDateTime{Date: dayStart.Format(time.DateOnly)},
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
}
gEvents.Items = append(gEvents.Items, gEvent)
gEvent = &calendar.Event{
Id: "all-day2-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{Date: dayEnd.Format(time.DateOnly)},
}
gEvents.Items = append(gEvents.Items, gEvent)
// User-declined event
gEvent = &calendar.Event{
Id: "user-declined-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "declined"}},
}
gEvents.Items = append(gEvents.Items, gEvent)
// Event before day
gEvent = &calendar.Event{
Id: "before-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Add(-time.Hour).Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: dayStart.Add(-30 * time.Minute).Format(time.RFC3339)},
}
gEvents.Items = append(gEvents.Items, gEvent)
// Event from 6am to 11am
eventStart := time.Date(date.Year(), date.Month(), date.Day(), 6, 0, 0, 0, location)
eventEnd := time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location)
gEvent = &calendar.Event{
Id: "6-to-11-event-id",
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
Attendees: []*calendar.EventAttendee{{Email: baseUserEmail, ResponseStatus: "accepted"}},
}
gEvents.Items = append(gEvents.Items, gEvent)
// Event from 10am to 10:30am
eventStart = time.Date(date.Year(), date.Month(), date.Day(), 10, 0, 0, 0, location)
eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 10, 30, 0, 0, location)
gEvent = &calendar.Event{
Id: "10-to-10-30-event-id",
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}},
}
gEvents.Items = append(gEvents.Items, gEvent)
// Event from 11am to 11:45am
eventStart = time.Date(date.Year(), date.Month(), date.Day(), 11, 0, 0, 0, location)
eventEnd = time.Date(date.Year(), date.Month(), date.Day(), 11, 45, 0, 0, location)
gEvent = &calendar.Event{
Id: "11-to-11-45-event-id",
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}},
}
gEvents.Items = append(gEvents.Items, gEvent)
// Event after day
eventStart = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location)
eventEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 45, 0, 0, location)
gEvent = &calendar.Event{
Id: "after-event-id",
Start: &calendar.EventDateTime{DateTime: eventStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: eventEnd.Format(time.RFC3339)},
Attendees: []*calendar.EventAttendee{{Email: "other@example.com", ResponseStatus: "accepted"}},
}
gEvents.Items = append(gEvents.Items, gEvent)
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return gEvents, nil
}
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), 12, 0, 0, 0, location)
event, err = gCal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
// Full schedule -- pick the last slot
date = time.Now().Add(48 * time.Hour)
dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour, 0, 0, 0, location)
gEvents = &calendar.Events{}
gEvent = &calendar.Event{
Id: "9-to-5-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
}
gEvents.Items = append(gEvents.Items, gEvent)
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return gEvents, nil
}
expectedStartTime = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
event, err = gCal.CreateEvent(date, genBodyConflictFn)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
// Almost full schedule -- pick the last slot
date = time.Now().Add(48 * time.Hour)
dayStart = time.Date(date.Year(), date.Month(), date.Day(), startHour, 0, 0, 0, location)
dayEnd = time.Date(date.Year(), date.Month(), date.Day(), endHour-1, 30, 0, 0, location)
gEvents = &calendar.Events{}
gEvent = &calendar.Event{
Id: "9-to-4-30-event-id",
Start: &calendar.EventDateTime{DateTime: dayStart.Format(time.RFC3339)},
End: &calendar.EventDateTime{DateTime: dayEnd.Format(time.RFC3339)},
}
gEvents.Items = append(gEvents.Items, gEvent)
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return gEvents, nil
}
expectedStartTime = dayEnd
event, err = gCal.CreateEvent(date, genBodyFn)
require.NoError(t, err)
assert.Equal(t, expectedStartTime.UTC(), event.StartTime.UTC())
assert.Equal(t, expectedStartTime.Add(eventLength).UTC(), event.EndTime.UTC())
// API error in ListEvents
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return nil, assert.AnError
}
_, err = gCal.CreateEvent(date, genBodyFn)
assert.ErrorIs(t, err, assert.AnError)
// API error in CreateEvent
mockAPI.ListEventsFunc = func(timeMin, timeMax string) (*calendar.Events, error) {
return &calendar.Events{}, nil
}
mockAPI.CreateEventFunc = func(event *calendar.Event) (*calendar.Event, error) {
return nil, assert.AnError
}
_, err = gCal.CreateEvent(date, genBodyFn)
assert.ErrorIs(t, err, assert.AnError)
}

View File

@ -0,0 +1,343 @@
// Package calendartest is not imported in production code, so it will not be compiled for Fleet server.
package calendartest
import (
"context"
"crypto/md5" //nolint:gosec // (only used in testing)
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
_ "github.com/mattn/go-sqlite3"
"google.golang.org/api/calendar/v3"
"hash/fnv"
"io"
"log"
"net/http"
"os"
"time"
)
// This calendar does not support all-day events.
var db *sql.DB
var timezones = []string{
"America/Chicago",
"America/New_York",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"America/Argentina/Buenos_Aires",
"Asia/Kolkata",
"Europe/London",
"Europe/Paris",
"Australia/Sydney",
}
func Configure(dbPath string) (http.Handler, error) {
var err error
db, err = sql.Open("sqlite3", dbPath)
if err != nil {
log.Fatal(err)
}
logger := log.New(os.Stdout, "", log.LstdFlags)
logger.Println("Server is starting...")
// Initialize the database schema if needed
err = initializeSchema()
if err != nil {
return nil, err
}
router := http.NewServeMux()
router.HandleFunc("/settings", getSetting)
router.HandleFunc("/events", getEvent)
router.HandleFunc("/events/list", getEvents)
router.HandleFunc("/events/add", addEvent)
router.HandleFunc("/events/delete", deleteEvent)
return logging(logger)(router), nil
}
func logging(logger *log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
defer func() {
logger.Println(r.Method, r.URL.String(), r.RemoteAddr)
}()
next.ServeHTTP(w, r)
},
)
}
}
func Close() {
_ = db.Close()
}
func getSetting(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "missing name", http.StatusBadRequest)
return
}
if name != "timezone" {
http.Error(w, "unsupported setting", http.StatusNotFound)
return
}
email := r.URL.Query().Get("email")
if email == "" {
http.Error(w, "missing email", http.StatusBadRequest)
return
}
timezone := getTimezone(email)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
setting := calendar.Setting{Value: timezone}
err := json.NewEncoder(w).Encode(setting)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// The timezone is determined by the user's email address
func getTimezone(email string) string {
index := hash(email) % uint32(len(timezones))
timezone := timezones[index]
return timezone
}
func hash(s string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(s))
return h.Sum32()
}
// getEvent handles GET /events?id=123
func getEvent(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
sqlStmt := "SELECT email, start, end, summary, description, status FROM events WHERE id = ?"
var start, end int64
var email, summary, description, status string
err := db.QueryRow(sqlStmt, id).Scan(&email, &start, &end, &summary, &description, &status)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
timezone := getTimezone(email)
loc, err := time.LoadLocation(timezone)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
calEvent := calendar.Event{}
calEvent.Id = id
calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)}
calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)}
calEvent.Summary = summary
calEvent.Description = description
calEvent.Status = status
calEvent.Etag = computeETag(start, end, summary, description, status)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(calEvent)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func getEvents(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
if email == "" {
http.Error(w, "missing email", http.StatusBadRequest)
return
}
timeMin := r.URL.Query().Get("timemin")
if email == "" {
http.Error(w, "missing timemin", http.StatusBadRequest)
return
}
timeMax := r.URL.Query().Get("timemax")
if email == "" {
http.Error(w, "missing timemax", http.StatusBadRequest)
return
}
minTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMin})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
maxTime, err := parseDateTime(r.Context(), &calendar.EventDateTime{DateTime: timeMax})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sqlStmt := "SELECT id, start, end, summary, description, status FROM events WHERE email = ? AND end > ? AND start < ?"
rows, err := db.Query(sqlStmt, email, minTime.Unix(), maxTime.Unix())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
timezone := getTimezone(email)
loc, err := time.LoadLocation(timezone)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
events := calendar.Events{}
events.Items = make([]*calendar.Event, 0)
for rows.Next() {
var id, start, end int64
var summary, description, status string
err = rows.Scan(&id, &start, &end, &summary, &description, &status)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
calEvent := calendar.Event{}
calEvent.Id = fmt.Sprintf("%d", id)
calEvent.Start = &calendar.EventDateTime{DateTime: time.Unix(start, 0).In(loc).Format(time.RFC3339)}
calEvent.End = &calendar.EventDateTime{DateTime: time.Unix(end, 0).In(loc).Format(time.RFC3339)}
calEvent.Summary = summary
calEvent.Description = description
calEvent.Status = status
calEvent.Etag = computeETag(start, end, summary, description, status)
events.Items = append(events.Items, &calEvent)
}
if err = rows.Err(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err = json.NewEncoder(w).Encode(events)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// addEvent handles POST /events/add?email=user@example.com
func addEvent(w http.ResponseWriter, r *http.Request) {
var event calendar.Event
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = json.Unmarshal(body, &event)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
email := r.URL.Query().Get("email")
if email == "" {
http.Error(w, "missing email", http.StatusBadRequest)
return
}
start, err := parseDateTime(r.Context(), event.Start)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
end, err := parseDateTime(r.Context(), event.End)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
status := "confirmed"
sqlStmt := `INSERT INTO events (email, start, end, summary, description, status) VALUES (?, ?, ?, ?, ?, ?)`
result, err := db.Exec(sqlStmt, email, start.Unix(), end.Unix(), event.Summary, event.Description, status)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
id, err := result.LastInsertId()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
event.Id = fmt.Sprintf("%d", id)
event.Etag = computeETag(start.Unix(), end.Unix(), event.Summary, event.Description, status)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
err = json.NewEncoder(w).Encode(event)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func computeETag(args ...any) string {
h := md5.New() //nolint:gosec // (only used for tests)
_, _ = fmt.Fprint(h, args...)
checksum := h.Sum(nil)
return hex.EncodeToString(checksum)
}
// deleteEvent handles DELETE /events/delete?id=123
func deleteEvent(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
sqlStmt := "DELETE FROM events WHERE id = ?"
_, err := db.Exec(sqlStmt, id)
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusGone)
return
}
}
func initializeSchema() error {
createTableSQL := `CREATE TABLE IF NOT EXISTS events (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"start" INTEGER NOT NULL,
"end" INTEGER NOT NULL,
"summary" TEXT NOT NULL,
"description" TEXT NOT NULL,
"status" TEXT NOT NULL
);`
_, err := db.Exec(createTableSQL)
if err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
return nil
}
func parseDateTime(ctx context.Context, eventDateTime *calendar.EventDateTime) (*time.Time, error) {
var t time.Time
var err error
if eventDateTime.TimeZone != "" {
var loc *time.Location
loc, err = time.LoadLocation(eventDateTime.TimeZone)
if err == nil {
t, err = time.ParseInLocation(time.RFC3339, eventDateTime.DateTime, loc)
}
} else {
t, err = time.Parse(time.RFC3339, eventDateTime.DateTime)
}
if err != nil {
return nil, ctxerr.Wrap(
ctx, err, fmt.Sprintf("parsing calendar event time: %s", eventDateTime.DateTime),
)
}
return &t, nil
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server"
@ -196,6 +197,7 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T
} }
if payload.Integrations != nil { if payload.Integrations != nil {
if payload.Integrations.Jira != nil || payload.Integrations.Zendesk != nil {
// the team integrations must reference an existing global config integration. // the team integrations must reference an existing global config integration.
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil { if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
return nil, fleet.NewInvalidArgumentError("integrations", err.Error()) return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
@ -209,6 +211,16 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T
team.Config.Integrations.Jira = payload.Integrations.Jira team.Config.Integrations.Jira = payload.Integrations.Jira
team.Config.Integrations.Zendesk = payload.Integrations.Zendesk team.Config.Integrations.Zendesk = payload.Integrations.Zendesk
} }
// Only update the calendar integration if it's not nil
if payload.Integrations.GoogleCalendar != nil {
invalid := &fleet.InvalidArgumentError{}
_ = svc.validateTeamCalendarIntegrations(payload.Integrations.GoogleCalendar, appCfg, invalid)
if invalid.HasErrors() {
return nil, ctxerr.Wrap(ctx, invalid)
}
team.Config.Integrations.GoogleCalendar = payload.Integrations.GoogleCalendar
}
}
if payload.WebhookSettings != nil || payload.Integrations != nil { if payload.WebhookSettings != nil || payload.Integrations != nil {
// must validate that at most only one automation is enabled for each // must validate that at most only one automation is enabled for each
@ -1081,6 +1093,15 @@ func (svc *Service) editTeamFromSpec(
fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid) fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid)
team.Config.WebhookSettings.HostStatusWebhook = spec.WebhookSettings.HostStatusWebhook team.Config.WebhookSettings.HostStatusWebhook = spec.WebhookSettings.HostStatusWebhook
} }
if spec.Integrations.GoogleCalendar != nil {
err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, invalid)
if err != nil {
return ctxerr.Wrap(ctx, err, "validate team calendar integrations")
}
team.Config.Integrations.GoogleCalendar = spec.Integrations.GoogleCalendar
}
if invalid.HasErrors() { if invalid.HasErrors() {
return ctxerr.Wrap(ctx, invalid) return ctxerr.Wrap(ctx, invalid)
} }
@ -1137,7 +1158,9 @@ func (svc *Service) editTeamFromSpec(
} }
if didUpdateMacOSEndUserAuth { if didUpdateMacOSEndUserAuth {
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil { if err := svc.updateMacOSSetupEnableEndUserAuth(
ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name,
); err != nil {
return err return err
} }
} }
@ -1145,6 +1168,26 @@ func (svc *Service) editTeamFromSpec(
return nil return nil
} }
func (svc *Service) validateTeamCalendarIntegrations(
calendarIntegration *fleet.TeamGoogleCalendarIntegration,
appCfg *fleet.AppConfig, invalid *fleet.InvalidArgumentError,
) error {
if !calendarIntegration.Enable {
return nil
}
// Check that global configs exist
if len(appCfg.Integrations.GoogleCalendar) == 0 {
invalid.Append("integrations.google_calendar.enable_calendar_events", "global Google Calendar integration is not configured")
}
// Validate URL
if u, err := url.ParseRequestURI(calendarIntegration.WebhookURL); err != nil {
invalid.Append("integrations.google_calendar.webhook_url", err.Error())
} else if u.Scheme != "https" && u.Scheme != "http" {
invalid.Append("integrations.google_calendar.webhook_url", "webhook_url must be https or http")
}
return nil
}
func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.TeamSpec, applyUpon *fleet.MacOSSettings) error { func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.TeamSpec, applyUpon *fleet.MacOSSettings) error {
oldCustomSettings := applyUpon.CustomSettings oldCustomSettings := applyUpon.CustomSettings
setFields, err := applyUpon.FromMap(spec.MDM.MacOSSettings) setFields, err := applyUpon.FromMap(spec.MDM.MacOSSettings)

View File

@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
integrations: { integrations: {
jira: [], jira: [],
zendesk: [], zendesk: [],
google_calendar: [],
}, },
logging: { logging: {
debug: false, debug: false,

View File

@ -22,6 +22,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = {
webhook: "Off", webhook: "Off",
has_run: true, has_run: true,
next_update_ms: 3600000, next_update_ms: 3600000,
calendar_events_enabled: true,
}; };
const createMockPolicy = (overrides?: Partial<IPolicyStats>): IPolicyStats => { const createMockPolicy = (overrides?: Partial<IPolicyStats>): IPolicyStats => {

View File

@ -51,12 +51,7 @@
} }
&__copy-message { &__copy-message {
font-weight: $regular; @include copy-message;
vertical-align: top;
background-color: $ui-light-grey;
border: solid 1px #e2e4ea;
border-radius: 10px;
padding: 2px 6px;
} }
.buttons { .buttons {
@ -122,9 +117,6 @@
} }
&__copy-message { &__copy-message {
background-color: $ui-light-grey; @include copy-message;
border: solid 1px #e2e4ea;
border-radius: 10px;
padding: 2px 6px;
} }
} }

View File

@ -40,10 +40,7 @@
} }
&__copy-message { &__copy-message {
background-color: $ui-light-grey; @include copy-message;
border: solid 1px #e2e4ea;
border-radius: 10px;
padding: 2px 6px;
} }
&__action-overlay { &__action-overlay {

View File

@ -3,6 +3,7 @@ import classnames from "classnames";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
import TooltipWrapper from "components/TooltipWrapper"; import TooltipWrapper from "components/TooltipWrapper";
import { PlacesType } from "react-tooltip-5";
// all form-field styles are defined in _global.scss, which apply here and elsewhere // all form-field styles are defined in _global.scss, which apply here and elsewhere
const baseClass = "form-field"; const baseClass = "form-field";
@ -16,6 +17,7 @@ export interface IFormFieldProps {
name: string; name: string;
type: string; type: string;
tooltip?: React.ReactNode; tooltip?: React.ReactNode;
labelTooltipPosition?: PlacesType;
} }
const FormField = ({ const FormField = ({
@ -27,6 +29,7 @@ const FormField = ({
name, name,
type, type,
tooltip, tooltip,
labelTooltipPosition,
}: IFormFieldProps): JSX.Element => { }: IFormFieldProps): JSX.Element => {
const renderLabel = () => { const renderLabel = () => {
const labelWrapperClasses = classnames(`${baseClass}__label`, { const labelWrapperClasses = classnames(`${baseClass}__label`, {
@ -45,7 +48,10 @@ const FormField = ({
> >
{error || {error ||
(tooltip ? ( (tooltip ? (
<TooltipWrapper tipContent={tooltip}> <TooltipWrapper
tipContent={tooltip}
position={labelTooltipPosition || "top-start"}
>
{label as string} {label as string}
</TooltipWrapper> </TooltipWrapper>
) : ( ) : (

View File

@ -33,6 +33,7 @@ class InputField extends Component {
]).isRequired, ]).isRequired,
parseTarget: PropTypes.bool, parseTarget: PropTypes.bool,
tooltip: PropTypes.string, tooltip: PropTypes.string,
labelTooltipPosition: PropTypes.string,
helpText: PropTypes.oneOfType([ helpText: PropTypes.oneOfType([
PropTypes.string, PropTypes.string,
PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.string),
@ -55,6 +56,7 @@ class InputField extends Component {
value: "", value: "",
parseTarget: false, parseTarget: false,
tooltip: "", tooltip: "",
labelTooltipPosition: "",
helpText: "", helpText: "",
enableCopy: false, enableCopy: false,
ignore1password: false, ignore1password: false,
@ -124,6 +126,7 @@ class InputField extends Component {
"error", "error",
"name", "name",
"tooltip", "tooltip",
"labelTooltipPosition",
]); ]);
const copyValue = (e) => { const copyValue = (e) => {

View File

@ -43,9 +43,6 @@
} }
&__copy-message { &__copy-message {
background-color: $ui-light-grey; @include copy-message;
border: solid 1px #e2e4ea;
border-radius: 10px;
padding: 2px 6px;
} }
} }

View File

@ -6,7 +6,10 @@ import FormField from "components/forms/FormField";
import { IFormFieldProps } from "components/forms/FormField/FormField"; import { IFormFieldProps } from "components/forms/FormField/FormField";
interface ISliderProps { interface ISliderProps {
onChange: () => void; onChange: (newValue?: {
name: string;
value: string | number | boolean;
}) => void;
value: boolean; value: boolean;
inactiveText: string; inactiveText: string;
activeText: string; activeText: string;

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ import EmptyTeams from "./EmptyTeams";
import EmptyPacks from "./EmptyPacks"; import EmptyPacks from "./EmptyPacks";
import EmptySchedule from "./EmptySchedule"; import EmptySchedule from "./EmptySchedule";
import CollectingResults from "./CollectingResults"; import CollectingResults from "./CollectingResults";
import CalendarEventPreview from "./CalendarEventPreview";
export const GRAPHIC_MAP = { export const GRAPHIC_MAP = {
// Empty state graphics // Empty state graphics
@ -41,6 +42,7 @@ export const GRAPHIC_MAP = {
"file-pem": FilePem, "file-pem": FilePem,
// Other graphics // Other graphics
"collecting-results": CollectingResults, "collecting-results": CollectingResults,
"calendar-event-preview": CalendarEventPreview,
}; };
export type GraphicNames = keyof typeof GRAPHIC_MAP; export type GraphicNames = keyof typeof GRAPHIC_MAP;

View File

@ -0,0 +1,36 @@
import { useState } from "react";
import { IPolicy } from "interfaces/policy";
interface ICheckedPolicy {
name?: string;
id: number;
isChecked: boolean;
}
const useCheckboxListStateManagement = (
allPolicies: IPolicy[],
automatedPolicies: number[] | undefined
) => {
const [policyItems, setPolicyItems] = useState<ICheckedPolicy[]>(() => {
return allPolicies.map(({ name, id }) => ({
name,
id,
isChecked: !!automatedPolicies?.includes(id),
}));
});
const updatePolicyItems = (policyId: number) => {
setPolicyItems((prevItems) =>
prevItems.map((policy) =>
policy.id !== policyId
? policy
: { ...policy, isChecked: !policy.isChecked }
)
);
};
return { policyItems, updatePolicyItems };
};
export default useCheckboxListStateManagement;

View File

@ -4,7 +4,7 @@ import {
IWebhookFailingPolicies, IWebhookFailingPolicies,
IWebhookSoftwareVulnerabilities, IWebhookSoftwareVulnerabilities,
} from "interfaces/webhook"; } from "interfaces/webhook";
import { IIntegrations } from "./integration"; import { IGlobalIntegrations } from "./integration";
export interface ILicense { export interface ILicense {
tier: string; tier: string;
@ -123,7 +123,7 @@ export interface IConfig {
}; };
sandbox_enabled: boolean; sandbox_enabled: boolean;
server_settings: IConfigServerSettings; server_settings: IConfigServerSettings;
smtp_settings: { smtp_settings?: {
enable_smtp: boolean; enable_smtp: boolean;
configured: boolean; configured: boolean;
sender_address: string; sender_address: string;
@ -176,7 +176,7 @@ export interface IConfig {
// databases_path: string; // databases_path: string;
// }; // };
webhook_settings: IWebhookSettings; webhook_settings: IWebhookSettings;
integrations: IIntegrations; integrations: IGlobalIntegrations;
logging: { logging: {
debug: boolean; debug: boolean;
json: boolean; json: boolean;

View File

@ -60,7 +60,32 @@ export interface IIntegrationFormErrors {
enableSoftwareVulnerabilities?: boolean; enableSoftwareVulnerabilities?: boolean;
} }
export interface IIntegrations { export interface IGlobalCalendarIntegration {
domain: string;
api_key_json: string;
}
interface ITeamCalendarSettings {
enable_calendar_events: boolean;
webhook_url: string;
}
// zendesk and jira fields are coupled if one is present, the other needs to be present. If
// one is present and the other is null/missing, the other will be nullified. google_calendar is
// separated it can be present without the other 2 without nullifying them.
// TODO: Update these types to reflect this.
export interface IZendeskJiraIntegrations {
zendesk: IZendeskIntegration[]; zendesk: IZendeskIntegration[];
jira: IJiraIntegration[]; jira: IJiraIntegration[];
} }
// reality is that IZendeskJiraIntegrations are optional should be something like `extends
// Partial<IZendeskJiraIntegrations>`, but that leads to a mess of types to resolve.
export interface IGlobalIntegrations extends IZendeskJiraIntegrations {
google_calendar?: IGlobalCalendarIntegration[] | null;
}
export interface ITeamIntegrations extends IZendeskJiraIntegrations {
google_calendar?: ITeamCalendarSettings | null;
}

View File

@ -40,6 +40,7 @@ export interface IPolicy {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
critical: boolean; critical: boolean;
calendar_events_enabled: boolean;
} }
// Used on the manage hosts page and other places where aggregate stats are displayed // Used on the manage hosts page and other places where aggregate stats are displayed
@ -90,6 +91,7 @@ export interface IPolicyFormData {
query?: string | number | boolean | undefined; query?: string | number | boolean | undefined;
team_id?: number; team_id?: number;
id?: number; id?: number;
calendar_events_enabled?: boolean;
} }
export interface IPolicyNew { export interface IPolicyNew {

View File

@ -1,7 +1,7 @@
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { IConfigFeatures, IWebhookSettings } from "./config"; import { IConfigFeatures, IWebhookSettings } from "./config";
import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret"; import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret";
import { IIntegrations } from "./integration"; import { ITeamIntegrations } from "./integration";
import { UserRole } from "./user"; import { UserRole } from "./user";
export default PropTypes.shape({ export default PropTypes.shape({
@ -83,7 +83,7 @@ export type ITeamWebhookSettings = Pick<
*/ */
export interface ITeamAutomationsConfig { export interface ITeamAutomationsConfig {
webhook_settings: ITeamWebhookSettings; webhook_settings: ITeamWebhookSettings;
integrations: IIntegrations; integrations: ITeamIntegrations;
} }
/** /**

View File

@ -31,12 +31,7 @@
} }
&__copy-message { &__copy-message {
font-weight: $regular; @include copy-message;
vertical-align: top;
background-color: $ui-light-grey;
border: solid 1px #e2e4ea;
border-radius: 10px;
padding: 2px 6px;
} }
&__secret-download-icon { &__secret-download-icon {

View File

@ -91,7 +91,11 @@ const AccountPage = ({ router }: IAccountPageProps): JSX.Element | null => {
await usersAPI.update(currentUser.id, updated); await usersAPI.update(currentUser.id, updated);
let accountUpdatedFlashMessage = "Account updated"; let accountUpdatedFlashMessage = "Account updated";
if (updated.email) { if (updated.email) {
accountUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${updated.email}`; // always present, this for typing
const senderAddressMessage = config?.smtp_settings?.sender_address
? ` from ${config?.smtp_settings?.sender_address}`
: "";
accountUpdatedFlashMessage += `: A confirmation email was sent${senderAddressMessage} to ${updated.email}`;
setPendingEmail(updated.email); setPendingEmail(updated.email);
} }

View File

@ -11,7 +11,7 @@ import {
import { import {
IJiraIntegration, IJiraIntegration,
IZendeskIntegration, IZendeskIntegration,
IIntegrations, IZendeskJiraIntegrations,
} from "interfaces/integration"; } from "interfaces/integration";
import { ITeamConfig } from "interfaces/team"; import { ITeamConfig } from "interfaces/team";
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook"; import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
@ -186,7 +186,9 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const vulnWebhookSettings = const vulnWebhookSettings =
softwareConfig?.webhook_settings?.vulnerabilities_webhook; softwareConfig?.webhook_settings?.vulnerabilities_webhook;
const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook; const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
const isVulnIntegrationEnabled = (integrations?: IIntegrations) => { const isVulnIntegrationEnabled = (
integrations?: IZendeskJiraIntegrations
) => {
return ( return (
!!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) || !!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
!!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities) !!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)

View File

@ -8,7 +8,7 @@ import {
IJiraIntegration, IJiraIntegration,
IZendeskIntegration, IZendeskIntegration,
IIntegration, IIntegration,
IIntegrations, IGlobalIntegrations,
IIntegrationType, IIntegrationType,
} from "interfaces/integration"; } from "interfaces/integration";
import { import {
@ -124,7 +124,7 @@ const ManageAutomationsModal = ({
} }
}, [destinationUrl]); }, [destinationUrl]);
const { data: integrations } = useQuery<IConfig, Error, IIntegrations>( const { data: integrations } = useQuery<IConfig, Error, IGlobalIntegrations>(
["integrations"], ["integrations"],
() => configAPI.loadAll(), () => configAPI.loadAll(),
{ {

View File

@ -4,11 +4,9 @@ import { ISideNavItem } from "../components/SideNav/SideNav";
import Integrations from "./cards/Integrations"; import Integrations from "./cards/Integrations";
import Mdm from "./cards/MdmSettings/MdmSettings"; import Mdm from "./cards/MdmSettings/MdmSettings";
import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment"; import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment";
import Calendars from "./cards/Calendars/Calendars";
const getFilteredIntegrationSettingsNavItems = ( const integrationSettingsNavItems: ISideNavItem<any>[] = [
isSandboxMode = false
): ISideNavItem<any>[] => {
return [
// TODO: types // TODO: types
{ {
title: "Ticket destinations", title: "Ticket destinations",
@ -21,7 +19,6 @@ const getFilteredIntegrationSettingsNavItems = (
urlSection: "mdm", urlSection: "mdm",
path: PATHS.ADMIN_INTEGRATIONS_MDM, path: PATHS.ADMIN_INTEGRATIONS_MDM,
Card: Mdm, Card: Mdm,
exclude: isSandboxMode,
}, },
{ {
title: "Automatic enrollment", title: "Automatic enrollment",
@ -29,7 +26,12 @@ const getFilteredIntegrationSettingsNavItems = (
path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT, path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT,
Card: AutomaticEnrollment, Card: AutomaticEnrollment,
}, },
].filter((navItem) => !navItem.exclude); {
}; title: "Calendars",
urlSection: "calendars",
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
Card: Calendars,
},
];
export default getFilteredIntegrationSettingsNavItems; export default integrationSettingsNavItems;

View File

@ -1,9 +1,8 @@
import { AppContext } from "context/app"; import React from "react";
import React, { useContext } from "react";
import { InjectedRouter, Params } from "react-router/lib/Router"; import { InjectedRouter, Params } from "react-router/lib/Router";
import SideNav from "../components/SideNav"; import SideNav from "../components/SideNav";
import getFilteredIntegrationSettingsNavItems from "./IntegrationNavItems"; import integrationSettingsNavItems from "./IntegrationNavItems";
const baseClass = "integrations"; const baseClass = "integrations";
@ -16,9 +15,8 @@ const IntegrationsPage = ({
router, router,
params, params,
}: IIntegrationSettingsPageProps) => { }: IIntegrationSettingsPageProps) => {
const { isSandboxMode } = useContext(AppContext);
const { section } = params; const { section } = params;
const navItems = getFilteredIntegrationSettingsNavItems(isSandboxMode); const navItems = integrationSettingsNavItems;
const DEFAULT_SETTINGS_SECTION = navItems[0]; const DEFAULT_SETTINGS_SECTION = navItems[0];
const currentSection = const currentSection =
navItems.find((item) => item.urlSection === section) ?? navItems.find((item) => item.urlSection === section) ??

View File

@ -0,0 +1,428 @@
import React, { useState, useContext, useCallback } from "react";
import { useQuery } from "react-query";
import { IConfig } from "interfaces/config";
import { NotificationContext } from "context/notification";
import { AppContext } from "context/app";
import configAPI from "services/entities/config";
// @ts-ignore
import { stringToClipboard } from "utilities/copy_text";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Button from "components/buttons/Button";
import SectionHeader from "components/SectionHeader";
import CustomLink from "components/CustomLink";
import Spinner from "components/Spinner";
import DataError from "components/DataError";
import PremiumFeatureMessage from "components/PremiumFeatureMessage/PremiumFeatureMessage";
import Icon from "components/Icon";
const CREATING_SERVICE_ACCOUNT =
"https://www.fleetdm.com/learn-more-about/creating-service-accounts";
const GOOGLE_WORKSPACE_DOMAINS =
"https://www.fleetdm.com/learn-more-about/google-workspace-domains";
const DOMAIN_WIDE_DELEGATION =
"https://www.fleetdm.com/learn-more-about/domain-wide-delegation";
const ENABLING_CALENDAR_API =
"fleetdm.com/learn-more-about/enabling-calendar-api";
const OAUTH_SCOPES =
"https://www.googleapis.com/auth/calendar.events,https://www.googleapis.com/auth/calendar.settings.readonly";
const API_KEY_JSON_PLACEHOLDER = `{
"type": "service_account",
"project_id": "fleet-in-your-calendar",
"private_key_id": "<private key id>",
"private_key": "-----BEGIN PRIVATE KEY----\\n<private key>\\n-----END PRIVATE KEY-----\\n",
"client_email": "fleet-calendar-events@fleet-in-your-calendar.iam.gserviceaccount.com",
"client_id": "<client id>",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/fleet-calendar-events%40fleet-in-your-calendar.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}`;
interface IFormField {
name: string;
value: string | boolean | number;
}
interface ICalendarsFormErrors {
domain?: string | null;
apiKeyJson?: string | null;
}
interface ICalendarsFormData {
domain?: string;
apiKeyJson?: string;
}
// Used to surface error.message in UI of unknown error type
type ErrorWithMessage = {
message: string;
[key: string]: unknown;
};
const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => {
return (error as ErrorWithMessage).message !== undefined;
};
const baseClass = "calendars-integration";
const Calendars = (): JSX.Element => {
const { renderFlash } = useContext(NotificationContext);
const { isPremiumTier } = useContext(AppContext);
const [formData, setFormData] = useState<ICalendarsFormData>({
domain: "",
apiKeyJson: "",
});
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [formErrors, setFormErrors] = useState<ICalendarsFormErrors>({});
const [copyMessage, setCopyMessage] = useState<string>("");
const {
isLoading: isLoadingAppConfig,
refetch: refetchConfig,
error: errorAppConfig,
} = useQuery<IConfig, Error, IConfig>(["config"], () => configAPI.loadAll(), {
select: (data: IConfig) => data,
onSuccess: (data) => {
if (data.integrations.google_calendar) {
setFormData({
domain: data.integrations.google_calendar[0].domain,
// Formats string for better UI readability
apiKeyJson: JSON.stringify(
data.integrations.google_calendar[0].api_key_json,
null,
"\t"
),
});
}
},
});
const { apiKeyJson, domain } = formData;
const validateForm = (curFormData: ICalendarsFormData) => {
const errors: ICalendarsFormErrors = {};
// Must set all keys or no keys at all
if (!curFormData.apiKeyJson && !!curFormData.domain) {
errors.apiKeyJson = "API key JSON must be present";
}
if (!curFormData.domain && !!curFormData.apiKeyJson) {
errors.domain = "Domain must be present";
}
if (curFormData.apiKeyJson) {
try {
JSON.parse(curFormData.apiKeyJson);
} catch (e: unknown) {
if (isErrorWithMessage(e)) {
errors.apiKeyJson = e.message.toString();
} else {
throw e;
}
}
}
return errors;
};
const onInputChange = useCallback(
({ name, value }: IFormField) => {
const newFormData = { ...formData, [name]: value };
setFormData(newFormData);
setFormErrors(validateForm(newFormData));
},
[formData]
);
const onFormSubmit = async (evt: React.MouseEvent<HTMLFormElement>) => {
setIsUpdatingSettings(true);
evt.preventDefault();
// Format for API
const formDataToSubmit =
formData.apiKeyJson === "" && formData.domain === ""
? [] // Send empty array if no keys are set
: [
{
domain: formData.domain,
api_key_json:
(formData.apiKeyJson && JSON.parse(formData.apiKeyJson)) ||
null,
},
];
// Update integrations.google_calendar only
const destination = {
google_calendar: formDataToSubmit,
};
try {
await configAPI.update({ integrations: destination });
renderFlash(
"success",
"Successfully saved calendar integration settings"
);
refetchConfig();
} catch (e) {
renderFlash("error", "Could not save calendar integration settings");
} finally {
setIsUpdatingSettings(false);
}
};
const renderOauthLabel = () => {
const onCopyOauthScopes = (evt: React.MouseEvent) => {
evt.preventDefault();
stringToClipboard(OAUTH_SCOPES)
.then(() => setCopyMessage(() => "Copied!"))
.catch(() => setCopyMessage(() => "Copy failed"));
// Clear message after 1 second
setTimeout(() => setCopyMessage(() => ""), 1000);
return false;
};
return (
<span className={`${baseClass}__oauth-scopes-copy-icon-wrapper`}>
<Button
variant="unstyled"
className={`${baseClass}__oauth-scopes-copy-icon`}
onClick={onCopyOauthScopes}
>
<Icon name="copy" />
</Button>
{copyMessage && (
<span className={`${baseClass}__copy-message`}>{copyMessage}</span>
)}
</span>
);
};
const renderForm = () => {
return (
<>
<SectionHeader title="Calendars" />
<p className={`${baseClass}__page-description`}>
To create calendar events for end users with failing policies,
you&apos;ll need to configure a dedicated Google Workspace service
account.
</p>
<div className={`${baseClass}__section-instructions`}>
<p>
1. Go to the <b>Service Accounts</b> page in Google Cloud Platform.{" "}
<CustomLink
text="View page"
url={CREATING_SERVICE_ACCOUNT}
newTab
/>
</p>
<p>
2. Create a new project for your service account.
<ul>
<li>
Click <b>Create project</b>.
</li>
<li>
Enter &quot;Fleet calendar events&quot; as the project name.
</li>
<li>
For &quot;Organization&quot; and &quot;Location&quot;, select
your calendar&apos;s organization.
</li>
</ul>
</p>
<p>
3. Create the service account.
<ul>
<li>
Click <b>Create service account</b>.
</li>
<li>
Set the service account name to &quot;Fleet calendar
events&quot;.
</li>
<li>
Set the service account ID to &quot;fleet-calendar-events&quot;.
</li>
<li>
Click <b>Create and continue</b>.
</li>
<li>
Click <b>Done</b> at the bottom of the form. (No need to
complete the optional steps.)
</li>
</ul>
</p>
<p>
4. Create an API key.{" "}
<ul>
<li>
Click the <b>Actions</b> menu for your new service account.
</li>
<li>
Select <b>Manage keys</b>.
</li>
<li>
Click <b>Add key &gt; Create new key</b>.
</li>
<li>Select the JSON key type.</li>
<li>
Click <b>Create</b> to create the key & download a JSON file.
</li>
<li className={`${baseClass}__configuration`}>
Configure your service account integration in Fleet using the
form below:
<form onSubmit={onFormSubmit} autoComplete="off">
<InputField
label="API key JSON"
onChange={onInputChange}
name="apiKeyJson"
value={apiKeyJson}
parseTarget
type="textarea"
tooltip={
<>
Paste the full contents of the JSON file downloaded{" "}
<br />
when creating your service account API key.
</>
}
placeholder={API_KEY_JSON_PLACEHOLDER}
ignore1password
inputClassName={`${baseClass}__api-key-json`}
error={formErrors.apiKeyJson}
/>
<InputField
label="Primary domain"
onChange={onInputChange}
name="domain"
value={domain}
parseTarget
tooltip={
<>
If the end user is signed into multiple Google accounts,
this will be used to identify their work calendar.
</>
}
placeholder="example.com"
helpText={
<>
You can find your primary domain in Google Workspace{" "}
<CustomLink
url={GOOGLE_WORKSPACE_DOMAINS}
text="here"
newTab
/>
</>
}
error={formErrors.domain}
/>
<Button
type="submit"
variant="brand"
disabled={Object.keys(formErrors).length > 0}
className="save-loading"
isLoading={isUpdatingSettings}
>
Save
</Button>
</form>
</li>
</ul>
</p>
<p>
5. Authorize the service account via domain-wide delegation.
<ul>
<li>
In Google Workspace, go to{" "}
<b>
Security &gt; Access and data control &gt; API controls &gt;
Manage Domain Wide Delegation
</b>
.{" "}
<CustomLink
url={DOMAIN_WIDE_DELEGATION}
text="View page"
newTab
/>
</li>
<li>
Under <b>API clients</b>, click <b>Add new</b>.
</li>
<li>
Enter the client ID for the service account. You can find this
in your downloaded API key JSON file (
<span className={`${baseClass}__code`}>client_id</span>
), or under <b>Advanced Settings</b> when viewing the service
account.
</li>
<li>
For the OAuth scopes, paste the following value:
<InputField
disabled
inputWrapperClass={`${baseClass}__oauth-scopes`}
name="oauth-scopes"
label={renderOauthLabel()}
type="textarea"
value={OAUTH_SCOPES}
/>
</li>
<li>
Click <b>Authorize</b>.
</li>
</ul>
</p>
<p>
6. Enable the Google Calendar API.
<ul>
<li>
In the Google Cloud console API library, go to the Google
Calendar API.{" "}
<CustomLink
url={ENABLING_CALENDAR_API}
text="View page"
newTab
/>
</li>
<li>
Make sure the &quot;Fleet calendar events&quot; project is
selected at the top of the page.
</li>
<li>
Click <b>Enable</b>.
</li>
</ul>
</p>
<p>
You&apos;re ready to automatically schedule calendar events for end
users.
</p>
</div>
</>
);
};
if (!isPremiumTier) return <PremiumFeatureMessage />;
if (isLoadingAppConfig) {
<div className={baseClass}>
<Spinner includeContainer={false} />
</div>;
}
if (errorAppConfig) {
return <DataError />;
}
return <div className={baseClass}>{renderForm()}</div>;
};
export default Calendars;

View File

@ -0,0 +1,62 @@
.calendars-integration {
&__page-description {
font-size: $x-small;
color: $core-fleet-black;
}
p {
margin: $pad-large 0;
}
ui {
margin-block-start: $pad-small;
}
li {
margin: $pad-small 0;
}
form {
margin-top: $pad-large;
}
&__configuration {
button {
align-self: flex-end;
}
}
&__api-key-json {
min-width: 100%; // resize vertically only
height: 294px;
font-size: $x-small;
}
#oauth-scopes {
font-family: "SourceCodePro", $monospace;
color: $core-fleet-black;
min-height: 80px;
padding: $pad-medium;
padding-right: $pad-xxlarge;
resize: none;
}
&__oauth-scopes-copy-icon-wrapper {
display: flex;
flex-direction: row-reverse;
align-items: center;
position: relative;
top: 36px;
right: 16px;
height: 0;
gap: 0.5rem;
}
&__copy-message {
@include copy-message;
}
&__code {
font-family: "SourceCodePro", $monospace;
}
}

View File

@ -0,0 +1 @@
export { default } from "./Calendars";

View File

@ -8,7 +8,7 @@ import {
IZendeskIntegration, IZendeskIntegration,
IIntegration, IIntegration,
IIntegrationTableData, IIntegrationTableData,
IIntegrations, IGlobalIntegrations,
} from "interfaces/integration"; } from "interfaces/integration";
import { IApiError } from "interfaces/errors"; import { IApiError } from "interfaces/errors";
@ -69,7 +69,7 @@ const Integrations = (): JSX.Element => {
isLoading: isLoadingIntegrations, isLoading: isLoadingIntegrations,
error: loadingIntegrationsError, error: loadingIntegrationsError,
refetch: refetchIntegrations, refetch: refetchIntegrations,
} = useQuery<IConfig, Error, IIntegrations>( } = useQuery<IConfig, Error, IGlobalIntegrations>(
["integrations"], ["integrations"],
() => configAPI.loadAll(), () => configAPI.loadAll(),
{ {
@ -133,9 +133,15 @@ const Integrations = (): JSX.Element => {
// Updates either integrations.jira or integrations.zendesk // Updates either integrations.jira or integrations.zendesk
const destination = () => { const destination = () => {
if (integrationDestination === "jira") { if (integrationDestination === "jira") {
return { jira: integrationSubmitData, zendesk: zendeskIntegrations }; return {
jira: integrationSubmitData,
zendesk: zendeskIntegrations,
};
} }
return { zendesk: integrationSubmitData, jira: jiraIntegrations }; return {
zendesk: integrationSubmitData,
jira: jiraIntegrations,
};
}; };
setTestingConnection(true); setTestingConnection(true);

View File

@ -4,7 +4,7 @@ import Modal from "components/Modal";
// @ts-ignore // @ts-ignore
import Dropdown from "components/forms/fields/Dropdown"; import Dropdown from "components/forms/fields/Dropdown";
import CustomLink from "components/CustomLink"; import CustomLink from "components/CustomLink";
import { IIntegration, IIntegrations } from "interfaces/integration"; import { IIntegration, IZendeskJiraIntegrations } from "interfaces/integration";
import IntegrationForm from "../IntegrationForm"; import IntegrationForm from "../IntegrationForm";
const baseClass = "add-integration-modal"; const baseClass = "add-integration-modal";
@ -17,7 +17,7 @@ interface IAddIntegrationModalProps {
) => void; ) => void;
serverErrors?: { base: string; email: string }; serverErrors?: { base: string; email: string };
backendValidators: { [key: string]: string }; backendValidators: { [key: string]: string };
integrations: IIntegrations; integrations: IZendeskJiraIntegrations;
testingConnection: boolean; testingConnection: boolean;
} }

View File

@ -4,7 +4,7 @@ import Modal from "components/Modal";
import Spinner from "components/Spinner"; import Spinner from "components/Spinner";
import { import {
IIntegration, IIntegration,
IIntegrations, IZendeskJiraIntegrations,
IIntegrationTableData, IIntegrationTableData,
} from "interfaces/integration"; } from "interfaces/integration";
import IntegrationForm from "../IntegrationForm"; import IntegrationForm from "../IntegrationForm";
@ -15,7 +15,7 @@ interface IEditIntegrationModalProps {
onCancel: () => void; onCancel: () => void;
onSubmit: (jiraIntegrationSubmitData: IIntegration[]) => void; onSubmit: (jiraIntegrationSubmitData: IIntegration[]) => void;
backendValidators: { [key: string]: string }; backendValidators: { [key: string]: string };
integrations: IIntegrations; integrations: IZendeskJiraIntegrations;
integrationEditing?: IIntegrationTableData; integrationEditing?: IIntegrationTableData;
testingConnection: boolean; testingConnection: boolean;
} }

View File

@ -5,7 +5,7 @@ import {
IIntegrationFormData, IIntegrationFormData,
IIntegrationTableData, IIntegrationTableData,
IIntegration, IIntegration,
IIntegrations, IZendeskJiraIntegrations,
IIntegrationType, IIntegrationType,
} from "interfaces/integration"; } from "interfaces/integration";
@ -26,7 +26,7 @@ interface IIntegrationFormProps {
integrationDestination: string integrationDestination: string
) => void; ) => void;
integrationEditing?: IIntegrationTableData; integrationEditing?: IIntegrationTableData;
integrations: IIntegrations; integrations: IZendeskJiraIntegrations;
integrationEditingUrl?: string; integrationEditingUrl?: string;
integrationEditingUsername?: string; integrationEditingUsername?: string;
integrationEditingEmail?: string; integrationEditingEmail?: string;

View File

@ -20,9 +20,9 @@ const Advanced = ({
isUpdatingSettings, isUpdatingSettings,
}: IAppConfigFormProps): JSX.Element => { }: IAppConfigFormProps): JSX.Element => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
domain: appConfig.smtp_settings.domain || "", domain: appConfig.smtp_settings?.domain || "",
verifySSLCerts: appConfig.smtp_settings.verify_ssl_certs || false, verifySSLCerts: appConfig.smtp_settings?.verify_ssl_certs || false,
enableStartTLS: appConfig.smtp_settings.enable_start_tls, enableStartTLS: appConfig.smtp_settings?.enable_start_tls,
enableHostExpiry: enableHostExpiry:
appConfig.host_expiry_settings.host_expiry_enabled || false, appConfig.host_expiry_settings.host_expiry_enabled || false,
hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0, hostExpiryWindow: appConfig.host_expiry_settings.host_expiry_window || 0,
@ -74,16 +74,16 @@ const Advanced = ({
scripts_disabled: disableScripts, scripts_disabled: disableScripts,
}, },
smtp_settings: { smtp_settings: {
enable_smtp: appConfig.smtp_settings.enable_smtp || false, enable_smtp: appConfig.smtp_settings?.enable_smtp || false,
sender_address: appConfig.smtp_settings.sender_address || "", sender_address: appConfig.smtp_settings?.sender_address || "",
server: appConfig.smtp_settings.server || "", server: appConfig.smtp_settings?.server || "",
port: Number(appConfig.smtp_settings.port), port: Number(appConfig.smtp_settings?.port),
authentication_type: appConfig.smtp_settings.authentication_type || "", authentication_type: appConfig.smtp_settings?.authentication_type || "",
user_name: appConfig.smtp_settings.user_name || "", user_name: appConfig.smtp_settings?.user_name || "",
password: appConfig.smtp_settings.password || "", password: appConfig.smtp_settings?.password || "",
enable_ssl_tls: appConfig.smtp_settings.enable_ssl_tls || false, enable_ssl_tls: appConfig.smtp_settings?.enable_ssl_tls || false,
authentication_method: authentication_method:
appConfig.smtp_settings.authentication_method || "", appConfig.smtp_settings?.authentication_method || "",
domain, domain,
verify_ssl_certs: verifySSLCerts, verify_ssl_certs: verifySSLCerts,
enable_start_tls: enableStartTLS, enable_start_tls: enableStartTLS,

View File

@ -31,16 +31,16 @@ const Smtp = ({
const { isPremiumTier } = useContext(AppContext); const { isPremiumTier } = useContext(AppContext);
const [formData, setFormData] = useState<any>({ const [formData, setFormData] = useState<any>({
enableSMTP: appConfig.smtp_settings.enable_smtp || false, enableSMTP: appConfig.smtp_settings?.enable_smtp || false,
smtpSenderAddress: appConfig.smtp_settings.sender_address || "", smtpSenderAddress: appConfig.smtp_settings?.sender_address || "",
smtpServer: appConfig.smtp_settings.server || "", smtpServer: appConfig.smtp_settings?.server || "",
smtpPort: appConfig.smtp_settings.port, smtpPort: appConfig.smtp_settings?.port,
smtpEnableSSLTLS: appConfig.smtp_settings.enable_ssl_tls || false, smtpEnableSSLTLS: appConfig.smtp_settings?.enable_ssl_tls || false,
smtpAuthenticationType: appConfig.smtp_settings.authentication_type || "", smtpAuthenticationType: appConfig.smtp_settings?.authentication_type || "",
smtpUsername: appConfig.smtp_settings.user_name || "", smtpUsername: appConfig.smtp_settings?.user_name || "",
smtpPassword: appConfig.smtp_settings.password || "", smtpPassword: appConfig.smtp_settings?.password || "",
smtpAuthenticationMethod: smtpAuthenticationMethod:
appConfig.smtp_settings.authentication_method || "", appConfig.smtp_settings?.authentication_method || "",
}); });
const { const {
@ -116,9 +116,9 @@ const Smtp = ({
password: smtpPassword, password: smtpPassword,
enable_ssl_tls: smtpEnableSSLTLS, enable_ssl_tls: smtpEnableSSLTLS,
authentication_method: smtpAuthenticationMethod, authentication_method: smtpAuthenticationMethod,
domain: appConfig.smtp_settings.domain || "", domain: appConfig.smtp_settings?.domain || "",
verify_ssl_certs: appConfig.smtp_settings.verify_ssl_certs || false, verify_ssl_certs: appConfig.smtp_settings?.verify_ssl_certs || false,
enable_start_tls: appConfig.smtp_settings.enable_start_tls, enable_start_tls: appConfig.smtp_settings?.enable_start_tls,
}, },
}; };
@ -282,13 +282,13 @@ const Smtp = ({
!sesConfigured ? ( !sesConfigured ? (
<small <small
className={`smtp-options smtp-options--${ className={`smtp-options smtp-options--${
appConfig.smtp_settings.configured appConfig.smtp_settings?.configured
? "configured" ? "configured"
: "notconfigured" : "notconfigured"
}`} }`}
> >
<em> <em>
{appConfig.smtp_settings.configured {appConfig.smtp_settings?.configured
? "CONFIGURED" ? "CONFIGURED"
: "NOT CONFIGURED"} : "NOT CONFIGURED"}
</em> </em>

View File

@ -217,9 +217,12 @@ const UsersPage = ({ location, router }: ITeamSubnavProps): JSX.Element => {
inviteAPI inviteAPI
.create(requestData) .create(requestData)
.then(() => { .then(() => {
const senderAddressMessage = config?.smtp_settings?.sender_address
? ` from ${config?.smtp_settings?.sender_address}`
: "";
renderFlash( renderFlash(
"success", "success",
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.` `An invitation email was sent${senderAddressMessage} to ${formData.email}.`
); );
refetchUsers(); refetchUsers();
toggleCreateUserModal(); toggleCreateUserModal();

View File

@ -225,9 +225,12 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
invitesAPI invitesAPI
.create(requestData) .create(requestData)
.then(() => { .then(() => {
const senderAddressMessage = config?.smtp_settings?.sender_address
? ` from ${config?.smtp_settings?.sender_address}`
: "";
renderFlash( renderFlash(
"success", "success",
`An invitation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}.` `An invitation email was sent${senderAddressMessage} to ${formData.email}.`
); );
toggleCreateUserModal(); toggleCreateUserModal();
refetchInvites(); refetchInvites();
@ -302,7 +305,10 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
let userUpdatedFlashMessage = `Successfully edited ${formData.name}`; let userUpdatedFlashMessage = `Successfully edited ${formData.name}`;
if (userData?.email !== formData.email) { if (userData?.email !== formData.email) {
userUpdatedFlashMessage += `: A confirmation email was sent from ${config?.smtp_settings.sender_address} to ${formData.email}`; const senderAddressMessage = config?.smtp_settings?.sender_address
? ` from ${config?.smtp_settings?.sender_address}`
: "";
userUpdatedFlashMessage += `: A confirmation email was sent${senderAddressMessage} to ${formData.email}`;
} }
const userUpdatedEmailError = const userUpdatedEmailError =
"A user with this email address already exists"; "A user with this email address already exists";
@ -463,7 +469,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
onSubmit={onEditUser} onSubmit={onEditUser}
availableTeams={teams || []} availableTeams={teams || []}
isPremiumTier={isPremiumTier || false} isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings.configured || false} smtpConfigured={config?.smtp_settings?.configured || false}
sesConfigured={config?.email?.backend === "ses" || false} sesConfigured={config?.email?.backend === "ses" || false}
canUseSso={config?.sso_settings.enable_sso || false} canUseSso={config?.sso_settings.enable_sso || false}
isSsoEnabled={userData?.sso_enabled} isSsoEnabled={userData?.sso_enabled}
@ -486,7 +492,7 @@ const UsersTable = ({ router }: IUsersTableProps): JSX.Element => {
defaultGlobalRole="observer" defaultGlobalRole="observer"
defaultTeams={[]} defaultTeams={[]}
isPremiumTier={isPremiumTier || false} isPremiumTier={isPremiumTier || false}
smtpConfigured={config?.smtp_settings.configured || false} smtpConfigured={config?.smtp_settings?.configured || false}
sesConfigured={config?.email?.backend === "ses" || false} sesConfigured={config?.email?.backend === "ses" || false}
canUseSso={config?.sso_settings.enable_sso || false} canUseSso={config?.sso_settings.enable_sso || false}
isUpdatingUsers={isUpdatingUsers} isUpdatingUsers={isUpdatingUsers}

View File

@ -12,7 +12,6 @@ export interface ISideNavItem<T> {
urlSection: string; urlSection: string;
path: string; path: string;
Card: (props: T) => JSX.Element; Card: (props: T) => JSX.Element;
exclude?: boolean;
} }
interface ISideNavProps { interface ISideNavProps {

View File

@ -1,104 +1,10 @@
.host-actions-dropdown { .host-actions-dropdown {
.form-field { @include button-dropdown;
margin: 0;
}
.Select {
position: relative;
border: 0;
height: auto;
&.is-focused,
&:hover {
border: 0;
}
&.is-focused:not(.is-open) {
.Select-control {
background-color: initial;
}
}
.Select-control {
display: flex;
background-color: initial;
height: auto;
justify-content: space-between;
border: 0;
cursor: pointer;
&:hover {
box-shadow: none;
}
&:hover .Select-placeholder {
color: $core-vibrant-blue;
}
.Select-placeholder {
color: $core-fleet-black; color: $core-fleet-black;
font-size: 14px;
line-height: normal;
padding-left: 0;
margin-top: 1px;
}
.Select-input {
height: auto;
}
.Select-arrow-zone {
display: flex;
}
}
.Select-multi-value-wrapper { .Select-multi-value-wrapper {
width: 55px; width: 55px;
} }
.Select > .Select-menu-outer {
.Select-placeholder {
display: flex;
align-items: center;
}
.Select-menu-outer {
margin-top: $pad-xsmall;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border-radius: $border-radius;
z-index: 6;
overflow: hidden;
border: 0;
width: 188px;
left: unset;
top: unset;
max-height: none;
padding: $pad-small;
position: absolute;
left: -120px; left: -120px;
.Select-menu {
max-height: none;
}
}
.Select-arrow {
transition: transform 0.25s ease;
}
&:not(.is-open) {
.Select-control:hover .Select-arrow {
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
}
}
&.is-open {
.Select-control .Select-placeholder {
color: $core-vibrant-blue;
}
.Select-arrow {
transform: rotate(180deg);
}
}
} }
} }

View File

@ -13,7 +13,6 @@ import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification"; import { NotificationContext } from "context/notification";
import activitiesAPI, { import activitiesAPI, {
IActivitiesResponse,
IPastActivitiesResponse, IPastActivitiesResponse,
IUpcomingActivitiesResponse, IUpcomingActivitiesResponse,
} from "services/entities/activities"; } from "services/entities/activities";

View File

@ -2,7 +2,9 @@ import React, { useCallback, useContext, useEffect, useState } from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { InjectedRouter } from "react-router/lib/Router"; import { InjectedRouter } from "react-router/lib/Router";
import PATHS from "router/paths"; import PATHS from "router/paths";
import { noop, isEqual } from "lodash"; import { noop, isEqual, uniqueId } from "lodash";
import { Tooltip as ReactTooltip5 } from "react-tooltip-5";
import { getNextLocationPath } from "utilities/helpers"; import { getNextLocationPath } from "utilities/helpers";
@ -12,7 +14,7 @@ import { TableContext } from "context/table";
import { NotificationContext } from "context/notification"; import { NotificationContext } from "context/notification";
import useTeamIdParam from "hooks/useTeamIdParam"; import useTeamIdParam from "hooks/useTeamIdParam";
import { IConfig, IWebhookSettings } from "interfaces/config"; import { IConfig, IWebhookSettings } from "interfaces/config";
import { IIntegrations } from "interfaces/integration"; import { IZendeskJiraIntegrations } from "interfaces/integration";
import { import {
IPolicyStats, IPolicyStats,
ILoadAllPoliciesResponse, ILoadAllPoliciesResponse,
@ -34,6 +36,8 @@ import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { ITableQueryData } from "components/TableContainer/TableContainer"; import { ITableQueryData } from "components/TableContainer/TableContainer";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import RevealButton from "components/buttons/RevealButton"; import RevealButton from "components/buttons/RevealButton";
import Spinner from "components/Spinner"; import Spinner from "components/Spinner";
import TeamsDropdown from "components/TeamsDropdown"; import TeamsDropdown from "components/TeamsDropdown";
@ -41,9 +45,11 @@ import TableDataError from "components/DataError";
import MainContent from "components/MainContent"; import MainContent from "components/MainContent";
import PoliciesTable from "./components/PoliciesTable"; import PoliciesTable from "./components/PoliciesTable";
import ManagePolicyAutomationsModal from "./components/ManagePolicyAutomationsModal"; import OtherWorkflowsModal from "./components/OtherWorkflowsModal";
import AddPolicyModal from "./components/AddPolicyModal"; import AddPolicyModal from "./components/AddPolicyModal";
import DeletePolicyModal from "./components/DeletePolicyModal"; import DeletePolicyModal from "./components/DeletePolicyModal";
import CalendarEventsModal from "./components/CalendarEventsModal";
import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal";
interface IManagePoliciesPageProps { interface IManagePoliciesPageProps {
router: InjectedRouter; router: InjectedRouter;
@ -125,13 +131,15 @@ const ManagePolicyPage = ({
const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false); const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false);
const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false); const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false);
const [
updatingPolicyEnabledCalendarEvents,
setUpdatingPolicyEnabledCalendarEvents,
] = useState(false);
const [selectedPolicyIds, setSelectedPolicyIds] = useState<number[]>([]); const [selectedPolicyIds, setSelectedPolicyIds] = useState<number[]>([]);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState( const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false);
false
);
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
const [showAddPolicyModal, setShowAddPolicyModal] = useState(false); const [showAddPolicyModal, setShowAddPolicyModal] = useState(false);
const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false); const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false);
const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false);
const [teamPolicies, setTeamPolicies] = useState<IPolicyStats[]>(); const [teamPolicies, setTeamPolicies] = useState<IPolicyStats[]>();
const [inheritedPolicies, setInheritedPolicies] = useState<IPolicyStats[]>(); const [inheritedPolicies, setInheritedPolicies] = useState<IPolicyStats[]>();
@ -474,18 +482,30 @@ const ManagePolicyPage = ({
] // Other dependencies can cause infinite re-renders as URL is source of truth ] // Other dependencies can cause infinite re-renders as URL is source of truth
); );
const toggleManageAutomationsModal = () => const toggleOtherWorkflowsModal = () =>
setShowManageAutomationsModal(!showManageAutomationsModal); setShowOtherWorkflowsModal(!showOtherWorkflowsModal);
const togglePreviewPayloadModal = useCallback(() => {
setShowPreviewPayloadModal(!showPreviewPayloadModal);
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
const toggleAddPolicyModal = () => setShowAddPolicyModal(!showAddPolicyModal); const toggleAddPolicyModal = () => setShowAddPolicyModal(!showAddPolicyModal);
const toggleDeletePolicyModal = () => const toggleDeletePolicyModal = () =>
setShowDeletePolicyModal(!showDeletePolicyModal); setShowDeletePolicyModal(!showDeletePolicyModal);
const toggleCalendarEventsModal = () => {
setShowCalendarEventsModal(!showCalendarEventsModal);
};
const onSelectAutomationOption = (option: string) => {
switch (option) {
case "calendar_events":
toggleCalendarEventsModal();
break;
case "other_workflows":
toggleOtherWorkflowsModal();
break;
default:
}
};
const toggleShowInheritedPolicies = () => { const toggleShowInheritedPolicies = () => {
// URL source of truth // URL source of truth
const locationPath = getNextLocationPath({ const locationPath = getNextLocationPath({
@ -499,9 +519,9 @@ const ManagePolicyPage = ({
router?.replace(locationPath); router?.replace(locationPath);
}; };
const handleUpdateAutomations = async (requestBody: { const handleUpdateOtherWorkflows = async (requestBody: {
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">; webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
integrations: IIntegrations; integrations: IZendeskJiraIntegrations;
}) => { }) => {
setIsUpdatingAutomations(true); setIsUpdatingAutomations(true);
try { try {
@ -515,13 +535,79 @@ const ManagePolicyPage = ({
"Could not update policy automations. Please try again." "Could not update policy automations. Please try again."
); );
} finally { } finally {
toggleManageAutomationsModal(); toggleOtherWorkflowsModal();
setIsUpdatingAutomations(false); setIsUpdatingAutomations(false);
refetchConfig(); refetchConfig();
isAnyTeamSelected && refetchTeamConfig(); isAnyTeamSelected && refetchTeamConfig();
} }
}; };
const updatePolicyEnabledCalendarEvents = async (
formData: ICalendarEventsFormData
) => {
setUpdatingPolicyEnabledCalendarEvents(true);
try {
// update team config if either field has been changed
const responses: Promise<any>[] = [];
if (
formData.enabled !==
teamConfig?.integrations.google_calendar?.enable_calendar_events ||
formData.url !== teamConfig?.integrations.google_calendar?.webhook_url
) {
responses.push(
teamsAPI.update(
{
integrations: {
google_calendar: {
enable_calendar_events: formData.enabled,
webhook_url: formData.url,
},
// These fields will never actually be changed here. See comment above
// IGlobalIntegrations definition.
zendesk: teamConfig?.integrations.zendesk || [],
jira: teamConfig?.integrations.jira || [],
},
},
teamIdForApi
)
);
}
// update changed policies calendar events enabled
const changedPolicies = formData.policies.filter((formPolicy) => {
const prevPolicyState = teamPolicies?.find(
(policy) => policy.id === formPolicy.id
);
return (
formPolicy.isChecked !== prevPolicyState?.calendar_events_enabled
);
});
responses.concat(
changedPolicies.map((changedPolicy) => {
return teamPoliciesAPI.update(changedPolicy.id, {
calendar_events_enabled: changedPolicy.isChecked,
team_id: teamIdForApi,
});
})
);
await Promise.all(responses);
renderFlash("success", "Successfully updated policy automations.");
} catch {
renderFlash(
"error",
"Could not update policy automations. Please try again."
);
} finally {
toggleCalendarEventsModal();
setUpdatingPolicyEnabledCalendarEvents(false);
refetchTeamPolicies();
refetchTeamConfig();
}
};
const onAddPolicyClick = () => { const onAddPolicyClick = () => {
setLastEditedQueryName(""); setLastEditedQueryName("");
setLastEditedQueryDescription(""); setLastEditedQueryDescription("");
@ -687,6 +773,70 @@ const ManagePolicyPage = ({
); );
}; };
const getAutomationsDropdownOptions = () => {
const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1;
let calEventsLabel: React.ReactNode = "Calendar events";
if (!isPremiumTier) {
const tipId = uniqueId();
calEventsLabel = (
<span>
<div className="label-text" data-tooltip-id={tipId}>
Calendar events
</div>
<ReactTooltip5
id={tipId}
place="left"
positionStrategy="fixed"
offset={24}
disableStyleInjection
>
Available in Fleet Premium
</ReactTooltip5>
</span>
);
} else if (isAllTeams) {
const tipId = uniqueId();
calEventsLabel = (
<span>
<div className="label-text" data-tooltip-id={tipId}>
Calendar events
</div>
<ReactTooltip5
id={tipId}
place="left"
positionStrategy="fixed"
offset={24}
disableStyleInjection
>
Select a team to manage
<br />
calendar events.
</ReactTooltip5>
</span>
);
}
return [
{
label: calEventsLabel,
value: "calendar_events",
disabled: !isPremiumTier || isAllTeams,
helpText: "Automatically reserve time to resolve failing policies.",
},
{
label: "Other workflows",
value: "other_workflows",
disabled: false,
helpText: "Create tickets or fire webhooks for failing policies.",
},
];
};
const isCalEventsConfigured =
(config?.integrations.google_calendar &&
config?.integrations.google_calendar.length > 0) ??
false;
return ( return (
<MainContent className={baseClass}> <MainContent className={baseClass}>
<div className={`${baseClass}__wrapper`}> <div className={`${baseClass}__wrapper`}>
@ -714,18 +864,15 @@ const ManagePolicyPage = ({
{showCtaButtons && ( {showCtaButtons && (
<div className={`${baseClass} button-wrap`}> <div className={`${baseClass} button-wrap`}>
{canManageAutomations && automationsConfig && ( {canManageAutomations && automationsConfig && (
<Button <div className={`${baseClass}__manage-automations-wrapper`}>
onClick={toggleManageAutomationsModal} <Dropdown
className={`${baseClass}__manage-automations button`} className={`${baseClass}__manage-automations-dropdown`}
variant="inverse" onChange={onSelectAutomationOption}
disabled={ placeholder="Manage automations"
isAnyTeamSelected searchable={false}
? isFetchingTeamPolicies options={getAutomationsDropdownOptions()}
: isFetchingGlobalPolicies />
} </div>
>
<span>Manage automations</span>
</Button>
)} )}
{canAddOrDeletePolicy && ( {canAddOrDeletePolicy && (
<div className={`${baseClass}__action-button-container`}> <div className={`${baseClass}__action-button-container`}>
@ -795,16 +942,14 @@ const ManagePolicyPage = ({
)} )}
</div> </div>
)} )}
{config && automationsConfig && showManageAutomationsModal && ( {config && automationsConfig && showOtherWorkflowsModal && (
<ManagePolicyAutomationsModal <OtherWorkflowsModal
automationsConfig={automationsConfig} automationsConfig={automationsConfig}
availableIntegrations={config.integrations} availableIntegrations={config.integrations}
availablePolicies={availablePoliciesForAutomation} availablePolicies={availablePoliciesForAutomation}
isUpdatingAutomations={isUpdatingAutomations} isUpdatingAutomations={isUpdatingAutomations}
showPreviewPayloadModal={showPreviewPayloadModal} onExit={toggleOtherWorkflowsModal}
onExit={toggleManageAutomationsModal} handleSubmit={handleUpdateOtherWorkflows}
handleSubmit={handleUpdateAutomations}
togglePreviewPayloadModal={togglePreviewPayloadModal}
/> />
)} )}
{showAddPolicyModal && ( {showAddPolicyModal && (
@ -822,6 +967,22 @@ const ManagePolicyPage = ({
onSubmit={onDeletePolicySubmit} onSubmit={onDeletePolicySubmit}
/> />
)} )}
{showCalendarEventsModal && (
<CalendarEventsModal
onExit={toggleCalendarEventsModal}
updatePolicyEnabledCalendarEvents={
updatePolicyEnabledCalendarEvents
}
configured={isCalEventsConfigured}
enabled={
teamConfig?.integrations.google_calendar
?.enable_calendar_events ?? false
}
url={teamConfig?.integrations.google_calendar?.webhook_url || ""}
policies={teamPolicies || []}
isUpdating={updatingPolicyEnabledCalendarEvents}
/>
)}
</div> </div>
</MainContent> </MainContent>
); );

View File

@ -8,13 +8,57 @@
.button-wrap { .button-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
min-width: 266px; align-items: center;
gap: 8px;
} }
} }
&__manage-automations { &__manage-automations-wrapper {
padding: $pad-small; @include button-dropdown;
margin-right: $pad-small; .Select-multi-value-wrapper {
width: 146px;
}
.Select > .Select-menu-outer {
left: -186px;
width: 360px;
.dropdown__help-text {
color: $ui-fleet-black-50;
}
.is-disabled * {
color: $ui-fleet-black-25;
.label-text {
font-style: normal;
// increase height to allow for broader tooltip activation area
position: absolute;
height: 34px;
width: 100%;
}
.dropdown__help-text {
// compensate for absolute label-text height
margin-top: 20px;
}
.react-tooltip {
@include tooltip-text;
font-style: normal;
text-align: center;
}
}
}
.Select-control {
margin-top: 0;
gap: 6px;
.Select-placeholder {
color: $core-vibrant-blue;
font-weight: $bold;
}
.dropdown__custom-arrow .dropdown__icon {
svg {
path {
stroke: $core-vibrant-blue-over;
}
}
}
}
} }
&__header { &__header {

View File

@ -0,0 +1,314 @@
import React, { useCallback, useState } from "react";
import { IPolicy } from "interfaces/policy";
import validURL from "components/forms/validators/valid_url";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import CustomLink from "components/CustomLink";
import Slider from "components/forms/fields/Slider";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import Graphic from "components/Graphic";
import Modal from "components/Modal";
import Checkbox from "components/forms/fields/Checkbox";
import { syntaxHighlight } from "utilities/helpers";
const baseClass = "calendar-events-modal";
interface IFormPolicy {
name: string;
id: number;
isChecked: boolean;
}
export interface ICalendarEventsFormData {
enabled: boolean;
url: string;
policies: IFormPolicy[];
}
interface ICalendarEventsModal {
onExit: () => void;
updatePolicyEnabledCalendarEvents: (
formData: ICalendarEventsFormData
) => void;
isUpdating: boolean;
configured: boolean;
enabled: boolean;
url: string;
policies: IPolicy[];
}
// allows any policy name to be the name of a form field, one of the checkboxes
type FormNames = string;
const CalendarEventsModal = ({
onExit,
updatePolicyEnabledCalendarEvents,
isUpdating,
configured,
enabled,
url,
policies,
}: ICalendarEventsModal) => {
const [formData, setFormData] = useState<ICalendarEventsFormData>({
enabled,
url,
policies: policies.map((policy) => ({
name: policy.name,
id: policy.id,
isChecked: policy.calendar_events_enabled || false,
})),
});
const [formErrors, setFormErrors] = useState<Record<string, string | null>>(
{}
);
const [showPreviewCalendarEvent, setShowPreviewCalendarEvent] = useState(
false
);
const [showExamplePayload, setShowExamplePayload] = useState(false);
const validateCalendarEventsFormData = (
curFormData: ICalendarEventsFormData
) => {
const errors: Record<string, string> = {};
if (curFormData.enabled) {
const { url: curUrl } = curFormData;
if (!validURL({ url: curUrl })) {
const errorPrefix = curUrl ? `${curUrl} is not` : "Please enter";
errors.url = `${errorPrefix} a valid resolution webhook URL`;
}
}
return errors;
};
// two onChange handlers to handle different levels of nesting in the form data
const onFeatureEnabledOrUrlChange = useCallback(
(newVal: { name: "enabled" | "url"; value: string | boolean }) => {
const { name, value } = newVal;
const newFormData = { ...formData, [name]: value };
setFormData(newFormData);
setFormErrors(validateCalendarEventsFormData(newFormData));
},
[formData]
);
const onPolicyEnabledChange = useCallback(
(newVal: { name: FormNames; value: boolean }) => {
const { name, value } = newVal;
const newFormPolicies = formData.policies.map((formPolicy) => {
if (formPolicy.name === name) {
return { ...formPolicy, isChecked: value };
}
return formPolicy;
});
const newFormData = { ...formData, policies: newFormPolicies };
setFormData(newFormData);
setFormErrors(validateCalendarEventsFormData(newFormData));
},
[formData]
);
const togglePreviewCalendarEvent = () => {
setShowPreviewCalendarEvent(!showPreviewCalendarEvent);
};
const renderExamplePayload = () => {
return (
<>
<pre>POST https://server.com/example</pre>
<pre
dangerouslySetInnerHTML={{
__html: syntaxHighlight({
timestamp: "0000-00-00T00:00:00Z",
host_id: 1,
host_display_name: "Anna's MacBook Pro",
host_serial_number: "ABCD1234567890",
failing_policies: [
{
id: 123,
name: "macOS - Disable guest account",
},
],
}),
}}
/>
</>
);
};
const renderPolicies = () => {
return (
<div className="form-field">
<div className="form-field__label">Policies:</div>
{formData.policies.map((policy) => {
const { isChecked, name, id } = policy;
return (
<div key={id}>
<Checkbox
value={isChecked}
name={name}
// can't use parseTarget as value needs to be set to !currentValue
onChange={() => {
onPolicyEnabledChange({ name, value: !isChecked });
}}
>
{name}
</Checkbox>
</div>
);
})}
<span className="form-field__help-text">
A calendar event will be created for end users if one of their hosts
fail any of these policies.{" "}
<CustomLink
url="https://www.fleetdm.com/learn-more-about/calendar-events"
text="Learn more"
newTab
/>
</span>
</div>
);
};
const renderPreviewCalendarEventModal = () => {
return (
<Modal
title="Calendar event preview"
width="large"
onExit={togglePreviewCalendarEvent}
className="calendar-event-preview"
>
<>
<p>A similar event will appear in the end user&apos;s calendar:</p>
<Graphic name="calendar-event-preview" />
<div className="modal-cta-wrap">
<Button onClick={togglePreviewCalendarEvent} variant="brand">
Done
</Button>
</div>
</>
</Modal>
);
};
const renderPlaceholderModal = () => {
return (
<div className="placeholder">
<a href="https://www.fleetdm.com/learn-more-about/calendar-events">
<Graphic name="calendar-event-preview" />
</a>
<div>
To create calendar events for end users if their hosts fail policies,
you must first connect Fleet to your Google Workspace service account.
</div>
<div>
This can be configured in{" "}
<b>Settings &gt; Integrations &gt; Calendars.</b>
</div>
<CustomLink
url="https://www.fleetdm.com/learn-more-about/calendar-events"
text="Learn more"
newTab
/>
<div className="modal-cta-wrap">
<Button onClick={onExit} variant="brand">
Done
</Button>
</div>
</div>
);
};
const renderConfiguredModal = () => (
<div className={`${baseClass} form`}>
<div className="form-header">
<Slider
value={formData.enabled}
onChange={() => {
onFeatureEnabledOrUrlChange({
name: "enabled",
value: !formData.enabled,
});
}}
inactiveText="Disabled"
activeText="Enabled"
/>
<Button
type="button"
variant="text-link"
onClick={togglePreviewCalendarEvent}
>
Preview calendar event
</Button>
</div>
<div
className={`form ${formData.enabled ? "" : "form-fields--disabled"}`}
>
<InputField
placeholder="https://server.com/example"
label="Resolution webhook URL"
onChange={onFeatureEnabledOrUrlChange}
name="url"
value={formData.url}
parseTarget
error={formErrors.url}
tooltip="Provide a URL to deliver a webhook request to."
labelTooltipPosition="top-start"
helpText="A request will be sent to this URL during the calendar event. Use it to trigger auto-remidiation."
/>
<RevealButton
isShowing={showExamplePayload}
className={`${baseClass}__show-example-payload-toggle`}
hideText="Hide example payload"
showText="Show example payload"
caretPosition="after"
onClick={() => {
setShowExamplePayload(!showExamplePayload);
}}
/>
{showExamplePayload && renderExamplePayload()}
{renderPolicies()}
</div>
<div className="modal-cta-wrap">
<Button
type="submit"
variant="brand"
onClick={() => {
updatePolicyEnabledCalendarEvents(formData);
}}
className="save-loading"
isLoading={isUpdating}
disabled={Object.keys(formErrors).length > 0}
>
Save
</Button>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
</div>
</div>
);
if (showPreviewCalendarEvent) {
return renderPreviewCalendarEventModal();
}
return (
<Modal
title="Calendar events"
onExit={onExit}
onEnter={
configured
? () => {
updatePolicyEnabledCalendarEvents(formData);
}
: onExit
}
className={baseClass}
width="large"
>
{configured ? renderConfiguredModal() : renderPlaceholderModal()}
</Modal>
);
};
export default CalendarEventsModal;

View File

@ -0,0 +1,35 @@
.calendar-events-modal {
.placeholder {
display: flex;
flex-direction: column;
gap: 24px;
line-height: 150%;
.modal-cta-wrap {
margin-top: 0;
}
}
.form-header {
display: flex;
justify-content: space-between;
.button--text-link {
white-space: nowrap;
}
}
.form-fields {
&--disabled {
@include disabled;
}
}
pre {
box-sizing: border-box;
margin: 0;
}
}
.calendar-event-preview {
p {
margin: 24px 0;
}
}

View File

@ -0,0 +1 @@
export { default } from "./CalendarEventsModal";

View File

@ -0,0 +1,64 @@
import React, { useContext } from "react";
import { syntaxHighlight } from "utilities/helpers";
import { AppContext } from "context/app";
import { IPolicyWebhookPreviewPayload } from "interfaces/policy";
const baseClass = "example-payload";
interface IHostPreview {
id: number;
display_name: string;
url: string;
}
interface IExamplePayload {
timestamp: string;
policy: IPolicyWebhookPreviewPayload;
hosts: IHostPreview[];
}
const ExamplePayload = (): JSX.Element => {
const { isFreeTier } = useContext(AppContext);
const json: IExamplePayload = {
timestamp: "0000-00-00T00:00:00Z",
policy: {
id: 1,
name: "Is Gatekeeper enabled?",
query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;",
description: "Checks if gatekeeper is enabled on macOS devices.",
author_id: 1,
author_name: "John",
author_email: "john@example.com",
resolution: "Turn on Gatekeeper feature in System Preferences.",
passing_host_count: 2000,
failing_host_count: 300,
critical: false,
},
hosts: [
{
id: 1,
display_name: "macbook-1",
url: "https://fleet.example.com/hosts/1",
},
{
id: 2,
display_name: "macbbook-2",
url: "https://fleet.example.com/hosts/2",
},
],
};
if (isFreeTier) {
delete json.policy.critical;
}
return (
<div className={baseClass}>
<pre>POST https://server.com/example</pre>
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
</div>
);
};
export default ExamplePayload;

View File

@ -0,0 +1,9 @@
.example-payload {
display: flex;
flex-direction: column;
gap: $pad-large;
pre {
margin: 0;
}
}

View File

@ -0,0 +1 @@
export { default } from "./ExamplePayload";

View File

@ -1,28 +1,24 @@
import React, { useContext } from "react"; import React, { useContext } from "react";
import { AppContext } from "context/app"; import { AppContext } from "context/app";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import { IIntegrationType } from "interfaces/integration"; import { IIntegrationType } from "interfaces/integration";
import Card from "components/Card";
import JiraPreview from "../../../../../../assets/images/jira-policy-automation-preview-400x419@2x.png"; import JiraPreview from "../../../../../../assets/images/jira-policy-automation-preview-400x419@2x.png";
import ZendeskPreview from "../../../../../../assets/images/zendesk-policy-automation-preview-400x515@2x.png"; import ZendeskPreview from "../../../../../../assets/images/zendesk-policy-automation-preview-400x515@2x.png";
import JiraPreviewPremium from "../../../../../../assets/images/jira-policy-automation-preview-premium-400x316@2x.png"; import JiraPreviewPremium from "../../../../../../assets/images/jira-policy-automation-preview-premium-400x316@2x.png";
import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-policy-automation-preview-premium-400x483@2x.png"; import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-policy-automation-preview-premium-400x483@2x.png";
const baseClass = "preview-ticket-modal"; const baseClass = "example-ticket";
interface IPreviewTicketModalProps { interface IExampleTicketProps {
integrationType?: IIntegrationType; integrationType?: IIntegrationType;
onCancel: () => void;
} }
const PreviewTicketModal = ({ const ExampleTicket = ({
integrationType, integrationType,
onCancel, }: IExampleTicketProps): JSX.Element => {
}: IPreviewTicketModalProps): JSX.Element => {
const { isPremiumTier } = useContext(AppContext); const { isPremiumTier } = useContext(AppContext);
const screenshot = const screenshot =
@ -41,30 +37,10 @@ const PreviewTicketModal = ({
); );
return ( return (
<Modal <Card className={baseClass} color="gray">
title="Example ticket" {screenshot}
onExit={onCancel} </Card>
className={baseClass}
width="large"
>
<div className={`${baseClass}`}>
<p className="automations-learn-more">
Want to learn more about how automations in Fleet work?{" "}
<CustomLink
url="https://fleetdm.com/docs/using-fleet/automations"
text=" Check out the Fleet documentation"
newTab
/>
</p>
<div className={`${baseClass}__example`}>{screenshot}</div>
<div className="modal-cta-wrap">
<Button onClick={onCancel} variant="brand">
Done
</Button>
</div>
</div>
</Modal>
); );
}; };
export default PreviewTicketModal; export default ExampleTicket;

View File

@ -0,0 +1,10 @@
.example-ticket {
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
&__screenshot {
max-width: 400px;
}
}

View File

@ -0,0 +1 @@
export { default } from "./ExampleTicket";

View File

@ -1 +0,0 @@
export { default } from "./ManagePolicyAutomationsModal";

View File

@ -3,7 +3,12 @@ import { Link } from "react-router";
import { isEmpty, noop, omit } from "lodash"; import { isEmpty, noop, omit } from "lodash";
import { IAutomationsConfig, IWebhookSettings } from "interfaces/config"; import { IAutomationsConfig, IWebhookSettings } from "interfaces/config";
import { IIntegration, IIntegrations } from "interfaces/integration"; import {
IGlobalIntegrations,
IIntegration,
IZendeskJiraIntegrations,
ITeamIntegrations,
} from "interfaces/integration";
import { IPolicy } from "interfaces/policy"; import { IPolicy } from "interfaces/policy";
import { ITeamAutomationsConfig } from "interfaces/team"; import { ITeamAutomationsConfig } from "interfaces/team";
import PATHS from "router/paths"; import PATHS from "router/paths";
@ -19,22 +24,21 @@ import Dropdown from "components/forms/fields/Dropdown";
import InputField from "components/forms/fields/InputField"; import InputField from "components/forms/fields/InputField";
import Radio from "components/forms/fields/Radio"; import Radio from "components/forms/fields/Radio";
import validUrl from "components/forms/validators/valid_url"; import validUrl from "components/forms/validators/valid_url";
import RevealButton from "components/buttons/RevealButton";
import CustomLink from "components/CustomLink";
import ExampleTicket from "../ExampleTicket";
import ExamplePayload from "../ExamplePayload";
import PreviewPayloadModal from "../PreviewPayloadModal"; interface IOtherWorkflowsModalProps {
import PreviewTicketModal from "../PreviewTicketModal";
interface IManagePolicyAutomationsModalProps {
automationsConfig: IAutomationsConfig | ITeamAutomationsConfig; automationsConfig: IAutomationsConfig | ITeamAutomationsConfig;
availableIntegrations: IIntegrations; availableIntegrations: IGlobalIntegrations | ITeamIntegrations;
availablePolicies: IPolicy[]; availablePolicies: IPolicy[];
isUpdatingAutomations: boolean; isUpdatingAutomations: boolean;
showPreviewPayloadModal: boolean;
onExit: () => void; onExit: () => void;
handleSubmit: (formData: { handleSubmit: (formData: {
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">; webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
integrations: IIntegrations; integrations: IGlobalIntegrations | ITeamIntegrations;
}) => void; }) => void;
togglePreviewPayloadModal: () => void;
} }
interface ICheckedPolicy { interface ICheckedPolicy {
@ -43,7 +47,10 @@ interface ICheckedPolicy {
isChecked: boolean; isChecked: boolean;
} }
const findEnabledIntegration = ({ jira, zendesk }: IIntegrations) => { const findEnabledIntegration = ({
jira,
zendesk,
}: IZendeskJiraIntegrations) => {
return ( return (
jira?.find((j) => j.enable_failing_policies) || jira?.find((j) => j.enable_failing_policies) ||
zendesk?.find((z) => z.enable_failing_policies) zendesk?.find((z) => z.enable_failing_policies)
@ -83,18 +90,16 @@ const useCheckboxListStateManagement = (
return { policyItems, updatePolicyItems }; return { policyItems, updatePolicyItems };
}; };
const baseClass = "manage-policy-automations-modal"; const baseClass = "other-workflows-modal";
const ManagePolicyAutomationsModal = ({ const OtherWorkflowsModal = ({
automationsConfig, automationsConfig,
availableIntegrations, availableIntegrations,
availablePolicies, availablePolicies,
isUpdatingAutomations, isUpdatingAutomations,
showPreviewPayloadModal,
onExit, onExit,
handleSubmit, handleSubmit,
togglePreviewPayloadModal: togglePreviewModal, }: IOtherWorkflowsModalProps): JSX.Element => {
}: IManagePolicyAutomationsModalProps): JSX.Element => {
const { const {
webhook_settings: { failing_policies_webhook: webhook }, webhook_settings: { failing_policies_webhook: webhook },
} = automationsConfig; } = automationsConfig;
@ -131,6 +136,9 @@ const ManagePolicyAutomationsModal = ({
IIntegration | undefined IIntegration | undefined
>(serverEnabledIntegration); >(serverEnabledIntegration);
const [showExamplePayload, setShowExamplePayload] = useState(false);
const [showExampleTicket, setShowExampleTicket] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [errors, setErrors] = useState<{ [key: string]: string }>({});
const { policyItems, updatePolicyItems } = useCheckboxListStateManagement( const { policyItems, updatePolicyItems } = useCheckboxListStateManagement(
@ -218,13 +226,6 @@ const ManagePolicyAutomationsModal = ({
z.group_id === selectedIntegration?.group_id, z.group_id === selectedIntegration?.group_id,
})) || null; })) || null;
// if (
// !isPolicyAutomationsEnabled ||
// (!isWebhookEnabled && !selectedIntegration)
// ) {
// newPolicyIds = [];
// }
const updatedEnabledPoliciesAcrossPages = () => { const updatedEnabledPoliciesAcrossPages = () => {
if (webhook.policy_ids) { if (webhook.policy_ids) {
// Array of policy ids on the page // Array of policy ids on the page
@ -263,6 +264,7 @@ const ManagePolicyAutomationsModal = ({
integrations: { integrations: {
jira: newJira, jira: newJira,
zendesk: newZendesk, zendesk: newZendesk,
google_calendar: null, // When null, the backend does not update google_calendar
}, },
}); });
@ -297,15 +299,22 @@ const ManagePolicyAutomationsModal = ({
placeholder="https://server.com/example" placeholder="https://server.com/example"
tooltip="Provide a URL to deliver a webhook request to." tooltip="Provide a URL to deliver a webhook request to."
/> />
<Button type="button" variant="text-link" onClick={togglePreviewModal}> <RevealButton
Preview payload isShowing={showExamplePayload}
</Button> className={baseClass}
hideText="Hide example payload"
showText="Show example payload"
caretPosition="after"
onClick={() => setShowExamplePayload(!showExamplePayload)}
/>
{showExamplePayload && <ExamplePayload />}
</> </>
); );
}; };
const renderIntegrations = () => { const renderIntegrations = () => {
return jira?.length || zendesk?.length ? ( return jira?.length || zendesk?.length ? (
<>
<div className={`${baseClass}__integrations`}> <div className={`${baseClass}__integrations`}>
<Dropdown <Dropdown
options={dropdownOptions} options={dropdownOptions}
@ -321,10 +330,21 @@ const ManagePolicyAutomationsModal = ({
"For each policy, Fleet will create a ticket with a list of the failing hosts." "For each policy, Fleet will create a ticket with a list of the failing hosts."
} }
/> />
<Button type="button" variant="text-link" onClick={togglePreviewModal}>
Preview ticket
</Button>
</div> </div>
<RevealButton
isShowing={showExampleTicket}
className={baseClass}
hideText={"Hide example ticket"}
showText={"Show example ticket"}
caretPosition="after"
onClick={() => setShowExampleTicket(!showExampleTicket)}
/>
{showExampleTicket && (
<ExampleTicket
integrationType={getIntegrationType(selectedIntegration)}
/>
)}
</>
) : ( ) : (
<div className={`form-field ${baseClass}__no-integrations`}> <div className={`form-field ${baseClass}__no-integrations`}>
<div className="form-field__label">You have no integrations.</div> <div className="form-field__label">You have no integrations.</div>
@ -338,22 +358,10 @@ const ManagePolicyAutomationsModal = ({
); );
}; };
const renderPreview = () => return (
!isWebhookEnabled ? (
<PreviewTicketModal
integrationType={getIntegrationType(selectedIntegration)}
onCancel={togglePreviewModal}
/>
) : (
<PreviewPayloadModal onCancel={togglePreviewModal} />
);
return showPreviewPayloadModal ? (
renderPreview()
) : (
<Modal <Modal
onExit={onExit} onExit={onExit}
title="Manage automations" title="Other workflows"
className={baseClass} className={baseClass}
width="large" width="large"
> >
@ -372,12 +380,32 @@ const ManagePolicyAutomationsModal = ({
isPolicyAutomationsEnabled ? "enabled" : "disabled" isPolicyAutomationsEnabled ? "enabled" : "disabled"
}`} }`}
> >
<div className={`form-field ${baseClass}__workflow`}>
<div className="form-field__label">Workflow</div>
<Radio
className={`${baseClass}__radio-input`}
label="Ticket"
id="ticket-radio-btn"
checked={!isWebhookEnabled}
value="ticket"
name="ticket"
onChange={onChangeRadio}
/>
<Radio
className={`${baseClass}__radio-input`}
label="Webhook"
id="webhook-radio-btn"
checked={isWebhookEnabled}
value="webhook"
name="webhook"
onChange={onChangeRadio}
/>
</div>
{isWebhookEnabled ? renderWebhook() : renderIntegrations()}
<div className="form-field"> <div className="form-field">
{availablePolicies?.length ? ( {availablePolicies?.length ? (
<> <>
<div className="form-field__label"> <div className="form-field__label">Policies:</div>
Choose which policies you would like to listen to:
</div>
{policyItems && {policyItems &&
policyItems.map((policyItem) => { policyItems.map((policyItem) => {
const { isChecked, name, id } = policyItem; const { isChecked, name, id } = policyItem;
@ -405,28 +433,14 @@ const ManagePolicyAutomationsModal = ({
</> </>
)} )}
</div> </div>
<div className={`form-field ${baseClass}__workflow`}> <p className={`${baseClass}__help-text`}>
<div className="form-field__label">Workflow</div> The workflow will be triggered when hosts fail these policies.{" "}
<Radio <CustomLink
className={`${baseClass}__radio-input`} url="https://www.fleetdm.com/learn-more-about/policy-automations"
label="Ticket" text="Learn more"
id="ticket-radio-btn" newTab
checked={!isWebhookEnabled}
value="ticket"
name="ticket"
onChange={onChangeRadio}
/> />
<Radio </p>
className={`${baseClass}__radio-input`}
label="Webhook"
id="webhook-radio-btn"
checked={isWebhookEnabled}
value="webhook"
name="webhook"
onChange={onChangeRadio}
/>
</div>
{isWebhookEnabled ? renderWebhook() : renderIntegrations()}
</div> </div>
<div className="modal-cta-wrap"> <div className="modal-cta-wrap">
<Button <Button
@ -447,4 +461,4 @@ const ManagePolicyAutomationsModal = ({
); );
}; };
export default ManagePolicyAutomationsModal; export default OtherWorkflowsModal;

View File

@ -1,12 +1,9 @@
.manage-policy-automations-modal { .other-workflows-modal {
pre, pre,
code { code {
background-color: $ui-off-white;
color: $core-fleet-blue;
border: 1px solid $ui-fleet-black-10; border: 1px solid $ui-fleet-black-10;
border-radius: $border-radius; border-radius: $border-radius;
padding: 7px $pad-medium; padding: 7px $pad-medium;
margin: $pad-large 0 0 44px;
} }
&__error { &__error {
@ -22,4 +19,8 @@
&__no-integrations a { &__no-integrations a {
display: block; display: block;
} }
&__help-text {
@include help-text;
}
} }

View File

@ -0,0 +1 @@
export { default } from "./OtherWorkflowsModal";

View File

@ -284,16 +284,6 @@ const generateTableHeaders = (
]; ];
if (tableType !== "inheritedPolicies") { if (tableType !== "inheritedPolicies") {
tableHeaders.push({
title: "Automations",
Header: "Automations",
disableSortBy: true,
accessor: "webhook",
Cell: (cellProps: ICellProps): JSX.Element => (
<StatusIndicator value={cellProps.cell.value} />
),
});
if (!canAddOrDeletePolicy) { if (!canAddOrDeletePolicy) {
return tableHeaders; return tableHeaders;
} }

View File

@ -1,97 +0,0 @@
import React, { useContext } from "react";
import { syntaxHighlight } from "utilities/helpers";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import { AppContext } from "context/app";
import { IPolicyWebhookPreviewPayload } from "interfaces/policy";
const baseClass = "preview-data-modal";
interface IPreviewPayloadModalProps {
onCancel: () => void;
}
interface IHostPreview {
id: number;
display_name: string;
url: string;
}
interface IPreviewPayload {
timestamp: string;
policy: IPolicyWebhookPreviewPayload;
hosts: IHostPreview[];
}
const PreviewPayloadModal = ({
onCancel,
}: IPreviewPayloadModalProps): JSX.Element => {
const { isFreeTier } = useContext(AppContext);
const json: IPreviewPayload = {
timestamp: "0000-00-00T00:00:00Z",
policy: {
id: 1,
name: "Is Gatekeeper enabled?",
query: "SELECT 1 FROM gatekeeper WHERE assessments_enabled = 1;",
description: "Checks if gatekeeper is enabled on macOS devices.",
author_id: 1,
author_name: "John",
author_email: "john@example.com",
resolution: "Turn on Gatekeeper feature in System Preferences.",
passing_host_count: 2000,
failing_host_count: 300,
critical: false,
},
hosts: [
{
id: 1,
display_name: "macbook-1",
url: "https://fleet.example.com/hosts/1",
},
{
id: 2,
display_name: "macbbook-2",
url: "https://fleet.example.com/hosts/2",
},
],
};
if (isFreeTier) {
delete json.policy.critical;
}
return (
<Modal
title="Example payload"
onExit={onCancel}
onEnter={onCancel}
className={baseClass}
>
<div className={`${baseClass}__preview-modal`}>
<p>
Want to learn more about how automations in Fleet work?{" "}
<CustomLink
url="https://fleetdm.com/docs/using-fleet/automations"
text="Check out the Fleet documentation"
newTab
/>
</p>
<div className={`${baseClass}__payload-request-preview`}>
<pre>POST https://server.com/example</pre>
</div>
<div className={`${baseClass}__payload-webhook-preview`}>
<pre dangerouslySetInnerHTML={{ __html: syntaxHighlight(json) }} />
</div>
<div className="modal-cta-wrap">
<Button onClick={onCancel} variant="brand">
Done
</Button>
</div>
</div>
</Modal>
);
};
export default PreviewPayloadModal;

View File

@ -1,54 +0,0 @@
.preview-payload-modal {
&__sandbox-info {
margin-top: $pad-medium;
p {
margin: 0;
margin-bottom: $pad-medium;
}
p:last-child {
margin-bottom: 0;
}
}
&__info-header {
font-weight: $bold;
}
&__advanced-options-button {
margin: $pad-medium 0;
color: $core-vibrant-blue;
font-weight: $bold;
font-size: $x-small;
}
.downcaret {
&::after {
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
transform: scale(0.5);
width: 16px;
border-radius: 0px;
padding: 0px;
padding-left: 2px;
margin-bottom: 2px;
}
}
.upcaret {
&::after {
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
transform: scale(0.5) rotate(180deg);
width: 16px;
border-radius: 0px;
padding: 0px;
padding-left: 2px;
margin-bottom: 4px;
margin-left: 14px;
}
}
.Select-value-label {
font-size: $small;
}
}

View File

@ -1 +0,0 @@
export { default } from "./PreviewPayloadModal";

View File

@ -1,13 +0,0 @@
.preview-ticket-modal {
&__example {
display: flex;
justify-content: center;
}
&__screenshot {
width: 400px;
height: auto;
border-radius: 8px;
filter: drop-shadow(0px 4px 16px rgba(0, 0, 0, 0.1));
}
}

View File

@ -1 +0,0 @@
export { default } from "./PreviewTicketModal";

View File

@ -33,6 +33,7 @@ export default {
ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`, ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`,
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`, ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`,
ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`,
ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`, ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`,
ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`, ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`,
ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`, ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`,

View File

@ -87,6 +87,7 @@ export default {
resolution, resolution,
platform, platform,
critical, critical,
calendar_events_enabled,
} = data; } = data;
const { TEAMS } = endpoints; const { TEAMS } = endpoints;
const path = `${TEAMS}/${team_id}/policies/${id}`; const path = `${TEAMS}/${team_id}/policies/${id}`;
@ -98,6 +99,7 @@ export default {
resolution, resolution,
platform, platform,
critical, critical,
calendar_events_enabled,
}); });
}, },
destroy: (teamId: number | undefined, ids: number[]) => { destroy: (teamId: number | undefined, ids: number[]) => {

View File

@ -5,7 +5,7 @@ import { pick } from "lodash";
import { buildQueryStringFromParams } from "utilities/url"; import { buildQueryStringFromParams } from "utilities/url";
import { IEnrollSecret } from "interfaces/enroll_secret"; import { IEnrollSecret } from "interfaces/enroll_secret";
import { IIntegrations } from "interfaces/integration"; import { ITeamIntegrations } from "interfaces/integration";
import { import {
API_NO_TEAM_ID, API_NO_TEAM_ID,
INewTeamUsersBody, INewTeamUsersBody,
@ -39,7 +39,7 @@ export interface ITeamFormData {
export interface IUpdateTeamFormData { export interface IUpdateTeamFormData {
name: string; name: string;
webhook_settings: Partial<ITeamWebhookSettings>; webhook_settings: Partial<ITeamWebhookSettings>;
integrations: IIntegrations; integrations: ITeamIntegrations;
mdm: { mdm: {
macos_updates?: { macos_updates?: {
minimum_version: string; minimum_version: string;
@ -118,7 +118,7 @@ export default {
requestBody.webhook_settings = webhook_settings; requestBody.webhook_settings = webhook_settings;
} }
if (integrations) { if (integrations) {
const { jira, zendesk } = integrations; const { jira, zendesk, google_calendar } = integrations;
const teamIntegrationProps = [ const teamIntegrationProps = [
"enable_failing_policies", "enable_failing_policies",
"group_id", "group_id",
@ -128,6 +128,7 @@ export default {
requestBody.integrations = { requestBody.integrations = {
jira: jira?.map((j) => pick(j, teamIntegrationProps)), jira: jira?.map((j) => pick(j, teamIntegrationProps)),
zendesk: zendesk?.map((z) => pick(z, teamIntegrationProps)), zendesk: zendesk?.map((z) => pick(z, teamIntegrationProps)),
google_calendar,
}; };
} }
if (mdm) { if (mdm) {

View File

@ -182,6 +182,15 @@ $max-width: 2560px;
} }
} }
@mixin copy-message {
font-weight: $regular;
vertical-align: top;
background-color: $ui-light-grey;
border: solid 1px #e2e4ea;
border-radius: 10px;
padding: 2px 6px;
}
@mixin color-contrasted-sections { @mixin color-contrasted-sections {
background-color: $ui-off-white; background-color: $ui-off-white;
.section { .section {
@ -227,3 +236,102 @@ $max-width: 2560px;
// compensate in layout for extra clickable area button height // compensate in layout for extra clickable area button height
margin: -8px 0; margin: -8px 0;
} }
@mixin button-dropdown {
.form-field {
margin: 0;
}
.Select {
position: relative;
border: 0;
height: auto;
&.is-focused,
&:hover {
border: 0;
}
&.is-focused:not(.is-open) {
.Select-control {
background-color: initial;
}
}
.Select-control {
display: flex;
background-color: initial;
height: auto;
justify-content: space-between;
border: 0;
cursor: pointer;
&:hover {
box-shadow: none;
}
&:hover .Select-placeholder {
color: $core-vibrant-blue;
}
.Select-placeholder {
font-size: 14px;
line-height: normal;
padding-left: 0;
margin-top: 1px;
}
.Select-input {
height: auto;
}
.Select-arrow-zone {
display: flex;
}
}
.Select-placeholder {
display: flex;
align-items: center;
}
.Select-menu-outer {
margin-top: $pad-xsmall;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
border-radius: $border-radius;
z-index: 6;
overflow: hidden;
border: 0;
width: 188px;
left: unset;
top: unset;
max-height: none;
padding: $pad-small;
position: absolute;
.Select-menu {
max-height: none;
}
}
.Select-arrow {
transition: transform 0.25s ease;
}
&:not(.is-open) {
.Select-control:hover .Select-arrow {
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
}
}
&.is-open {
.Select-control .Select-placeholder {
color: $core-vibrant-blue;
}
.Select-arrow {
transform: rotate(180deg);
}
}
}
}

View File

@ -283,7 +283,6 @@ You can confirm that the device has been ordered correctly by following these st
- Use the device serial number to find the device. - Use the device serial number to find the device.
- Note: if the device cannot be found, you will need to manually enroll the device. - Note: if the device cannot be found, you will need to manually enroll the device.
- View device settings and ensure the "MDM Server" selected is "Fleet Dogfood". - View device settings and ensure the "MDM Server" selected is "Fleet Dogfood".
<img width="143" alt="Screenshot 2023-11-21 at 11 08 50AM" src="https://github.com/fleetdm/confidential/assets/47070608/512dc629-76dd-4090-bf86-9c4582286d1d">
On occasion there will be a need to manually enroll a macOS host in dogfood. This could be due to a BYOD arrangement, or because the Fleetie getting the device is in a country when DEP (automatic enrollment) isn't supported. To manually enroll a macOS host in dogfood, follow these steps: On occasion there will be a need to manually enroll a macOS host in dogfood. This could be due to a BYOD arrangement, or because the Fleetie getting the device is in a country when DEP (automatic enrollment) isn't supported. To manually enroll a macOS host in dogfood, follow these steps:
- If you have physical access to the macOS host, use Apple Configurator (docs are [here](https://support.apple.com/guide/apple-business-manager/add-devices-from-apple-configurator-axm200a54d59/web)). - If you have physical access to the macOS host, use Apple Configurator (docs are [here](https://support.apple.com/guide/apple-business-manager/add-devices-from-apple-configurator-axm200a54d59/web)).

View File

@ -131,7 +131,7 @@ Besides the exceptions above, Fleet does not use any other repositories. Other
## Why not continuously generate REST API reference docs from javadoc-style code comments? ## Why not continuously generate REST API reference docs from javadoc-style code comments?
Here are a few of the drawbacks that we have experienced when generating docs via tools like Swagger or OpenAPI, and some of the advantages of doing it by hand with Markdown. Here are a few of the drawbacks that we have experienced when generating docs via tools like Swagger or OpenAPI, and some of the advantages of doing it by hand with Markdown.
- Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we present the information to the end-user. - Markdown gives us more control over how the docs are compiled, what annotations we can include, and how we [present the information to the end-user](https://x.com/wesleytodd/status/1769810305448616185?s=46&t=4_cwTxqV5IXDLBvCm8KI6Q).
- Markdown is more accessible. Anyone can edit Fleet's docs directly from our website without needing coding experience. - Markdown is more accessible. Anyone can edit Fleet's docs directly from our website without needing coding experience.
- A single Markdown file reduces the amount of surface area to manage that comes from spreading code comments across multiple files throughout the codebase. (see ["Why do we use one repo?"](#why-do-we-use-one-repo)). - A single Markdown file reduces the amount of surface area to manage that comes from spreading code comments across multiple files throughout the codebase. (see ["Why do we use one repo?"](#why-do-we-use-one-repo)).
- Autogenerated docs can become just as outdated as handmade docs, except since they are siloed, they require more skills to edit. - Autogenerated docs can become just as outdated as handmade docs, except since they are siloed, they require more skills to edit.
@ -247,7 +247,7 @@ For example, here is the [philosophy behind Fleet's bug report template](https:/
## Why don't we sell like everyone else? ## Why don't we sell like everyone else?
Many companies encourage salespeople to "spray and pray" email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach: Many companies encourage salespeople to ["spray and pray"](https://www.linkedin.com/posts/amstech_the-rampant-abuse-of-linkedin-connections-activity-7178412289413246978-Ci0I?utm_source=share&utm_medium=member_ios) email blasts, and to do whatever it takes to close deals. This can sometimes be temporarily effective. But Fleet takes a [🟠longer-term](https://fleetdm.com/handbook/company#ownership) approach:
- **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md). - **No spam.** Fleet is deliberate and thoughtful in the way we do outreach, whether that's for community-building, education, or [🧊 conversation-starting](https://github.com/fleetdm/confidential/blob/main/cold-outbound-strategy.md).
- **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win. - **Be a helper.** We focus on [🔴being helpers](https://fleetdm.com/handbook/company#empathy). Always be depositing value. This is how we create a virtuous cycle. (That doesn't mean sharing a random article; it means genuinely hearing, doing whatever it takes to fully understand, and offering only advice or links that we would actually want.) We are genuinely curious and desperate to help, because creating real value for people is the way we win.
- **Engineers first.** We always talk to engineers first, and learn how it's going. Security and IT engineers are the people closest to the work, and the people best positioned to know what their organizations need. - **Engineers first.** We always talk to engineers first, and learn how it's going. Security and IT engineers are the people closest to the work, and the people best positioned to know what their organizations need.

View File

@ -0,0 +1,8 @@
$ResolveWingetPath = Resolve-Path "C:\Program Files\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe"
if ($ResolveWingetPath){
$WingetPath = $ResolveWingetPath[-1].Path
}
$config
Set-Location $wingetpath
.\winget.exe install --id=Bitdefender.Bitdefender -e -h --accept-package-agreements --accept-source-agreements --disable-interactivity

View File

@ -53,6 +53,7 @@ controls:
- path: ../lib/macos-remove-old-nudge.sh - path: ../lib/macos-remove-old-nudge.sh
- path: ../lib/windows-remove-fleetd.ps1 - path: ../lib/windows-remove-fleetd.ps1
- path: ../lib/windows-turn-off-mdm.ps1 - path: ../lib/windows-turn-off-mdm.ps1
- path: ../lib/windows-install-bitdefender.ps1
policies: policies:
- path: ../lib/macos-device-health.policies.yml - path: ../lib/macos-device-health.policies.yml
- path: ../lib/windows-device-health.policies.yml - path: ../lib/windows-device-health.policies.yml

View File

@ -0,0 +1,238 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/jmoiron/sqlx"
)
func (ds *Datastore) CreateOrUpdateCalendarEvent(
ctx context.Context,
email string,
startTime time.Time,
endTime time.Time,
data []byte,
hostID uint,
webhookStatus fleet.CalendarWebhookStatus,
) (*fleet.CalendarEvent, error) {
var id int64
if err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
const calendarEventsQuery = `
INSERT INTO calendar_events (
email,
start_time,
end_time,
event
) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
start_time = VALUES(start_time),
end_time = VALUES(end_time),
event = VALUES(event),
updated_at = CURRENT_TIMESTAMP;
`
result, err := tx.ExecContext(
ctx,
calendarEventsQuery,
email,
startTime,
endTime,
data,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert calendar event")
}
if insertOnDuplicateDidInsert(result) {
id, _ = result.LastInsertId()
} else {
stmt := `SELECT id FROM calendar_events WHERE email = ?`
if err := sqlx.GetContext(ctx, tx, &id, stmt, email); err != nil {
return ctxerr.Wrap(ctx, err, "query mdm solution id")
}
}
const hostCalendarEventsQuery = `
INSERT INTO host_calendar_events (
host_id,
calendar_event_id,
webhook_status
) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
webhook_status = VALUES(webhook_status),
calendar_event_id = VALUES(calendar_event_id);
`
result, err = tx.ExecContext(
ctx,
hostCalendarEventsQuery,
hostID,
id,
webhookStatus,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert host calendar event")
}
return nil
}); err != nil {
return nil, ctxerr.Wrap(ctx, err)
}
calendarEvent, err := getCalendarEventByID(ctx, ds.writer(ctx), uint(id))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get created calendar event by id")
}
return calendarEvent, nil
}
func getCalendarEventByID(ctx context.Context, q sqlx.QueryerContext, id uint) (*fleet.CalendarEvent, error) {
const calendarEventsQuery = `
SELECT * FROM calendar_events WHERE id = ?;
`
var calendarEvent fleet.CalendarEvent
err := sqlx.GetContext(ctx, q, &calendarEvent, calendarEventsQuery, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithID(id))
}
return nil, ctxerr.Wrap(ctx, err, "get calendar event")
}
return &calendarEvent, nil
}
func (ds *Datastore) GetCalendarEvent(ctx context.Context, email string) (*fleet.CalendarEvent, error) {
const calendarEventsQuery = `
SELECT * FROM calendar_events WHERE email = ?;
`
var calendarEvent fleet.CalendarEvent
err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, email)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("email: %s", email)))
}
return nil, ctxerr.Wrap(ctx, err, "get calendar event")
}
return &calendarEvent, nil
}
func (ds *Datastore) UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error {
const calendarEventsQuery = `
UPDATE calendar_events SET
start_time = ?,
end_time = ?,
event = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
`
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, startTime, endTime, data, calendarEventID); err != nil {
return ctxerr.Wrap(ctx, err, "update calendar event")
}
return nil
}
func (ds *Datastore) DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error {
const calendarEventsQuery = `
DELETE FROM calendar_events WHERE id = ?;
`
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, calendarEventID); err != nil {
return ctxerr.Wrap(ctx, err, "delete calendar event")
}
return nil
}
func (ds *Datastore) GetHostCalendarEvent(ctx context.Context, hostID uint) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
const hostCalendarEventsQuery = `
SELECT * FROM host_calendar_events WHERE host_id = ?
`
var hostCalendarEvent fleet.HostCalendarEvent
if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostCalendarEvent, hostCalendarEventsQuery, hostID); err != nil {
if err == sql.ErrNoRows {
return nil, nil, ctxerr.Wrap(ctx, notFound("HostCalendarEvent").WithMessage(fmt.Sprintf("host_id: %d", hostID)))
}
return nil, nil, ctxerr.Wrap(ctx, err, "get host calendar event")
}
const calendarEventsQuery = `
SELECT * FROM calendar_events WHERE id = ?
`
var calendarEvent fleet.CalendarEvent
if err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, hostCalendarEvent.CalendarEventID); err != nil {
if err == sql.ErrNoRows {
return nil, nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithID(hostCalendarEvent.CalendarEventID))
}
return nil, nil, ctxerr.Wrap(ctx, err, "get calendar event")
}
return &hostCalendarEvent, &calendarEvent, nil
}
func (ds *Datastore) GetHostCalendarEventByEmail(ctx context.Context, email string) (*fleet.HostCalendarEvent, *fleet.CalendarEvent, error) {
const calendarEventsQuery = `
SELECT * FROM calendar_events WHERE email = ?
`
var calendarEvent fleet.CalendarEvent
if err := sqlx.GetContext(ctx, ds.reader(ctx), &calendarEvent, calendarEventsQuery, email); err != nil {
if err == sql.ErrNoRows {
return nil, nil, ctxerr.Wrap(ctx, notFound("CalendarEvent").WithMessage(fmt.Sprintf("email: %s", email)))
}
return nil, nil, ctxerr.Wrap(ctx, err, "get calendar event")
}
const hostCalendarEventsQuery = `
SELECT * FROM host_calendar_events WHERE calendar_event_id = ?
`
var hostCalendarEvent fleet.HostCalendarEvent
if err := sqlx.GetContext(ctx, ds.reader(ctx), &hostCalendarEvent, hostCalendarEventsQuery, calendarEvent.ID); err != nil {
if err == sql.ErrNoRows {
return nil, nil, ctxerr.Wrap(ctx, notFound("HostCalendarEvent").WithID(calendarEvent.ID))
}
return nil, nil, ctxerr.Wrap(ctx, err, "get host calendar event")
}
return &hostCalendarEvent, &calendarEvent, nil
}
func (ds *Datastore) UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status fleet.CalendarWebhookStatus) error {
const calendarEventsQuery = `
UPDATE host_calendar_events SET
webhook_status = ?
WHERE host_id = ?;
`
if _, err := ds.writer(ctx).ExecContext(ctx, calendarEventsQuery, status, hostID); err != nil {
return ctxerr.Wrap(ctx, err, "update host calendar event webhook status")
}
return nil
}
func (ds *Datastore) ListCalendarEvents(ctx context.Context, teamID *uint) ([]*fleet.CalendarEvent, error) {
calendarEventsQuery := `
SELECT ce.* FROM calendar_events ce
`
var args []interface{}
if teamID != nil {
// TODO(lucas): Should we add a team_id column to calendar_events?
calendarEventsQuery += ` JOIN host_calendar_events hce ON ce.id=hce.calendar_event_id
JOIN hosts h ON h.id=hce.host_id WHERE h.team_id = ?`
args = append(args, *teamID)
}
var calendarEvents []*fleet.CalendarEvent
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &calendarEvents, calendarEventsQuery, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, ctxerr.Wrap(ctx, err, "get all calendar events")
}
return calendarEvents, nil
}
func (ds *Datastore) ListOutOfDateCalendarEvents(ctx context.Context, t time.Time) ([]*fleet.CalendarEvent, error) {
calendarEventsQuery := `
SELECT ce.* FROM calendar_events ce WHERE updated_at < ?
`
var calendarEvents []*fleet.CalendarEvent
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &calendarEvents, calendarEventsQuery, t); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get all calendar events")
}
return calendarEvents, nil
}

View File

@ -0,0 +1,128 @@
package mysql
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/require"
)
func TestCalendarEvents(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"UpdateCalendarEvent", testUpdateCalendarEvent},
{"CreateOrUpdateCalendarEvent", testCreateOrUpdateCalendarEvent},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testUpdateCalendarEvent(t *testing.T, ds *Datastore) {
ctx := context.Background()
host, err := ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("1"),
UUID: "1",
Hostname: "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
})
require.NoError(t, err)
err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{
{
HostID: host.ID,
Email: "foo@example.com",
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
startTime1 := time.Now()
endTime1 := startTime1.Add(30 * time.Minute)
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
time.Sleep(1 * time.Second)
err = ds.UpdateCalendarEvent(ctx, calendarEvent.ID, startTime1, endTime1, []byte(`{}`))
require.NoError(t, err)
calendarEvent2, err := ds.GetCalendarEvent(ctx, "foo@example.com")
require.NoError(t, err)
require.NotEqual(t, *calendarEvent, *calendarEvent2)
calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt
require.Equal(t, *calendarEvent, *calendarEvent2)
// TODO(lucas): Add more tests here.
}
func testCreateOrUpdateCalendarEvent(t *testing.T, ds *Datastore) {
ctx := context.Background()
host, err := ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("1"),
UUID: "1",
Hostname: "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
})
require.NoError(t, err)
err = ds.ReplaceHostDeviceMapping(ctx, host.ID, []*fleet.HostDeviceMapping{
{
HostID: host.ID,
Email: "foo@example.com",
Source: "google_chrome_profiles",
},
}, "google_chrome_profiles")
require.NoError(t, err)
startTime1 := time.Now()
endTime1 := startTime1.Add(30 * time.Minute)
calendarEvent, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
time.Sleep(1 * time.Second)
calendarEvent2, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime1, endTime1, []byte(`{}`), host.ID, fleet.CalendarWebhookStatusNone)
require.NoError(t, err)
require.Greater(t, calendarEvent2.UpdatedAt, calendarEvent.UpdatedAt)
calendarEvent.UpdatedAt = calendarEvent2.UpdatedAt
require.Equal(t, *calendarEvent, *calendarEvent2)
time.Sleep(1 * time.Second)
startTime2 := startTime1.Add(1 * time.Hour)
endTime2 := startTime1.Add(30 * time.Minute)
calendarEvent3, err := ds.CreateOrUpdateCalendarEvent(ctx, "foo@example.com", startTime2, endTime2, []byte(`{"foo": "bar"}`), host.ID, fleet.CalendarWebhookStatusPending)
require.NoError(t, err)
require.Greater(t, calendarEvent3.UpdatedAt, calendarEvent2.UpdatedAt)
require.WithinDuration(t, startTime2, calendarEvent3.StartTime, 1*time.Second)
require.WithinDuration(t, endTime2, calendarEvent3.EndTime, 1*time.Second)
require.Equal(t, string(calendarEvent3.Data), `{"foo": "bar"}`)
calendarEvent3b, err := ds.GetCalendarEvent(ctx, "foo@example.com")
require.NoError(t, err)
require.Equal(t, calendarEvent3, calendarEvent3b)
// TODO(lucas): Add more tests here.
}

View File

@ -502,6 +502,7 @@ var hostRefs = []string{
"query_results", "query_results",
"host_activities", "host_activities",
"host_mdm_actions", "host_mdm_actions",
"host_calendar_events",
} }
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not // NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not

View File

@ -2554,7 +2554,6 @@ func testHostLiteByIdentifierAndID(t *testing.T, ds *Datastore) {
h, err = ds.HostLiteByID(context.Background(), 0) h, err = ds.HostLiteByID(context.Background(), 0)
assert.ErrorIs(t, err, sql.ErrNoRows) assert.ErrorIs(t, err, sql.ErrNoRows)
assert.Nil(t, h) assert.Nil(t, h)
} }
func testHostsAddToTeam(t *testing.T, ds *Datastore) { func testHostsAddToTeam(t *testing.T, ds *Datastore) {
@ -2795,7 +2794,6 @@ func testHostsTotalAndUnseenSince(t *testing.T, ds *Datastore) {
assert.Equal(t, 2, total) assert.Equal(t, 2, total)
require.Len(t, unseen, 1) require.Len(t, unseen, 1)
assert.Equal(t, host3.ID, unseen[0]) assert.Equal(t, host3.ID, unseen[0])
} }
func testHostsListByPolicy(t *testing.T, ds *Datastore) { func testHostsListByPolicy(t *testing.T, ds *Datastore) {
@ -6577,6 +6575,23 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
`, host.ID) `, host.ID)
require.NoError(t, err) require.NoError(t, err)
// Add a calendar event for the host.
_, err = ds.writer(context.Background()).Exec(`
INSERT INTO calendar_events (email, start_time, end_time, event)
VALUES ('foobar@example.com', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, '{}');
`)
require.NoError(t, err)
var calendarEventID int
err = ds.writer(context.Background()).Get(&calendarEventID, `
SELECT id FROM calendar_events WHERE email = 'foobar@example.com';
`)
require.NoError(t, err)
_, err = ds.writer(context.Background()).Exec(`
INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status)
VALUES (?, ?, 1);
`, host.ID, calendarEventID)
require.NoError(t, err)
// Check there's an entry for the host in all the associated tables. // Check there's an entry for the host in all the associated tables.
for _, hostRef := range hostRefs { for _, hostRef := range hostRefs {
var ok bool var ok bool

View File

@ -0,0 +1,54 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20240314085226, Down_20240314085226)
}
func Up_20240314085226(tx *sql.Tx) error {
// TODO(lucas): Check if we need more indexes.
if _, err := tx.Exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
event JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_one_calendar_event_per_email (email)
);
`); err != nil {
return fmt.Errorf("create calendar_events table: %w", err)
}
if _, err := tx.Exec(`
CREATE TABLE IF NOT EXISTS host_calendar_events (
id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
host_id INT(10) UNSIGNED NOT NULL,
calendar_event_id INT(10) UNSIGNED NOT NULL,
webhook_status TINYINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_one_calendar_event_per_host (host_id),
FOREIGN KEY (calendar_event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
);
`); err != nil {
return fmt.Errorf("create host_calendar_events table: %w", err)
}
return nil
}
func Down_20240314085226(tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show More