mirror of
https://github.com/empayre/fleet.git
synced 2024-11-06 00:45:19 +00:00
Fleet in your calendar feature branch (#17584)
# Checklist for submitter - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality
This commit is contained in:
commit
9bb1610408
5
changes/17230-fleet-in-your-calendar
Normal file
5
changes/17230-fleet-in-your-calendar
Normal 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.
|
699
cmd/fleet/calendar_cron.go
Normal file
699
cmd/fleet/calendar_cron.go
Normal 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
|
||||
}
|
637
cmd/fleet/calendar_cron_test.go
Normal file
637
cmd/fleet/calendar_cron_test.go
Normal 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)
|
||||
}
|
@ -768,6 +768,18 @@ the way that the Fleet server works.
|
||||
}
|
||||
}
|
||||
|
||||
if license.IsPremium() {
|
||||
if err := cronSchedules.StartCronSchedule(
|
||||
func() (fleet.CronSchedule, error) {
|
||||
return newCalendarSchedule(
|
||||
ctx, instanceID, ds, logger,
|
||||
)
|
||||
},
|
||||
); err != nil {
|
||||
initFatal(err, "failed to register calendar schedule")
|
||||
}
|
||||
}
|
||||
|
||||
level.Info(logger).Log("msg", fmt.Sprintf("started cron schedules: %s", strings.Join(cronSchedules.ScheduleNames(), ", ")))
|
||||
|
||||
// StartCollectors starts a goroutine per collector, using ctx to cancel.
|
||||
|
@ -144,7 +144,13 @@ func TestApplyTeamSpecs(t *testing.T) {
|
||||
|
||||
agentOpts := json.RawMessage(`{"config":{"foo":"bar"},"overrides":{"platforms":{"darwin":{"foo":"override"}}}}`)
|
||||
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
|
||||
return &fleet.AppConfig{AgentOptions: &agentOpts, MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
|
||||
return &fleet.AppConfig{
|
||||
AgentOptions: &agentOpts,
|
||||
MDM: fleet.MDM{EnabledAndConfigured: true},
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ds.SaveTeamFunc = func(ctx context.Context, team *fleet.Team) (*fleet.Team, error) {
|
||||
@ -439,6 +445,46 @@ spec:
|
||||
HostPercentage: 25,
|
||||
}, *teamsByName["team1"].Config.WebhookSettings.HostStatusWebhook,
|
||||
)
|
||||
|
||||
// Apply calendar integration
|
||||
filename = writeTmpYml(
|
||||
t, `
|
||||
apiVersion: v1
|
||||
kind: team
|
||||
spec:
|
||||
team:
|
||||
name: team1
|
||||
integrations:
|
||||
google_calendar:
|
||||
enable_calendar_events: true
|
||||
webhook_url: https://example.com/webhook
|
||||
`,
|
||||
)
|
||||
require.Equal(t, "[+] applied 1 teams\n", runAppForTest(t, []string{"apply", "-f", filename}))
|
||||
require.NotNil(t, teamsByName["team1"].Config.Integrations.GoogleCalendar)
|
||||
assert.Equal(
|
||||
t, fleet.TeamGoogleCalendarIntegration{
|
||||
Enable: true,
|
||||
WebhookURL: "https://example.com/webhook",
|
||||
}, *teamsByName["team1"].Config.Integrations.GoogleCalendar,
|
||||
)
|
||||
|
||||
// Apply calendar integration -- invalid webhook destination
|
||||
filename = writeTmpYml(
|
||||
t, `
|
||||
apiVersion: v1
|
||||
kind: team
|
||||
spec:
|
||||
team:
|
||||
name: team1
|
||||
integrations:
|
||||
google_calendar:
|
||||
enable_calendar_events: true
|
||||
webhook_url: bozo
|
||||
`,
|
||||
)
|
||||
_, err = runAppNoChecks([]string{"apply", "-f", filename})
|
||||
assert.ErrorContains(t, err, "invalid URI for request")
|
||||
}
|
||||
|
||||
func writeTmpYml(t *testing.T, contents string) string {
|
||||
|
@ -331,29 +331,31 @@ func TestGetHosts(t *testing.T) {
|
||||
return []*fleet.HostPolicy{
|
||||
{
|
||||
PolicyData: fleet.PolicyData{
|
||||
ID: 1,
|
||||
Name: "query1",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "Some description",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: ptr.String("Some resolution"),
|
||||
TeamID: ptr.Uint(1),
|
||||
ID: 1,
|
||||
Name: "query1",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "Some description",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: ptr.String("Some resolution"),
|
||||
TeamID: ptr.Uint(1),
|
||||
CalendarEventsEnabled: true,
|
||||
},
|
||||
Response: "passes",
|
||||
},
|
||||
{
|
||||
PolicyData: fleet.PolicyData{
|
||||
ID: 2,
|
||||
Name: "query2",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: nil,
|
||||
TeamID: nil,
|
||||
ID: 2,
|
||||
Name: "query2",
|
||||
Query: defaultPolicyQuery,
|
||||
Description: "",
|
||||
AuthorID: ptr.Uint(1),
|
||||
AuthorName: "Alice",
|
||||
AuthorEmail: "alice@example.com",
|
||||
Resolution: nil,
|
||||
TeamID: nil,
|
||||
CalendarEventsEnabled: false,
|
||||
},
|
||||
Response: "fails",
|
||||
},
|
||||
|
@ -360,6 +360,8 @@ func TestFullGlobalGitOps(t *testing.T) {
|
||||
assert.Len(t, appliedScripts, 1)
|
||||
assert.Len(t, appliedMacProfiles, 1)
|
||||
assert.Len(t, appliedWinProfiles, 1)
|
||||
require.Len(t, savedAppConfig.Integrations.GoogleCalendar, 1)
|
||||
assert.Equal(t, "service@example.com", savedAppConfig.Integrations.GoogleCalendar[0].ApiKey["client_email"])
|
||||
}
|
||||
|
||||
func TestFullTeamGitOps(t *testing.T) {
|
||||
@ -389,6 +391,9 @@ func TestFullTeamGitOps(t *testing.T) {
|
||||
EnabledAndConfigured: true,
|
||||
WindowsEnabledAndConfigured: true,
|
||||
},
|
||||
Integrations: fleet.Integrations{
|
||||
GoogleCalendar: []*fleet.GoogleCalendarIntegration{{}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -536,6 +541,8 @@ func TestFullTeamGitOps(t *testing.T) {
|
||||
assert.Len(t, appliedWinProfiles, 1)
|
||||
assert.True(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable)
|
||||
assert.Equal(t, "https://example.com/host_status_webhook", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
|
||||
require.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
|
||||
assert.True(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
|
||||
|
||||
// Now clear the settings
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
|
||||
@ -569,6 +576,9 @@ team_settings:
|
||||
assert.Equal(t, secret, enrolledSecrets[0].Secret)
|
||||
assert.False(t, savedTeam.Config.WebhookSettings.HostStatusWebhook.Enable)
|
||||
assert.Equal(t, "", savedTeam.Config.WebhookSettings.HostStatusWebhook.DestinationURL)
|
||||
assert.NotNil(t, savedTeam.Config.Integrations.GoogleCalendar)
|
||||
assert.False(t, savedTeam.Config.Integrations.GoogleCalendar.Enable)
|
||||
assert.Empty(t, savedTeam.Config.Integrations.GoogleCalendar)
|
||||
assert.Empty(t, savedTeam.Config.MDM.MacOSSettings.CustomSettings)
|
||||
assert.Empty(t, savedTeam.Config.MDM.WindowsSettings.CustomSettings.Value)
|
||||
assert.Empty(t, savedTeam.Config.MDM.MacOSUpdates.Deadline.Value)
|
||||
|
@ -79,7 +79,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"mdm": {
|
||||
"apple_bm_terms_expired": false,
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -119,7 +119,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"update_interval": {
|
||||
"osquery_detail": "1h0m0s",
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
enable_host_users: true
|
||||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -22,7 +22,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": true,
|
||||
@ -92,7 +93,8 @@
|
||||
},
|
||||
"integrations": {
|
||||
"jira": null,
|
||||
"zendesk": null
|
||||
"zendesk": null,
|
||||
"google_calendar": null
|
||||
},
|
||||
"features": {
|
||||
"enable_host_users": false,
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_updates:
|
||||
@ -49,6 +51,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 15
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_updates:
|
||||
|
@ -76,7 +76,8 @@
|
||||
"team_id": 1,
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"critical": false
|
||||
"critical": false,
|
||||
"calendar_events_enabled": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
@ -91,7 +92,8 @@
|
||||
"team_id": null,
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"created_at": "0001-01-01T00:00:00Z",
|
||||
"critical": false
|
||||
"critical": false,
|
||||
"calendar_events_enabled": false
|
||||
}
|
||||
],
|
||||
"status": "offline",
|
||||
|
@ -62,6 +62,7 @@ spec:
|
||||
created_at: "0001-01-01T00:00:00Z"
|
||||
updated_at: "0001-01-01T00:00:00Z"
|
||||
critical: false
|
||||
calendar_events_enabled: true
|
||||
- author_email: "alice@example.com"
|
||||
author_id: 1
|
||||
author_name: Alice
|
||||
@ -75,6 +76,7 @@ spec:
|
||||
created_at: "0001-01-01T00:00:00Z"
|
||||
updated_at: "0001-01-01T00:00:00Z"
|
||||
critical: false
|
||||
calendar_events_enabled: false
|
||||
policy_updated_at: "0001-01-01T00:00:00Z"
|
||||
public_ip: ""
|
||||
primary_ip: ""
|
||||
|
@ -137,6 +137,12 @@ org_settings:
|
||||
integrations:
|
||||
jira: []
|
||||
zendesk: []
|
||||
google_calendar:
|
||||
- domain: example.com
|
||||
api_key_json: {
|
||||
"client_email": "service@example.com",
|
||||
"private_key": "google_calendar_private_key",
|
||||
}
|
||||
mdm:
|
||||
apple_bm_default_team: ""
|
||||
end_user_authentication:
|
||||
|
@ -15,6 +15,10 @@ team_settings:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: true
|
||||
host_expiry_window: 30
|
||||
integrations:
|
||||
google_calendar:
|
||||
enable_calendar_events: true
|
||||
webhook_url: https://example.com/google_calendar_webhook
|
||||
agent_options:
|
||||
command_line_flags:
|
||||
distributed_denylist_duration: 0
|
||||
@ -89,6 +93,7 @@ policies:
|
||||
description: This policy should always fail.
|
||||
resolution: There is no resolution for this policy.
|
||||
query: SELECT 1 FROM osquery_info WHERE start_time < 0;
|
||||
calendar_events_enabled: true
|
||||
- name: Passing policy
|
||||
platform: linux,windows,darwin,chrome
|
||||
description: This policy should always pass.
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -11,6 +11,7 @@ spec:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
jira: null
|
||||
zendesk: null
|
||||
mdm:
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
@ -40,6 +42,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
@ -40,6 +42,8 @@ spec:
|
||||
host_expiry_settings:
|
||||
host_expiry_enabled: false
|
||||
host_expiry_window: 0
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
@ -9,6 +9,8 @@ spec:
|
||||
features:
|
||||
enable_host_users: false
|
||||
enable_software_inventory: false
|
||||
integrations:
|
||||
google_calendar: null
|
||||
mdm:
|
||||
enable_disk_encryption: false
|
||||
macos_settings:
|
||||
|
573
ee/server/calendar/google_calendar.go
Normal file
573
ee/server/calendar/google_calendar.go
Normal 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
|
||||
}
|
132
ee/server/calendar/google_calendar_integration_test.go
Normal file
132
ee/server/calendar/google_calendar_integration_test.go
Normal 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())
|
||||
}
|
||||
|
||||
}
|
234
ee/server/calendar/google_calendar_load.go
Normal file
234
ee/server/calendar/google_calendar_load.go
Normal 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
|
||||
}
|
95
ee/server/calendar/google_calendar_mock.go
Normal file
95
ee/server/calendar/google_calendar_mock.go
Normal 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)
|
||||
}
|
650
ee/server/calendar/google_calendar_test.go
Normal file
650
ee/server/calendar/google_calendar_test.go
Normal 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)
|
||||
}
|
343
ee/server/calendar/load_test/calendar_http_handler.go
Normal file
343
ee/server/calendar/load_test/calendar_http_handler.go
Normal 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
|
||||
}
|
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/authz"
|
||||
@ -195,18 +196,29 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T
|
||||
}
|
||||
|
||||
if payload.Integrations != nil {
|
||||
// the team integrations must reference an existing global config integration.
|
||||
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
if payload.Integrations.Jira != nil || payload.Integrations.Zendesk != nil {
|
||||
// the team integrations must reference an existing global config integration.
|
||||
if _, err := payload.Integrations.MatchWithIntegrations(appCfg.Integrations); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
|
||||
// integrations must be unique
|
||||
if err := payload.Integrations.Validate(); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
// integrations must be unique
|
||||
if err := payload.Integrations.Validate(); err != nil {
|
||||
return nil, fleet.NewInvalidArgumentError("integrations", err.Error())
|
||||
}
|
||||
|
||||
team.Config.Integrations.Jira = payload.Integrations.Jira
|
||||
team.Config.Integrations.Zendesk = payload.Integrations.Zendesk
|
||||
team.Config.Integrations.Jira = payload.Integrations.Jira
|
||||
team.Config.Integrations.Zendesk = payload.Integrations.Zendesk
|
||||
}
|
||||
// Only update the calendar integration if it's not nil
|
||||
if payload.Integrations.GoogleCalendar != nil {
|
||||
invalid := &fleet.InvalidArgumentError{}
|
||||
_ = svc.validateTeamCalendarIntegrations(payload.Integrations.GoogleCalendar, appCfg, invalid)
|
||||
if invalid.HasErrors() {
|
||||
return nil, ctxerr.Wrap(ctx, invalid)
|
||||
}
|
||||
team.Config.Integrations.GoogleCalendar = payload.Integrations.GoogleCalendar
|
||||
}
|
||||
}
|
||||
|
||||
if payload.WebhookSettings != nil || payload.Integrations != nil {
|
||||
@ -1068,6 +1080,15 @@ func (svc *Service) editTeamFromSpec(
|
||||
fleet.ValidateEnabledHostStatusIntegrations(*spec.WebhookSettings.HostStatusWebhook, invalid)
|
||||
team.Config.WebhookSettings.HostStatusWebhook = spec.WebhookSettings.HostStatusWebhook
|
||||
}
|
||||
|
||||
if spec.Integrations.GoogleCalendar != nil {
|
||||
err = svc.validateTeamCalendarIntegrations(spec.Integrations.GoogleCalendar, appCfg, invalid)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "validate team calendar integrations")
|
||||
}
|
||||
team.Config.Integrations.GoogleCalendar = spec.Integrations.GoogleCalendar
|
||||
}
|
||||
|
||||
if invalid.HasErrors() {
|
||||
return ctxerr.Wrap(ctx, invalid)
|
||||
}
|
||||
@ -1124,7 +1145,9 @@ func (svc *Service) editTeamFromSpec(
|
||||
}
|
||||
|
||||
if didUpdateMacOSEndUserAuth {
|
||||
if err := svc.updateMacOSSetupEnableEndUserAuth(ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name); err != nil {
|
||||
if err := svc.updateMacOSSetupEnableEndUserAuth(
|
||||
ctx, spec.MDM.MacOSSetup.EnableEndUserAuthentication, &team.ID, &team.Name,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -1132,6 +1155,26 @@ func (svc *Service) editTeamFromSpec(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) validateTeamCalendarIntegrations(
|
||||
calendarIntegration *fleet.TeamGoogleCalendarIntegration,
|
||||
appCfg *fleet.AppConfig, invalid *fleet.InvalidArgumentError,
|
||||
) error {
|
||||
if !calendarIntegration.Enable {
|
||||
return nil
|
||||
}
|
||||
// Check that global configs exist
|
||||
if len(appCfg.Integrations.GoogleCalendar) == 0 {
|
||||
invalid.Append("integrations.google_calendar.enable_calendar_events", "global Google Calendar integration is not configured")
|
||||
}
|
||||
// Validate URL
|
||||
if u, err := url.ParseRequestURI(calendarIntegration.WebhookURL); err != nil {
|
||||
invalid.Append("integrations.google_calendar.webhook_url", err.Error())
|
||||
} else if u.Scheme != "https" && u.Scheme != "http" {
|
||||
invalid.Append("integrations.google_calendar.webhook_url", "webhook_url must be https or http")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svc *Service) applyTeamMacOSSettings(ctx context.Context, spec *fleet.TeamSpec, applyUpon *fleet.MacOSSettings) error {
|
||||
oldCustomSettings := applyUpon.CustomSettings
|
||||
setFields, err := applyUpon.FromMap(spec.MDM.MacOSSettings)
|
||||
|
@ -76,6 +76,7 @@ const DEFAULT_CONFIG_MOCK: IConfig = {
|
||||
integrations: {
|
||||
jira: [],
|
||||
zendesk: [],
|
||||
google_calendar: [],
|
||||
},
|
||||
logging: {
|
||||
debug: false,
|
||||
|
@ -22,6 +22,7 @@ const DEFAULT_POLICY_MOCK: IPolicyStats = {
|
||||
webhook: "Off",
|
||||
has_run: true,
|
||||
next_update_ms: 3600000,
|
||||
calendar_events_enabled: true,
|
||||
};
|
||||
|
||||
const createMockPolicy = (overrides?: Partial<IPolicyStats>): IPolicyStats => {
|
||||
|
@ -51,12 +51,7 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
font-weight: $regular;
|
||||
vertical-align: top;
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
@ -122,9 +117,6 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
}
|
||||
|
@ -40,10 +40,7 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
&__action-overlay {
|
||||
|
@ -3,6 +3,7 @@ import classnames from "classnames";
|
||||
import { isEmpty } from "lodash";
|
||||
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import { PlacesType } from "react-tooltip-5";
|
||||
|
||||
// all form-field styles are defined in _global.scss, which apply here and elsewhere
|
||||
const baseClass = "form-field";
|
||||
@ -16,6 +17,7 @@ export interface IFormFieldProps {
|
||||
name: string;
|
||||
type: string;
|
||||
tooltip?: React.ReactNode;
|
||||
labelTooltipPosition?: PlacesType;
|
||||
}
|
||||
|
||||
const FormField = ({
|
||||
@ -27,6 +29,7 @@ const FormField = ({
|
||||
name,
|
||||
type,
|
||||
tooltip,
|
||||
labelTooltipPosition,
|
||||
}: IFormFieldProps): JSX.Element => {
|
||||
const renderLabel = () => {
|
||||
const labelWrapperClasses = classnames(`${baseClass}__label`, {
|
||||
@ -45,7 +48,10 @@ const FormField = ({
|
||||
>
|
||||
{error ||
|
||||
(tooltip ? (
|
||||
<TooltipWrapper tipContent={tooltip}>
|
||||
<TooltipWrapper
|
||||
tipContent={tooltip}
|
||||
position={labelTooltipPosition || "top-start"}
|
||||
>
|
||||
{label as string}
|
||||
</TooltipWrapper>
|
||||
) : (
|
||||
|
@ -33,6 +33,7 @@ class InputField extends Component {
|
||||
]).isRequired,
|
||||
parseTarget: PropTypes.bool,
|
||||
tooltip: PropTypes.string,
|
||||
labelTooltipPosition: PropTypes.string,
|
||||
helpText: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
@ -55,6 +56,7 @@ class InputField extends Component {
|
||||
value: "",
|
||||
parseTarget: false,
|
||||
tooltip: "",
|
||||
labelTooltipPosition: "",
|
||||
helpText: "",
|
||||
enableCopy: false,
|
||||
ignore1password: false,
|
||||
@ -124,6 +126,7 @@ class InputField extends Component {
|
||||
"error",
|
||||
"name",
|
||||
"tooltip",
|
||||
"labelTooltipPosition",
|
||||
]);
|
||||
|
||||
const copyValue = (e) => {
|
||||
|
@ -43,9 +43,6 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,10 @@ import FormField from "components/forms/FormField";
|
||||
import { IFormFieldProps } from "components/forms/FormField/FormField";
|
||||
|
||||
interface ISliderProps {
|
||||
onChange: () => void;
|
||||
onChange: (newValue?: {
|
||||
name: string;
|
||||
value: string | number | boolean;
|
||||
}) => void;
|
||||
value: boolean;
|
||||
inactiveText: string;
|
||||
activeText: string;
|
||||
|
1184
frontend/components/graphics/CalendarEventPreview.tsx
Normal file
1184
frontend/components/graphics/CalendarEventPreview.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@ import EmptyTeams from "./EmptyTeams";
|
||||
import EmptyPacks from "./EmptyPacks";
|
||||
import EmptySchedule from "./EmptySchedule";
|
||||
import CollectingResults from "./CollectingResults";
|
||||
import CalendarEventPreview from "./CalendarEventPreview";
|
||||
|
||||
export const GRAPHIC_MAP = {
|
||||
// Empty state graphics
|
||||
@ -41,6 +42,7 @@ export const GRAPHIC_MAP = {
|
||||
"file-pem": FilePem,
|
||||
// Other graphics
|
||||
"collecting-results": CollectingResults,
|
||||
"calendar-event-preview": CalendarEventPreview,
|
||||
};
|
||||
|
||||
export type GraphicNames = keyof typeof GRAPHIC_MAP;
|
||||
|
36
frontend/hooks/useCheckboxListStateManagement.tsx
Normal file
36
frontend/hooks/useCheckboxListStateManagement.tsx
Normal 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;
|
@ -4,7 +4,7 @@ import {
|
||||
IWebhookFailingPolicies,
|
||||
IWebhookSoftwareVulnerabilities,
|
||||
} from "interfaces/webhook";
|
||||
import { IIntegrations } from "./integration";
|
||||
import { IGlobalIntegrations } from "./integration";
|
||||
|
||||
export interface ILicense {
|
||||
tier: string;
|
||||
@ -175,7 +175,7 @@ export interface IConfig {
|
||||
// databases_path: string;
|
||||
// };
|
||||
webhook_settings: IWebhookSettings;
|
||||
integrations: IIntegrations;
|
||||
integrations: IGlobalIntegrations;
|
||||
logging: {
|
||||
debug: boolean;
|
||||
json: boolean;
|
||||
|
@ -60,7 +60,32 @@ export interface IIntegrationFormErrors {
|
||||
enableSoftwareVulnerabilities?: boolean;
|
||||
}
|
||||
|
||||
export interface IIntegrations {
|
||||
export interface IGlobalCalendarIntegration {
|
||||
domain: string;
|
||||
api_key_json: string;
|
||||
}
|
||||
|
||||
interface ITeamCalendarSettings {
|
||||
enable_calendar_events: boolean;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
// zendesk and jira fields are coupled – if one is present, the other needs to be present. If
|
||||
// one is present and the other is null/missing, the other will be nullified. google_calendar is
|
||||
// separated – it can be present without the other 2 without nullifying them.
|
||||
// TODO: Update these types to reflect this.
|
||||
|
||||
export interface IZendeskJiraIntegrations {
|
||||
zendesk: IZendeskIntegration[];
|
||||
jira: IJiraIntegration[];
|
||||
}
|
||||
|
||||
// reality is that IZendeskJiraIntegrations are optional – should be something like `extends
|
||||
// Partial<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;
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ export interface IPolicy {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
critical: boolean;
|
||||
calendar_events_enabled: boolean;
|
||||
}
|
||||
|
||||
// Used on the manage hosts page and other places where aggregate stats are displayed
|
||||
@ -90,6 +91,7 @@ export interface IPolicyFormData {
|
||||
query?: string | number | boolean | undefined;
|
||||
team_id?: number;
|
||||
id?: number;
|
||||
calendar_events_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IPolicyNew {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { IConfigFeatures, IWebhookSettings } from "./config";
|
||||
import enrollSecretInterface, { IEnrollSecret } from "./enroll_secret";
|
||||
import { IIntegrations } from "./integration";
|
||||
import { ITeamIntegrations } from "./integration";
|
||||
import { UserRole } from "./user";
|
||||
|
||||
export default PropTypes.shape({
|
||||
@ -82,7 +82,7 @@ export type ITeamWebhookSettings = Pick<
|
||||
*/
|
||||
export interface ITeamAutomationsConfig {
|
||||
webhook_settings: ITeamWebhookSettings;
|
||||
integrations: IIntegrations;
|
||||
integrations: ITeamIntegrations;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,12 +31,7 @@
|
||||
}
|
||||
|
||||
&__copy-message {
|
||||
font-weight: $regular;
|
||||
vertical-align: top;
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
@include copy-message;
|
||||
}
|
||||
|
||||
&__secret-download-icon {
|
||||
|
@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
IJiraIntegration,
|
||||
IZendeskIntegration,
|
||||
IIntegrations,
|
||||
IZendeskJiraIntegrations,
|
||||
} from "interfaces/integration";
|
||||
import { ITeamConfig } from "interfaces/team";
|
||||
import { IWebhookSoftwareVulnerabilities } from "interfaces/webhook";
|
||||
@ -186,7 +186,9 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
|
||||
const vulnWebhookSettings =
|
||||
softwareConfig?.webhook_settings?.vulnerabilities_webhook;
|
||||
const isVulnWebhookEnabled = !!vulnWebhookSettings?.enable_vulnerabilities_webhook;
|
||||
const isVulnIntegrationEnabled = (integrations?: IIntegrations) => {
|
||||
const isVulnIntegrationEnabled = (
|
||||
integrations?: IZendeskJiraIntegrations
|
||||
) => {
|
||||
return (
|
||||
!!integrations?.jira?.some((j) => j.enable_software_vulnerabilities) ||
|
||||
!!integrations?.zendesk?.some((z) => z.enable_software_vulnerabilities)
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
IJiraIntegration,
|
||||
IZendeskIntegration,
|
||||
IIntegration,
|
||||
IIntegrations,
|
||||
IGlobalIntegrations,
|
||||
IIntegrationType,
|
||||
} from "interfaces/integration";
|
||||
import {
|
||||
@ -124,7 +124,7 @@ const ManageAutomationsModal = ({
|
||||
}
|
||||
}, [destinationUrl]);
|
||||
|
||||
const { data: integrations } = useQuery<IConfig, Error, IIntegrations>(
|
||||
const { data: integrations } = useQuery<IConfig, Error, IGlobalIntegrations>(
|
||||
["integrations"],
|
||||
() => configAPI.loadAll(),
|
||||
{
|
||||
|
@ -4,32 +4,34 @@ import { ISideNavItem } from "../components/SideNav/SideNav";
|
||||
import Integrations from "./cards/Integrations";
|
||||
import Mdm from "./cards/MdmSettings/MdmSettings";
|
||||
import AutomaticEnrollment from "./cards/AutomaticEnrollment/AutomaticEnrollment";
|
||||
import Calendars from "./cards/Calendars/Calendars";
|
||||
|
||||
const getFilteredIntegrationSettingsNavItems = (
|
||||
isSandboxMode = false
|
||||
): ISideNavItem<any>[] => {
|
||||
return [
|
||||
// TODO: types
|
||||
{
|
||||
title: "Ticket destinations",
|
||||
urlSection: "ticket-destinations",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS,
|
||||
Card: Integrations,
|
||||
},
|
||||
{
|
||||
title: "Mobile device management (MDM)",
|
||||
urlSection: "mdm",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_MDM,
|
||||
Card: Mdm,
|
||||
exclude: isSandboxMode,
|
||||
},
|
||||
{
|
||||
title: "Automatic enrollment",
|
||||
urlSection: "automatic-enrollment",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT,
|
||||
Card: AutomaticEnrollment,
|
||||
},
|
||||
].filter((navItem) => !navItem.exclude);
|
||||
};
|
||||
const integrationSettingsNavItems: ISideNavItem<any>[] = [
|
||||
// TODO: types
|
||||
{
|
||||
title: "Ticket destinations",
|
||||
urlSection: "ticket-destinations",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_TICKET_DESTINATIONS,
|
||||
Card: Integrations,
|
||||
},
|
||||
{
|
||||
title: "Mobile device management (MDM)",
|
||||
urlSection: "mdm",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_MDM,
|
||||
Card: Mdm,
|
||||
},
|
||||
{
|
||||
title: "Automatic enrollment",
|
||||
urlSection: "automatic-enrollment",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT,
|
||||
Card: AutomaticEnrollment,
|
||||
},
|
||||
{
|
||||
title: "Calendars",
|
||||
urlSection: "calendars",
|
||||
path: PATHS.ADMIN_INTEGRATIONS_CALENDARS,
|
||||
Card: Calendars,
|
||||
},
|
||||
];
|
||||
|
||||
export default getFilteredIntegrationSettingsNavItems;
|
||||
export default integrationSettingsNavItems;
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { AppContext } from "context/app";
|
||||
import React, { useContext } from "react";
|
||||
import React from "react";
|
||||
import { InjectedRouter, Params } from "react-router/lib/Router";
|
||||
|
||||
import SideNav from "../components/SideNav";
|
||||
import getFilteredIntegrationSettingsNavItems from "./IntegrationNavItems";
|
||||
import integrationSettingsNavItems from "./IntegrationNavItems";
|
||||
|
||||
const baseClass = "integrations";
|
||||
|
||||
@ -16,9 +15,8 @@ const IntegrationsPage = ({
|
||||
router,
|
||||
params,
|
||||
}: IIntegrationSettingsPageProps) => {
|
||||
const { isSandboxMode } = useContext(AppContext);
|
||||
const { section } = params;
|
||||
const navItems = getFilteredIntegrationSettingsNavItems(isSandboxMode);
|
||||
const navItems = integrationSettingsNavItems;
|
||||
const DEFAULT_SETTINGS_SECTION = navItems[0];
|
||||
const currentSection =
|
||||
navItems.find((item) => item.urlSection === section) ??
|
||||
|
@ -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'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 "Fleet calendar events" as the project name.
|
||||
</li>
|
||||
<li>
|
||||
For "Organization" and "Location", select
|
||||
your calendar'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 "Fleet calendar
|
||||
events".
|
||||
</li>
|
||||
<li>
|
||||
Set the service account ID to "fleet-calendar-events".
|
||||
</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 > 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 > Access and data control > API controls >
|
||||
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 "Fleet calendar events" project is
|
||||
selected at the top of the page.
|
||||
</li>
|
||||
<li>
|
||||
Click <b>Enable</b>.
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
You'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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./Calendars";
|
@ -8,7 +8,7 @@ import {
|
||||
IZendeskIntegration,
|
||||
IIntegration,
|
||||
IIntegrationTableData,
|
||||
IIntegrations,
|
||||
IGlobalIntegrations,
|
||||
} from "interfaces/integration";
|
||||
import { IApiError } from "interfaces/errors";
|
||||
|
||||
@ -69,7 +69,7 @@ const Integrations = (): JSX.Element => {
|
||||
isLoading: isLoadingIntegrations,
|
||||
error: loadingIntegrationsError,
|
||||
refetch: refetchIntegrations,
|
||||
} = useQuery<IConfig, Error, IIntegrations>(
|
||||
} = useQuery<IConfig, Error, IGlobalIntegrations>(
|
||||
["integrations"],
|
||||
() => configAPI.loadAll(),
|
||||
{
|
||||
@ -133,9 +133,15 @@ const Integrations = (): JSX.Element => {
|
||||
// Updates either integrations.jira or integrations.zendesk
|
||||
const destination = () => {
|
||||
if (integrationDestination === "jira") {
|
||||
return { jira: integrationSubmitData, zendesk: zendeskIntegrations };
|
||||
return {
|
||||
jira: integrationSubmitData,
|
||||
zendesk: zendeskIntegrations,
|
||||
};
|
||||
}
|
||||
return { zendesk: integrationSubmitData, jira: jiraIntegrations };
|
||||
return {
|
||||
zendesk: integrationSubmitData,
|
||||
jira: jiraIntegrations,
|
||||
};
|
||||
};
|
||||
|
||||
setTestingConnection(true);
|
||||
|
@ -4,7 +4,7 @@ import Modal from "components/Modal";
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import { IIntegration, IIntegrations } from "interfaces/integration";
|
||||
import { IIntegration, IZendeskJiraIntegrations } from "interfaces/integration";
|
||||
import IntegrationForm from "../IntegrationForm";
|
||||
|
||||
const baseClass = "add-integration-modal";
|
||||
@ -17,7 +17,7 @@ interface IAddIntegrationModalProps {
|
||||
) => void;
|
||||
serverErrors?: { base: string; email: string };
|
||||
backendValidators: { [key: string]: string };
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
testingConnection: boolean;
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import Modal from "components/Modal";
|
||||
import Spinner from "components/Spinner";
|
||||
import {
|
||||
IIntegration,
|
||||
IIntegrations,
|
||||
IZendeskJiraIntegrations,
|
||||
IIntegrationTableData,
|
||||
} from "interfaces/integration";
|
||||
import IntegrationForm from "../IntegrationForm";
|
||||
@ -15,7 +15,7 @@ interface IEditIntegrationModalProps {
|
||||
onCancel: () => void;
|
||||
onSubmit: (jiraIntegrationSubmitData: IIntegration[]) => void;
|
||||
backendValidators: { [key: string]: string };
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
integrationEditing?: IIntegrationTableData;
|
||||
testingConnection: boolean;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
IIntegrationFormData,
|
||||
IIntegrationTableData,
|
||||
IIntegration,
|
||||
IIntegrations,
|
||||
IZendeskJiraIntegrations,
|
||||
IIntegrationType,
|
||||
} from "interfaces/integration";
|
||||
|
||||
@ -26,7 +26,7 @@ interface IIntegrationFormProps {
|
||||
integrationDestination: string
|
||||
) => void;
|
||||
integrationEditing?: IIntegrationTableData;
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
integrationEditingUrl?: string;
|
||||
integrationEditingUsername?: string;
|
||||
integrationEditingEmail?: string;
|
||||
|
@ -12,7 +12,6 @@ export interface ISideNavItem<T> {
|
||||
urlSection: string;
|
||||
path: string;
|
||||
Card: (props: T) => JSX.Element;
|
||||
exclude?: boolean;
|
||||
}
|
||||
|
||||
interface ISideNavProps {
|
||||
|
@ -1,104 +1,10 @@
|
||||
.host-actions-dropdown {
|
||||
.form-field {
|
||||
margin: 0;
|
||||
@include button-dropdown;
|
||||
color: $core-fleet-black;
|
||||
.Select-multi-value-wrapper {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.Select {
|
||||
position: relative;
|
||||
border: 0;
|
||||
height: auto;
|
||||
|
||||
&.is-focused,
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.is-focused:not(.is-open) {
|
||||
.Select-control {
|
||||
background-color: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-control {
|
||||
display: flex;
|
||||
background-color: initial;
|
||||
height: auto;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:hover .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
color: $core-fleet-black;
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
padding-left: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.Select-input {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.Select-arrow-zone {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-multi-value-wrapper {
|
||||
width: 55px;
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Select-menu-outer {
|
||||
margin-top: $pad-xsmall;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
border-radius: $border-radius;
|
||||
z-index: 6;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
width: 188px;
|
||||
left: unset;
|
||||
top: unset;
|
||||
max-height: none;
|
||||
padding: $pad-small;
|
||||
position: absolute;
|
||||
left: -120px;
|
||||
|
||||
.Select-menu {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&:not(.is-open) {
|
||||
.Select-control:hover .Select-arrow {
|
||||
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
.Select-control .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
.Select > .Select-menu-outer {
|
||||
left: -120px;
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,6 @@ import { QueryContext } from "context/query";
|
||||
import { NotificationContext } from "context/notification";
|
||||
|
||||
import activitiesAPI, {
|
||||
IActivitiesResponse,
|
||||
IPastActivitiesResponse,
|
||||
IUpcomingActivitiesResponse,
|
||||
} from "services/entities/activities";
|
||||
|
@ -2,7 +2,9 @@ import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { InjectedRouter } from "react-router/lib/Router";
|
||||
import PATHS from "router/paths";
|
||||
import { noop, isEqual } from "lodash";
|
||||
import { noop, isEqual, uniqueId } from "lodash";
|
||||
|
||||
import { Tooltip as ReactTooltip5 } from "react-tooltip-5";
|
||||
|
||||
import { getNextLocationPath } from "utilities/helpers";
|
||||
|
||||
@ -12,7 +14,7 @@ import { TableContext } from "context/table";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
import { IConfig, IWebhookSettings } from "interfaces/config";
|
||||
import { IIntegrations } from "interfaces/integration";
|
||||
import { IZendeskJiraIntegrations } from "interfaces/integration";
|
||||
import {
|
||||
IPolicyStats,
|
||||
ILoadAllPoliciesResponse,
|
||||
@ -34,6 +36,8 @@ import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
|
||||
|
||||
import { ITableQueryData } from "components/TableContainer/TableContainer";
|
||||
import Button from "components/buttons/Button";
|
||||
// @ts-ignore
|
||||
import Dropdown from "components/forms/fields/Dropdown";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import Spinner from "components/Spinner";
|
||||
import TeamsDropdown from "components/TeamsDropdown";
|
||||
@ -41,9 +45,11 @@ import TableDataError from "components/DataError";
|
||||
import MainContent from "components/MainContent";
|
||||
|
||||
import PoliciesTable from "./components/PoliciesTable";
|
||||
import ManagePolicyAutomationsModal from "./components/ManagePolicyAutomationsModal";
|
||||
import OtherWorkflowsModal from "./components/OtherWorkflowsModal";
|
||||
import AddPolicyModal from "./components/AddPolicyModal";
|
||||
import DeletePolicyModal from "./components/DeletePolicyModal";
|
||||
import CalendarEventsModal from "./components/CalendarEventsModal";
|
||||
import { ICalendarEventsFormData } from "./components/CalendarEventsModal/CalendarEventsModal";
|
||||
|
||||
interface IManagePoliciesPageProps {
|
||||
router: InjectedRouter;
|
||||
@ -125,13 +131,15 @@ const ManagePolicyPage = ({
|
||||
|
||||
const [isUpdatingAutomations, setIsUpdatingAutomations] = useState(false);
|
||||
const [isUpdatingPolicies, setIsUpdatingPolicies] = useState(false);
|
||||
const [
|
||||
updatingPolicyEnabledCalendarEvents,
|
||||
setUpdatingPolicyEnabledCalendarEvents,
|
||||
] = useState(false);
|
||||
const [selectedPolicyIds, setSelectedPolicyIds] = useState<number[]>([]);
|
||||
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
|
||||
false
|
||||
);
|
||||
const [showPreviewPayloadModal, setShowPreviewPayloadModal] = useState(false);
|
||||
const [showOtherWorkflowsModal, setShowOtherWorkflowsModal] = useState(false);
|
||||
const [showAddPolicyModal, setShowAddPolicyModal] = useState(false);
|
||||
const [showDeletePolicyModal, setShowDeletePolicyModal] = useState(false);
|
||||
const [showCalendarEventsModal, setShowCalendarEventsModal] = useState(false);
|
||||
|
||||
const [teamPolicies, setTeamPolicies] = useState<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
|
||||
);
|
||||
|
||||
const toggleManageAutomationsModal = () =>
|
||||
setShowManageAutomationsModal(!showManageAutomationsModal);
|
||||
|
||||
const togglePreviewPayloadModal = useCallback(() => {
|
||||
setShowPreviewPayloadModal(!showPreviewPayloadModal);
|
||||
}, [setShowPreviewPayloadModal, showPreviewPayloadModal]);
|
||||
const toggleOtherWorkflowsModal = () =>
|
||||
setShowOtherWorkflowsModal(!showOtherWorkflowsModal);
|
||||
|
||||
const toggleAddPolicyModal = () => setShowAddPolicyModal(!showAddPolicyModal);
|
||||
|
||||
const toggleDeletePolicyModal = () =>
|
||||
setShowDeletePolicyModal(!showDeletePolicyModal);
|
||||
|
||||
const toggleCalendarEventsModal = () => {
|
||||
setShowCalendarEventsModal(!showCalendarEventsModal);
|
||||
};
|
||||
|
||||
const onSelectAutomationOption = (option: string) => {
|
||||
switch (option) {
|
||||
case "calendar_events":
|
||||
toggleCalendarEventsModal();
|
||||
break;
|
||||
case "other_workflows":
|
||||
toggleOtherWorkflowsModal();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowInheritedPolicies = () => {
|
||||
// URL source of truth
|
||||
const locationPath = getNextLocationPath({
|
||||
@ -499,9 +519,9 @@ const ManagePolicyPage = ({
|
||||
router?.replace(locationPath);
|
||||
};
|
||||
|
||||
const handleUpdateAutomations = async (requestBody: {
|
||||
const handleUpdateOtherWorkflows = async (requestBody: {
|
||||
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
|
||||
integrations: IIntegrations;
|
||||
integrations: IZendeskJiraIntegrations;
|
||||
}) => {
|
||||
setIsUpdatingAutomations(true);
|
||||
try {
|
||||
@ -515,13 +535,79 @@ const ManagePolicyPage = ({
|
||||
"Could not update policy automations. Please try again."
|
||||
);
|
||||
} finally {
|
||||
toggleManageAutomationsModal();
|
||||
toggleOtherWorkflowsModal();
|
||||
setIsUpdatingAutomations(false);
|
||||
refetchConfig();
|
||||
isAnyTeamSelected && refetchTeamConfig();
|
||||
}
|
||||
};
|
||||
|
||||
const updatePolicyEnabledCalendarEvents = async (
|
||||
formData: ICalendarEventsFormData
|
||||
) => {
|
||||
setUpdatingPolicyEnabledCalendarEvents(true);
|
||||
|
||||
try {
|
||||
// update team config if either field has been changed
|
||||
const responses: Promise<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 = () => {
|
||||
setLastEditedQueryName("");
|
||||
setLastEditedQueryDescription("");
|
||||
@ -687,6 +773,70 @@ const ManagePolicyPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getAutomationsDropdownOptions = () => {
|
||||
const isAllTeams = teamIdForApi === undefined || teamIdForApi === -1;
|
||||
let calEventsLabel: React.ReactNode = "Calendar events";
|
||||
if (!isPremiumTier) {
|
||||
const tipId = uniqueId();
|
||||
calEventsLabel = (
|
||||
<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 (
|
||||
<MainContent className={baseClass}>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
@ -714,18 +864,15 @@ const ManagePolicyPage = ({
|
||||
{showCtaButtons && (
|
||||
<div className={`${baseClass} button-wrap`}>
|
||||
{canManageAutomations && automationsConfig && (
|
||||
<Button
|
||||
onClick={toggleManageAutomationsModal}
|
||||
className={`${baseClass}__manage-automations button`}
|
||||
variant="inverse"
|
||||
disabled={
|
||||
isAnyTeamSelected
|
||||
? isFetchingTeamPolicies
|
||||
: isFetchingGlobalPolicies
|
||||
}
|
||||
>
|
||||
<span>Manage automations</span>
|
||||
</Button>
|
||||
<div className={`${baseClass}__manage-automations-wrapper`}>
|
||||
<Dropdown
|
||||
className={`${baseClass}__manage-automations-dropdown`}
|
||||
onChange={onSelectAutomationOption}
|
||||
placeholder="Manage automations"
|
||||
searchable={false}
|
||||
options={getAutomationsDropdownOptions()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canAddOrDeletePolicy && (
|
||||
<div className={`${baseClass}__action-button-container`}>
|
||||
@ -795,16 +942,14 @@ const ManagePolicyPage = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{config && automationsConfig && showManageAutomationsModal && (
|
||||
<ManagePolicyAutomationsModal
|
||||
{config && automationsConfig && showOtherWorkflowsModal && (
|
||||
<OtherWorkflowsModal
|
||||
automationsConfig={automationsConfig}
|
||||
availableIntegrations={config.integrations}
|
||||
availablePolicies={availablePoliciesForAutomation}
|
||||
isUpdatingAutomations={isUpdatingAutomations}
|
||||
showPreviewPayloadModal={showPreviewPayloadModal}
|
||||
onExit={toggleManageAutomationsModal}
|
||||
handleSubmit={handleUpdateAutomations}
|
||||
togglePreviewPayloadModal={togglePreviewPayloadModal}
|
||||
onExit={toggleOtherWorkflowsModal}
|
||||
handleSubmit={handleUpdateOtherWorkflows}
|
||||
/>
|
||||
)}
|
||||
{showAddPolicyModal && (
|
||||
@ -822,6 +967,22 @@ const ManagePolicyPage = ({
|
||||
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>
|
||||
</MainContent>
|
||||
);
|
||||
|
@ -8,13 +8,57 @@
|
||||
.button-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 266px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__manage-automations {
|
||||
padding: $pad-small;
|
||||
margin-right: $pad-small;
|
||||
&__manage-automations-wrapper {
|
||||
@include button-dropdown;
|
||||
.Select-multi-value-wrapper {
|
||||
width: 146px;
|
||||
}
|
||||
.Select > .Select-menu-outer {
|
||||
left: -186px;
|
||||
width: 360px;
|
||||
.dropdown__help-text {
|
||||
color: $ui-fleet-black-50;
|
||||
}
|
||||
.is-disabled * {
|
||||
color: $ui-fleet-black-25;
|
||||
.label-text {
|
||||
font-style: normal;
|
||||
// increase height to allow for broader tooltip activation area
|
||||
position: absolute;
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
}
|
||||
.dropdown__help-text {
|
||||
// compensate for absolute label-text height
|
||||
margin-top: 20px;
|
||||
}
|
||||
.react-tooltip {
|
||||
@include tooltip-text;
|
||||
font-style: normal;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.Select-control {
|
||||
margin-top: 0;
|
||||
gap: 6px;
|
||||
.Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
font-weight: $bold;
|
||||
}
|
||||
.dropdown__custom-arrow .dropdown__icon {
|
||||
svg {
|
||||
path {
|
||||
stroke: $core-vibrant-blue-over;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
|
@ -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'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 > Integrations > 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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./CalendarEventsModal";
|
@ -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;
|
@ -0,0 +1,9 @@
|
||||
.example-payload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-large;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./ExamplePayload";
|
@ -1,28 +1,24 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { AppContext } from "context/app";
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
|
||||
import { IIntegrationType } from "interfaces/integration";
|
||||
|
||||
import Card from "components/Card";
|
||||
import JiraPreview from "../../../../../../assets/images/jira-policy-automation-preview-400x419@2x.png";
|
||||
import ZendeskPreview from "../../../../../../assets/images/zendesk-policy-automation-preview-400x515@2x.png";
|
||||
import JiraPreviewPremium from "../../../../../../assets/images/jira-policy-automation-preview-premium-400x316@2x.png";
|
||||
import ZendeskPreviewPremium from "../../../../../../assets/images/zendesk-policy-automation-preview-premium-400x483@2x.png";
|
||||
|
||||
const baseClass = "preview-ticket-modal";
|
||||
const baseClass = "example-ticket";
|
||||
|
||||
interface IPreviewTicketModalProps {
|
||||
interface IExampleTicketProps {
|
||||
integrationType?: IIntegrationType;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PreviewTicketModal = ({
|
||||
const ExampleTicket = ({
|
||||
integrationType,
|
||||
onCancel,
|
||||
}: IPreviewTicketModalProps): JSX.Element => {
|
||||
}: IExampleTicketProps): JSX.Element => {
|
||||
const { isPremiumTier } = useContext(AppContext);
|
||||
|
||||
const screenshot =
|
||||
@ -41,30 +37,10 @@ const PreviewTicketModal = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Example ticket"
|
||||
onExit={onCancel}
|
||||
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>
|
||||
<Card className={baseClass} color="gray">
|
||||
{screenshot}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewTicketModal;
|
||||
export default ExampleTicket;
|
@ -0,0 +1,10 @@
|
||||
.example-ticket {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__screenshot {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./ExampleTicket";
|
@ -1 +0,0 @@
|
||||
export { default } from "./ManagePolicyAutomationsModal";
|
@ -3,7 +3,12 @@ import { Link } from "react-router";
|
||||
import { isEmpty, noop, omit } from "lodash";
|
||||
|
||||
import { IAutomationsConfig, IWebhookSettings } from "interfaces/config";
|
||||
import { IIntegration, IIntegrations } from "interfaces/integration";
|
||||
import {
|
||||
IGlobalIntegrations,
|
||||
IIntegration,
|
||||
IZendeskJiraIntegrations,
|
||||
ITeamIntegrations,
|
||||
} from "interfaces/integration";
|
||||
import { IPolicy } from "interfaces/policy";
|
||||
import { ITeamAutomationsConfig } from "interfaces/team";
|
||||
import PATHS from "router/paths";
|
||||
@ -19,22 +24,21 @@ import Dropdown from "components/forms/fields/Dropdown";
|
||||
import InputField from "components/forms/fields/InputField";
|
||||
import Radio from "components/forms/fields/Radio";
|
||||
import validUrl from "components/forms/validators/valid_url";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import ExampleTicket from "../ExampleTicket";
|
||||
import ExamplePayload from "../ExamplePayload";
|
||||
|
||||
import PreviewPayloadModal from "../PreviewPayloadModal";
|
||||
import PreviewTicketModal from "../PreviewTicketModal";
|
||||
|
||||
interface IManagePolicyAutomationsModalProps {
|
||||
interface IOtherWorkflowsModalProps {
|
||||
automationsConfig: IAutomationsConfig | ITeamAutomationsConfig;
|
||||
availableIntegrations: IIntegrations;
|
||||
availableIntegrations: IGlobalIntegrations | ITeamIntegrations;
|
||||
availablePolicies: IPolicy[];
|
||||
isUpdatingAutomations: boolean;
|
||||
showPreviewPayloadModal: boolean;
|
||||
onExit: () => void;
|
||||
handleSubmit: (formData: {
|
||||
webhook_settings: Pick<IWebhookSettings, "failing_policies_webhook">;
|
||||
integrations: IIntegrations;
|
||||
integrations: IGlobalIntegrations | ITeamIntegrations;
|
||||
}) => void;
|
||||
togglePreviewPayloadModal: () => void;
|
||||
}
|
||||
|
||||
interface ICheckedPolicy {
|
||||
@ -43,7 +47,10 @@ interface ICheckedPolicy {
|
||||
isChecked: boolean;
|
||||
}
|
||||
|
||||
const findEnabledIntegration = ({ jira, zendesk }: IIntegrations) => {
|
||||
const findEnabledIntegration = ({
|
||||
jira,
|
||||
zendesk,
|
||||
}: IZendeskJiraIntegrations) => {
|
||||
return (
|
||||
jira?.find((j) => j.enable_failing_policies) ||
|
||||
zendesk?.find((z) => z.enable_failing_policies)
|
||||
@ -83,18 +90,16 @@ const useCheckboxListStateManagement = (
|
||||
return { policyItems, updatePolicyItems };
|
||||
};
|
||||
|
||||
const baseClass = "manage-policy-automations-modal";
|
||||
const baseClass = "other-workflows-modal";
|
||||
|
||||
const ManagePolicyAutomationsModal = ({
|
||||
const OtherWorkflowsModal = ({
|
||||
automationsConfig,
|
||||
availableIntegrations,
|
||||
availablePolicies,
|
||||
isUpdatingAutomations,
|
||||
showPreviewPayloadModal,
|
||||
onExit,
|
||||
handleSubmit,
|
||||
togglePreviewPayloadModal: togglePreviewModal,
|
||||
}: IManagePolicyAutomationsModalProps): JSX.Element => {
|
||||
}: IOtherWorkflowsModalProps): JSX.Element => {
|
||||
const {
|
||||
webhook_settings: { failing_policies_webhook: webhook },
|
||||
} = automationsConfig;
|
||||
@ -131,6 +136,9 @@ const ManagePolicyAutomationsModal = ({
|
||||
IIntegration | undefined
|
||||
>(serverEnabledIntegration);
|
||||
|
||||
const [showExamplePayload, setShowExamplePayload] = useState(false);
|
||||
const [showExampleTicket, setShowExampleTicket] = useState(false);
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
const { policyItems, updatePolicyItems } = useCheckboxListStateManagement(
|
||||
@ -218,13 +226,6 @@ const ManagePolicyAutomationsModal = ({
|
||||
z.group_id === selectedIntegration?.group_id,
|
||||
})) || null;
|
||||
|
||||
// if (
|
||||
// !isPolicyAutomationsEnabled ||
|
||||
// (!isWebhookEnabled && !selectedIntegration)
|
||||
// ) {
|
||||
// newPolicyIds = [];
|
||||
// }
|
||||
|
||||
const updatedEnabledPoliciesAcrossPages = () => {
|
||||
if (webhook.policy_ids) {
|
||||
// Array of policy ids on the page
|
||||
@ -263,6 +264,7 @@ const ManagePolicyAutomationsModal = ({
|
||||
integrations: {
|
||||
jira: newJira,
|
||||
zendesk: newZendesk,
|
||||
google_calendar: null, // When null, the backend does not update google_calendar
|
||||
},
|
||||
});
|
||||
|
||||
@ -297,34 +299,52 @@ const ManagePolicyAutomationsModal = ({
|
||||
placeholder="https://server.com/example"
|
||||
tooltip="Provide a URL to deliver a webhook request to."
|
||||
/>
|
||||
<Button type="button" variant="text-link" onClick={togglePreviewModal}>
|
||||
Preview payload
|
||||
</Button>
|
||||
<RevealButton
|
||||
isShowing={showExamplePayload}
|
||||
className={baseClass}
|
||||
hideText="Hide example payload"
|
||||
showText="Show example payload"
|
||||
caretPosition="after"
|
||||
onClick={() => setShowExamplePayload(!showExamplePayload)}
|
||||
/>
|
||||
{showExamplePayload && <ExamplePayload />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderIntegrations = () => {
|
||||
return jira?.length || zendesk?.length ? (
|
||||
<div className={`${baseClass}__integrations`}>
|
||||
<Dropdown
|
||||
options={dropdownOptions}
|
||||
onChange={onSelectIntegration}
|
||||
placeholder="Select integration"
|
||||
value={
|
||||
selectedIntegration?.group_id || selectedIntegration?.project_key
|
||||
}
|
||||
label="Integration"
|
||||
error={errors.integration}
|
||||
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
|
||||
hint={
|
||||
"For each policy, Fleet will create a ticket with a list of the failing hosts."
|
||||
}
|
||||
<>
|
||||
<div className={`${baseClass}__integrations`}>
|
||||
<Dropdown
|
||||
options={dropdownOptions}
|
||||
onChange={onSelectIntegration}
|
||||
placeholder="Select integration"
|
||||
value={
|
||||
selectedIntegration?.group_id || selectedIntegration?.project_key
|
||||
}
|
||||
label="Integration"
|
||||
error={errors.integration}
|
||||
wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`}
|
||||
hint={
|
||||
"For each policy, Fleet will create a ticket with a list of the failing hosts."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<RevealButton
|
||||
isShowing={showExampleTicket}
|
||||
className={baseClass}
|
||||
hideText={"Hide example ticket"}
|
||||
showText={"Show example ticket"}
|
||||
caretPosition="after"
|
||||
onClick={() => setShowExampleTicket(!showExampleTicket)}
|
||||
/>
|
||||
<Button type="button" variant="text-link" onClick={togglePreviewModal}>
|
||||
Preview ticket
|
||||
</Button>
|
||||
</div>
|
||||
{showExampleTicket && (
|
||||
<ExampleTicket
|
||||
integrationType={getIntegrationType(selectedIntegration)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={`form-field ${baseClass}__no-integrations`}>
|
||||
<div className="form-field__label">You have no integrations.</div>
|
||||
@ -338,22 +358,10 @@ const ManagePolicyAutomationsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreview = () =>
|
||||
!isWebhookEnabled ? (
|
||||
<PreviewTicketModal
|
||||
integrationType={getIntegrationType(selectedIntegration)}
|
||||
onCancel={togglePreviewModal}
|
||||
/>
|
||||
) : (
|
||||
<PreviewPayloadModal onCancel={togglePreviewModal} />
|
||||
);
|
||||
|
||||
return showPreviewPayloadModal ? (
|
||||
renderPreview()
|
||||
) : (
|
||||
return (
|
||||
<Modal
|
||||
onExit={onExit}
|
||||
title="Manage automations"
|
||||
title="Other workflows"
|
||||
className={baseClass}
|
||||
width="large"
|
||||
>
|
||||
@ -372,12 +380,32 @@ const ManagePolicyAutomationsModal = ({
|
||||
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">
|
||||
{availablePolicies?.length ? (
|
||||
<>
|
||||
<div className="form-field__label">
|
||||
Choose which policies you would like to listen to:
|
||||
</div>
|
||||
<div className="form-field__label">Policies:</div>
|
||||
{policyItems &&
|
||||
policyItems.map((policyItem) => {
|
||||
const { isChecked, name, id } = policyItem;
|
||||
@ -405,28 +433,14 @@ const ManagePolicyAutomationsModal = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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}
|
||||
<p className={`${baseClass}__help-text`}>
|
||||
The workflow will be triggered when hosts fail these policies.{" "}
|
||||
<CustomLink
|
||||
url="https://www.fleetdm.com/learn-more-about/policy-automations"
|
||||
text="Learn more"
|
||||
newTab
|
||||
/>
|
||||
<Radio
|
||||
className={`${baseClass}__radio-input`}
|
||||
label="Webhook"
|
||||
id="webhook-radio-btn"
|
||||
checked={isWebhookEnabled}
|
||||
value="webhook"
|
||||
name="webhook"
|
||||
onChange={onChangeRadio}
|
||||
/>
|
||||
</div>
|
||||
{isWebhookEnabled ? renderWebhook() : renderIntegrations()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-cta-wrap">
|
||||
<Button
|
||||
@ -447,4 +461,4 @@ const ManagePolicyAutomationsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagePolicyAutomationsModal;
|
||||
export default OtherWorkflowsModal;
|
@ -1,12 +1,9 @@
|
||||
.manage-policy-automations-modal {
|
||||
.other-workflows-modal {
|
||||
pre,
|
||||
code {
|
||||
background-color: $ui-off-white;
|
||||
color: $core-fleet-blue;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: $border-radius;
|
||||
padding: 7px $pad-medium;
|
||||
margin: $pad-large 0 0 44px;
|
||||
}
|
||||
|
||||
&__error {
|
||||
@ -22,4 +19,8 @@
|
||||
&__no-integrations a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__help-text {
|
||||
@include help-text;
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from "./OtherWorkflowsModal";
|
@ -284,16 +284,6 @@ const generateTableHeaders = (
|
||||
];
|
||||
|
||||
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) {
|
||||
return tableHeaders;
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./PreviewPayloadModal";
|
@ -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));
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export { default } from "./PreviewTicketModal";
|
@ -32,6 +32,7 @@ export default {
|
||||
ADMIN_INTEGRATIONS_MDM_WINDOWS: `${URL_PREFIX}/settings/integrations/mdm/windows`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT: `${URL_PREFIX}/settings/integrations/automatic-enrollment`,
|
||||
ADMIN_INTEGRATIONS_AUTOMATIC_ENROLLMENT_WINDOWS: `${URL_PREFIX}/settings/integrations/automatic-enrollment/windows`,
|
||||
ADMIN_INTEGRATIONS_CALENDARS: `${URL_PREFIX}/settings/integrations/calendars`,
|
||||
ADMIN_TEAMS: `${URL_PREFIX}/settings/teams`,
|
||||
ADMIN_ORGANIZATION: `${URL_PREFIX}/settings/organization`,
|
||||
ADMIN_ORGANIZATION_INFO: `${URL_PREFIX}/settings/organization/info`,
|
||||
|
@ -87,6 +87,7 @@ export default {
|
||||
resolution,
|
||||
platform,
|
||||
critical,
|
||||
calendar_events_enabled,
|
||||
} = data;
|
||||
const { TEAMS } = endpoints;
|
||||
const path = `${TEAMS}/${team_id}/policies/${id}`;
|
||||
@ -98,6 +99,7 @@ export default {
|
||||
resolution,
|
||||
platform,
|
||||
critical,
|
||||
calendar_events_enabled,
|
||||
});
|
||||
},
|
||||
destroy: (teamId: number | undefined, ids: number[]) => {
|
||||
|
@ -5,7 +5,7 @@ import { pick } from "lodash";
|
||||
|
||||
import { buildQueryStringFromParams } from "utilities/url";
|
||||
import { IEnrollSecret } from "interfaces/enroll_secret";
|
||||
import { IIntegrations } from "interfaces/integration";
|
||||
import { ITeamIntegrations } from "interfaces/integration";
|
||||
import {
|
||||
API_NO_TEAM_ID,
|
||||
INewTeamUsersBody,
|
||||
@ -39,7 +39,7 @@ export interface ITeamFormData {
|
||||
export interface IUpdateTeamFormData {
|
||||
name: string;
|
||||
webhook_settings: Partial<ITeamWebhookSettings>;
|
||||
integrations: IIntegrations;
|
||||
integrations: ITeamIntegrations;
|
||||
mdm: {
|
||||
macos_updates?: {
|
||||
minimum_version: string;
|
||||
@ -118,7 +118,7 @@ export default {
|
||||
requestBody.webhook_settings = webhook_settings;
|
||||
}
|
||||
if (integrations) {
|
||||
const { jira, zendesk } = integrations;
|
||||
const { jira, zendesk, google_calendar } = integrations;
|
||||
const teamIntegrationProps = [
|
||||
"enable_failing_policies",
|
||||
"group_id",
|
||||
@ -128,6 +128,7 @@ export default {
|
||||
requestBody.integrations = {
|
||||
jira: jira?.map((j) => pick(j, teamIntegrationProps)),
|
||||
zendesk: zendesk?.map((z) => pick(z, teamIntegrationProps)),
|
||||
google_calendar,
|
||||
};
|
||||
}
|
||||
if (mdm) {
|
||||
|
@ -182,6 +182,15 @@ $max-width: 2560px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin copy-message {
|
||||
font-weight: $regular;
|
||||
vertical-align: top;
|
||||
background-color: $ui-light-grey;
|
||||
border: solid 1px #e2e4ea;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
@mixin color-contrasted-sections {
|
||||
background-color: $ui-off-white;
|
||||
.section {
|
||||
@ -227,3 +236,102 @@ $max-width: 2560px;
|
||||
// compensate in layout for extra clickable area button height
|
||||
margin: -8px 0;
|
||||
}
|
||||
|
||||
@mixin button-dropdown {
|
||||
.form-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Select {
|
||||
position: relative;
|
||||
border: 0;
|
||||
height: auto;
|
||||
|
||||
&.is-focused,
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&.is-focused:not(.is-open) {
|
||||
.Select-control {
|
||||
background-color: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-control {
|
||||
display: flex;
|
||||
background-color: initial;
|
||||
height: auto;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:hover .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
padding-left: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.Select-input {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.Select-arrow-zone {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.Select-menu-outer {
|
||||
margin-top: $pad-xsmall;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
border-radius: $border-radius;
|
||||
z-index: 6;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
width: 188px;
|
||||
left: unset;
|
||||
top: unset;
|
||||
max-height: none;
|
||||
padding: $pad-small;
|
||||
position: absolute;
|
||||
|
||||
.Select-menu {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&:not(.is-open) {
|
||||
.Select-control:hover .Select-arrow {
|
||||
content: url("../assets/images/icon-chevron-blue-16x16@2x.png");
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
.Select-control .Select-placeholder {
|
||||
color: $core-vibrant-blue;
|
||||
}
|
||||
|
||||
.Select-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
238
server/datastore/mysql/calendar_events.go
Normal file
238
server/datastore/mysql/calendar_events.go
Normal 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
|
||||
}
|
128
server/datastore/mysql/calendar_events_test.go
Normal file
128
server/datastore/mysql/calendar_events_test.go
Normal 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.
|
||||
}
|
@ -502,6 +502,7 @@ var hostRefs = []string{
|
||||
"query_results",
|
||||
"host_activities",
|
||||
"host_mdm_actions",
|
||||
"host_calendar_events",
|
||||
}
|
||||
|
||||
// NOTE: The following tables are explicity excluded from hostRefs list and accordingly are not
|
||||
|
@ -2554,7 +2554,6 @@ func testHostLiteByIdentifierAndID(t *testing.T, ds *Datastore) {
|
||||
h, err = ds.HostLiteByID(context.Background(), 0)
|
||||
assert.ErrorIs(t, err, sql.ErrNoRows)
|
||||
assert.Nil(t, h)
|
||||
|
||||
}
|
||||
|
||||
func testHostsAddToTeam(t *testing.T, ds *Datastore) {
|
||||
@ -2795,7 +2794,6 @@ func testHostsTotalAndUnseenSince(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, 2, total)
|
||||
require.Len(t, unseen, 1)
|
||||
assert.Equal(t, host3.ID, unseen[0])
|
||||
|
||||
}
|
||||
|
||||
func testHostsListByPolicy(t *testing.T, ds *Datastore) {
|
||||
@ -6577,6 +6575,23 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
||||
`, host.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a calendar event for the host.
|
||||
_, err = ds.writer(context.Background()).Exec(`
|
||||
INSERT INTO calendar_events (email, start_time, end_time, event)
|
||||
VALUES ('foobar@example.com', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, '{}');
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
var calendarEventID int
|
||||
err = ds.writer(context.Background()).Get(&calendarEventID, `
|
||||
SELECT id FROM calendar_events WHERE email = 'foobar@example.com';
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
_, err = ds.writer(context.Background()).Exec(`
|
||||
INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status)
|
||||
VALUES (?, ?, 1);
|
||||
`, host.ID, calendarEventID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check there's an entry for the host in all the associated tables.
|
||||
for _, hostRef := range hostRefs {
|
||||
var ok bool
|
||||
|
@ -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
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20240314085226(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
applyNext(t, db)
|
||||
|
||||
sampleEvent := fleet.CalendarEvent{
|
||||
Email: "foo@example.com",
|
||||
StartTime: time.Now().UTC(),
|
||||
EndTime: time.Now().UTC().Add(30 * time.Minute),
|
||||
Data: []byte("{\"foo\": \"bar\"}"),
|
||||
}
|
||||
sampleEvent.ID = uint(execNoErrLastID(t, db,
|
||||
`INSERT INTO calendar_events (email, start_time, end_time, event) VALUES (?, ?, ?, ?);`,
|
||||
sampleEvent.Email, sampleEvent.StartTime, sampleEvent.EndTime, sampleEvent.Data,
|
||||
))
|
||||
|
||||
sampleHostEvent := fleet.HostCalendarEvent{
|
||||
HostID: 1,
|
||||
CalendarEventID: sampleEvent.ID,
|
||||
WebhookStatus: fleet.CalendarWebhookStatusPending,
|
||||
}
|
||||
sampleHostEvent.ID = uint(execNoErrLastID(t, db,
|
||||
`INSERT INTO host_calendar_events (host_id, calendar_event_id, webhook_status) VALUES (?, ?, ?);`,
|
||||
sampleHostEvent.HostID, sampleHostEvent.CalendarEventID, sampleHostEvent.WebhookStatus,
|
||||
))
|
||||
|
||||
var event fleet.CalendarEvent
|
||||
err := db.Get(&event, `SELECT * FROM calendar_events WHERE id = ?;`, sampleEvent.ID)
|
||||
require.NoError(t, err)
|
||||
sampleEvent.CreatedAt = event.CreatedAt // sampleEvent doesn't have this set.
|
||||
sampleEvent.UpdatedAt = event.UpdatedAt // sampleEvent doesn't have this set.
|
||||
sampleEvent.StartTime = sampleEvent.StartTime.Round(time.Second)
|
||||
sampleEvent.EndTime = sampleEvent.EndTime.Round(time.Second)
|
||||
event.StartTime = event.StartTime.Round(time.Second)
|
||||
event.EndTime = event.EndTime.Round(time.Second)
|
||||
require.Equal(t, sampleEvent, event)
|
||||
|
||||
var hostEvent fleet.HostCalendarEvent
|
||||
err = db.Get(&hostEvent, `SELECT * FROM host_calendar_events WHERE id = ?;`, sampleHostEvent.ID)
|
||||
require.NoError(t, err)
|
||||
sampleHostEvent.CreatedAt = hostEvent.CreatedAt // sampleHostEvent doesn't have this set.
|
||||
sampleHostEvent.UpdatedAt = hostEvent.UpdatedAt // sampleHostEvent doesn't have this set.
|
||||
require.Equal(t, sampleHostEvent, hostEvent)
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20240314151747, Down_20240314151747)
|
||||
}
|
||||
|
||||
func Up_20240314151747(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`ALTER TABLE policies ADD COLUMN calendar_events_enabled TINYINT(1) UNSIGNED NOT NULL DEFAULT '0'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add calendar_events_enabled to policies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20240314151747(_ *sql.Tx) error {
|
||||
return nil
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUp_20240314151747(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
policy1 := execNoErrLastID(
|
||||
t, db, "INSERT INTO policies (name, query, description, checksum) VALUES (?,?,?,?)", "policy", "", "", "checksum",
|
||||
)
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
var policyCheck []struct {
|
||||
ID int64 `db:"id"`
|
||||
CalEnabled bool `db:"calendar_events_enabled"`
|
||||
}
|
||||
err := db.SelectContext(context.Background(), &policyCheck, `SELECT id, calendar_events_enabled FROM policies ORDER BY id`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policyCheck, 1)
|
||||
assert.Equal(t, policy1, policyCheck[0].ID)
|
||||
assert.Equal(t, false, policyCheck[0].CalEnabled)
|
||||
|
||||
policy2 := execNoErrLastID(
|
||||
t, db, "INSERT INTO policies (name, query, description, checksum, calendar_events_enabled) VALUES (?,?,?,?,?)", "policy2", "", "",
|
||||
"checksum2", 1,
|
||||
)
|
||||
|
||||
policyCheck = nil
|
||||
err = db.SelectContext(context.Background(), &policyCheck, `SELECT id, calendar_events_enabled FROM policies WHERE id = ?`, policy2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, policyCheck, 1)
|
||||
assert.Equal(t, policy2, policyCheck[0].ID)
|
||||
assert.Equal(t, true, policyCheck[0].CalEnabled)
|
||||
|
||||
}
|
@ -5,11 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"github.com/doug-martin/goqu/v9"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
@ -19,7 +20,7 @@ import (
|
||||
|
||||
const policyCols = `
|
||||
p.id, p.team_id, p.resolution, p.name, p.query, p.description,
|
||||
p.author_id, p.platforms, p.created_at, p.updated_at, p.critical
|
||||
p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled
|
||||
`
|
||||
|
||||
var policySearchColumns = []string{"p.name"}
|
||||
@ -115,10 +116,12 @@ func (ds *Datastore) SavePolicy(ctx context.Context, p *fleet.Policy, shouldRemo
|
||||
p.Name = norm.NFC.String(p.Name)
|
||||
sql := `
|
||||
UPDATE policies
|
||||
SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, checksum = ` + policiesChecksumComputedColumn() + `
|
||||
SET name = ?, query = ?, description = ?, resolution = ?, platforms = ?, critical = ?, calendar_events_enabled = ?, checksum = ` + policiesChecksumComputedColumn() + `
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := ds.writer(ctx).ExecContext(ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.ID)
|
||||
result, err := ds.writer(ctx).ExecContext(
|
||||
ctx, sql, p.Name, p.Query, p.Description, p.Resolution, p.Platform, p.Critical, p.CalendarEventsEnabled, p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating policy")
|
||||
}
|
||||
@ -525,10 +528,11 @@ func (ds *Datastore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *u
|
||||
nameUnicode := norm.NFC.String(args.Name)
|
||||
res, err := ds.writer(ctx).ExecContext(ctx,
|
||||
fmt.Sprintf(
|
||||
`INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, %s)`,
|
||||
`INSERT INTO policies (name, query, description, team_id, resolution, author_id, platforms, critical, calendar_events_enabled, checksum) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s)`,
|
||||
policiesChecksumComputedColumn(),
|
||||
),
|
||||
nameUnicode, args.Query, args.Description, teamID, args.Resolution, authorID, args.Platform, args.Critical,
|
||||
args.CalendarEventsEnabled,
|
||||
)
|
||||
switch {
|
||||
case err == nil:
|
||||
@ -586,15 +590,17 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
team_id,
|
||||
platforms,
|
||||
critical,
|
||||
calendar_events_enabled,
|
||||
checksum
|
||||
) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, %s)
|
||||
) VALUES ( ?, ?, ?, ?, ?, (SELECT IFNULL(MIN(id), NULL) FROM teams WHERE name = ?), ?, ?, ?, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
query = VALUES(query),
|
||||
description = VALUES(description),
|
||||
author_id = VALUES(author_id),
|
||||
resolution = VALUES(resolution),
|
||||
platforms = VALUES(platforms),
|
||||
critical = VALUES(critical)
|
||||
critical = VALUES(critical),
|
||||
calendar_events_enabled = VALUES(calendar_events_enabled)
|
||||
`, policiesChecksumComputedColumn(),
|
||||
)
|
||||
for _, spec := range specs {
|
||||
@ -603,6 +609,7 @@ func (ds *Datastore) ApplyPolicySpecs(ctx context.Context, authorID uint, specs
|
||||
spec.Name = norm.NFC.String(spec.Name)
|
||||
res, err := tx.ExecContext(ctx,
|
||||
query, spec.Name, spec.Query, spec.Description, authorID, spec.Resolution, spec.Team, spec.Platform, spec.Critical,
|
||||
spec.CalendarEventsEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "exec ApplyPolicySpecs insert")
|
||||
@ -1153,3 +1160,56 @@ func (ds *Datastore) UpdateHostPolicyCounts(ctx context.Context) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetCalendarPolicies(ctx context.Context, teamID uint) ([]fleet.PolicyCalendarData, error) {
|
||||
query := `SELECT id, name FROM policies WHERE team_id = ? AND calendar_events_enabled;`
|
||||
var policies []fleet.PolicyCalendarData
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &policies, query, teamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get calendar policies")
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// TODO(lucas): Must be tested at scale.
|
||||
func (ds *Datastore) GetTeamHostsPolicyMemberships(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
teamID uint,
|
||||
policyIDs []uint,
|
||||
) ([]fleet.HostPolicyMembershipData, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(sh.email, '') AS email,
|
||||
pm.passing AS passing,
|
||||
h.id AS host_id,
|
||||
hdn.display_name AS host_display_name,
|
||||
h.hardware_serial AS host_hardware_serial
|
||||
FROM (
|
||||
SELECT host_id, BIT_AND(COALESCE(passes, 0)) AS passing
|
||||
FROM policy_membership
|
||||
WHERE policy_id IN (?)
|
||||
GROUP BY host_id
|
||||
) pm
|
||||
LEFT JOIN (
|
||||
SELECT host_id, MIN(email) AS email
|
||||
FROM host_emails
|
||||
JOIN hosts ON host_emails.host_id=hosts.id
|
||||
WHERE email LIKE CONCAT('%@', ?) AND team_id = ?
|
||||
GROUP BY host_id
|
||||
) sh ON sh.host_id = pm.host_id
|
||||
JOIN hosts h ON h.id = pm.host_id
|
||||
LEFT JOIN host_display_names hdn ON hdn.host_id = pm.host_id;
|
||||
`
|
||||
|
||||
query, args, err := sqlx.In(query, policyIDs, domain, teamID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "build select get team hosts policy memberships query")
|
||||
}
|
||||
var hosts []fleet.HostPolicyMembershipData
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hosts, query, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "listing policies")
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
@ -59,6 +59,8 @@ func TestPolicies(t *testing.T) {
|
||||
{"TestPoliciesNameUnicode", testPoliciesNameUnicode},
|
||||
{"TestPoliciesNameEmoji", testPoliciesNameEmoji},
|
||||
{"TestPoliciesNameSort", testPoliciesNameSort},
|
||||
{"TestGetCalendarPolicies", testGetCalendarPolicies},
|
||||
{"GetTeamHostsPolicyMemberships", testGetTeamHostsPolicyMemberships},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@ -582,10 +584,11 @@ func testTeamPolicyProprietary(t *testing.T, ds *Datastore) {
|
||||
require.Error(t, err)
|
||||
|
||||
p, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, fleet.PolicyPayload{
|
||||
Name: "query1",
|
||||
Query: "select 1;",
|
||||
Description: "query1 desc",
|
||||
Resolution: "query1 resolution",
|
||||
Name: "query1",
|
||||
Query: "select 1;",
|
||||
Description: "query1 desc",
|
||||
Resolution: "query1 resolution",
|
||||
CalendarEventsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@ -615,6 +618,7 @@ func testTeamPolicyProprietary(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, "query1 resolution", *p.Resolution)
|
||||
require.NotNil(t, p.AuthorID)
|
||||
assert.Equal(t, user1.ID, *p.AuthorID)
|
||||
assert.True(t, p.CalendarEventsEnabled)
|
||||
|
||||
globalPolicies, err := ds.ListGlobalPolicies(ctx, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
@ -1244,12 +1248,13 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
Platform: "",
|
||||
},
|
||||
{
|
||||
Name: "query2",
|
||||
Query: "select 2;",
|
||||
Description: "query2 desc",
|
||||
Resolution: "some other resolution",
|
||||
Team: "team1",
|
||||
Platform: "darwin",
|
||||
Name: "query2",
|
||||
Query: "select 2;",
|
||||
Description: "query2 desc",
|
||||
Resolution: "some other resolution",
|
||||
Team: "team1",
|
||||
Platform: "darwin",
|
||||
CalendarEventsEnabled: true,
|
||||
},
|
||||
{
|
||||
Name: "query3",
|
||||
@ -1284,6 +1289,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
require.NotNil(t, teamPolicies[0].Resolution)
|
||||
assert.Equal(t, "some other resolution", *teamPolicies[0].Resolution)
|
||||
assert.Equal(t, "darwin", teamPolicies[0].Platform)
|
||||
assert.True(t, teamPolicies[0].CalendarEventsEnabled)
|
||||
|
||||
assert.Equal(t, "query3", teamPolicies[1].Name)
|
||||
assert.Equal(t, "select 3;", teamPolicies[1].Query)
|
||||
@ -1293,6 +1299,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
require.NotNil(t, teamPolicies[1].Resolution)
|
||||
assert.Equal(t, "some other good resolution", *teamPolicies[1].Resolution)
|
||||
assert.Equal(t, "windows,linux", teamPolicies[1].Platform)
|
||||
assert.False(t, teamPolicies[1].CalendarEventsEnabled)
|
||||
|
||||
// Make sure apply is idempotent
|
||||
require.NoError(t, ds.ApplyPolicySpecs(ctx, user1.ID, []*fleet.PolicySpec{
|
||||
@ -1305,12 +1312,13 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
Platform: "",
|
||||
},
|
||||
{
|
||||
Name: "query2",
|
||||
Query: "select 2;",
|
||||
Description: "query2 desc",
|
||||
Resolution: "some other resolution",
|
||||
Team: "team1",
|
||||
Platform: "darwin",
|
||||
Name: "query2",
|
||||
Query: "select 2;",
|
||||
Description: "query2 desc",
|
||||
Resolution: "some other resolution",
|
||||
Team: "team1",
|
||||
Platform: "darwin",
|
||||
CalendarEventsEnabled: true,
|
||||
},
|
||||
{
|
||||
Name: "query3",
|
||||
@ -1340,12 +1348,13 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
Platform: "",
|
||||
},
|
||||
{
|
||||
Name: "query2",
|
||||
Query: "select 2 from updated;",
|
||||
Description: "query2 desc updated",
|
||||
Resolution: "some other resolution updated",
|
||||
Team: "team1", // No error, team did not change
|
||||
Platform: "windows",
|
||||
Name: "query2",
|
||||
Query: "select 2 from updated;",
|
||||
Description: "query2 desc updated",
|
||||
Resolution: "some other resolution updated",
|
||||
Team: "team1", // No error, team did not change
|
||||
Platform: "windows",
|
||||
CalendarEventsEnabled: false,
|
||||
},
|
||||
}))
|
||||
policies, err = ds.ListGlobalPolicies(ctx, fleet.ListOptions{})
|
||||
@ -1360,6 +1369,7 @@ func testApplyPolicySpec(t *testing.T, ds *Datastore) {
|
||||
require.NotNil(t, policies[0].Resolution)
|
||||
assert.Equal(t, "some resolution updated", *policies[0].Resolution)
|
||||
assert.Equal(t, "", policies[0].Platform)
|
||||
assert.False(t, policies[0].CalendarEventsEnabled)
|
||||
|
||||
teamPolicies, _, err = ds.ListTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, fleet.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
@ -1439,11 +1449,12 @@ func testPoliciesSave(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, computeChecksum(*gp), hex.EncodeToString(globalChecksum))
|
||||
|
||||
payload = fleet.PolicyPayload{
|
||||
Name: "team1 query",
|
||||
Query: "select 2;",
|
||||
Description: "team1 query desc",
|
||||
Resolution: "team1 query resolution",
|
||||
Critical: true,
|
||||
Name: "team1 query",
|
||||
Query: "select 2;",
|
||||
Description: "team1 query desc",
|
||||
Resolution: "team1 query resolution",
|
||||
Critical: true,
|
||||
CalendarEventsEnabled: true,
|
||||
}
|
||||
tp1, err := ds.NewTeamPolicy(ctx, team1.ID, &user1.ID, payload)
|
||||
require.NoError(t, err)
|
||||
@ -1452,6 +1463,7 @@ func testPoliciesSave(t *testing.T, ds *Datastore) {
|
||||
require.Equal(t, tp1.Description, payload.Description)
|
||||
require.Equal(t, *tp1.Resolution, payload.Resolution)
|
||||
require.Equal(t, tp1.Critical, payload.Critical)
|
||||
assert.Equal(t, tp1.CalendarEventsEnabled, payload.CalendarEventsEnabled)
|
||||
var teamChecksum []uint8
|
||||
err = ds.writer(context.Background()).Get(&teamChecksum, `SELECT checksum FROM policies WHERE id = ?`, tp1.ID)
|
||||
require.NoError(t, err)
|
||||
@ -1480,6 +1492,7 @@ func testPoliciesSave(t *testing.T, ds *Datastore) {
|
||||
tp2.Description = "team1 query desc updated"
|
||||
tp2.Resolution = ptr.String("team1 query resolution updated")
|
||||
tp2.Critical = false
|
||||
tp2.CalendarEventsEnabled = false
|
||||
err = ds.SavePolicy(ctx, &tp2, true)
|
||||
require.NoError(t, err)
|
||||
tp1, err = ds.Policy(ctx, tp1.ID)
|
||||
@ -2773,7 +2786,6 @@ func testPoliciesNameEmoji(t *testing.T, ds *Datastore) {
|
||||
assert.NoError(t, err)
|
||||
require.Len(t, policies, 1)
|
||||
assert.Equal(t, emoji1, policies[0].Name)
|
||||
|
||||
}
|
||||
|
||||
// Ensure case-insensitive sort order for policy names
|
||||
@ -2795,3 +2807,256 @@ func testPoliciesNameSort(t *testing.T, ds *Datastore) {
|
||||
assert.Equal(t, policy.Name, policiesResult[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func testGetCalendarPolicies(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test with non-existent team.
|
||||
_, err := ds.GetCalendarPolicies(ctx, 999)
|
||||
require.NoError(t, err)
|
||||
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{
|
||||
Name: "Foobar",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test when the team has no policies.
|
||||
_, err = ds.GetCalendarPolicies(ctx, team.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a global query to test that only team policies are returned.
|
||||
_, err = ds.NewGlobalPolicy(ctx, nil, fleet.PolicyPayload{
|
||||
Name: "Global Policy",
|
||||
Query: "SELECT * FROM time;",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team Policy 1",
|
||||
Query: "SELECT * FROM system_info;",
|
||||
CalendarEventsEnabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test when the team has policies, but none is configured for calendar.
|
||||
_, err = ds.GetCalendarPolicies(ctx, team.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
teamPolicy2, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team Policy 2",
|
||||
Query: "SELECT * FROM osquery_info;",
|
||||
CalendarEventsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
teamPolicy3, err := ds.NewTeamPolicy(ctx, team.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team Policy 3",
|
||||
Query: "SELECT * FROM os_version;",
|
||||
CalendarEventsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
calendarPolicies, err := ds.GetCalendarPolicies(ctx, team.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, calendarPolicies, 2)
|
||||
require.Equal(t, calendarPolicies[0].ID, teamPolicy2.ID)
|
||||
require.Equal(t, calendarPolicies[1].ID, teamPolicy3.ID)
|
||||
}
|
||||
|
||||
func testGetTeamHostsPolicyMemberships(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
|
||||
require.NoError(t, err)
|
||||
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
|
||||
require.NoError(t, err)
|
||||
|
||||
team1Policy1, err := ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team 1 Policy 1",
|
||||
Query: "SELECT * FROM osquery_info;",
|
||||
CalendarEventsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
team1Policy2, err := ds.NewTeamPolicy(ctx, team1.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team 1 Policy 2",
|
||||
Query: "SELECT * FROM system_info;",
|
||||
CalendarEventsEnabled: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
team2Policy1, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team 2 Policy 1",
|
||||
Query: "SELECT * FROM os_version;",
|
||||
CalendarEventsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
team2Policy2, err := ds.NewTeamPolicy(ctx, team2.ID, nil, fleet.PolicyPayload{
|
||||
Name: "Team 2 Policy 2",
|
||||
Query: "SELECT * FROM processes;",
|
||||
CalendarEventsEnabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Empty teams.
|
||||
hostsTeam1, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hostsTeam1, 0)
|
||||
|
||||
host1, err := ds.NewHost(ctx, &fleet.Host{
|
||||
OsqueryHostID: ptr.String("host1"),
|
||||
NodeKey: ptr.String("host1"),
|
||||
HardwareSerial: "serial1",
|
||||
ComputerName: "display_name1",
|
||||
TeamID: &team1.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
host2, err := ds.NewHost(ctx, &fleet.Host{
|
||||
OsqueryHostID: ptr.String("host2"),
|
||||
NodeKey: ptr.String("host2"),
|
||||
HardwareSerial: "serial2",
|
||||
ComputerName: "display_name2",
|
||||
TeamID: &team2.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
host3, err := ds.NewHost(ctx, &fleet.Host{
|
||||
OsqueryHostID: ptr.String("host3"),
|
||||
NodeKey: ptr.String("host3"),
|
||||
HardwareSerial: "serial3",
|
||||
ComputerName: "display_name3",
|
||||
TeamID: &team2.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
host4, err := ds.NewHost(ctx, &fleet.Host{
|
||||
OsqueryHostID: ptr.String("host4"),
|
||||
NodeKey: ptr.String("host4"),
|
||||
HardwareSerial: "serial4",
|
||||
ComputerName: "display_name4",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
host5, err := ds.NewHost(ctx, &fleet.Host{
|
||||
OsqueryHostID: ptr.String("host5"),
|
||||
NodeKey: ptr.String("host5"),
|
||||
HardwareSerial: "serial5",
|
||||
ComputerName: "display_name5",
|
||||
TeamID: &team1.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// No policy results yet.
|
||||
hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policy1.ID, team1Policy2.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hostsTeam1, 0)
|
||||
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host1.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host1.ID, Email: "foo@example.com", Source: "google_chrome_profiles"},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host1.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host1.ID, Email: "zoo@example.com", Source: "custom"},
|
||||
}, "custom")
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host2.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host2.ID, Email: "foo@example.com", Source: "custom"},
|
||||
}, "custom")
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host2.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host2.ID, Email: "foo@other.com", Source: "google_chrome_profiles"},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host3.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host3.ID, Email: "zoo@example.com", Source: "google_chrome_profiles"},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host4.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host4.ID, Email: "foo@example.com", Source: "google_chrome_profiles"},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
err = ds.ReplaceHostDeviceMapping(ctx, host5.ID, []*fleet.HostDeviceMapping{
|
||||
{HostID: host5.ID, Email: "foo@other.com", Source: "google_chrome_profiles"},
|
||||
}, "google_chrome_profiles")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.RecordPolicyQueryExecutions(ctx, host1, map[uint]*bool{
|
||||
team1Policy1.ID: ptr.Bool(true),
|
||||
team1Policy2.ID: ptr.Bool(false),
|
||||
}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.RecordPolicyQueryExecutions(ctx, host2, map[uint]*bool{
|
||||
team2Policy1.ID: ptr.Bool(false),
|
||||
team2Policy2.ID: ptr.Bool(true),
|
||||
}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.RecordPolicyQueryExecutions(ctx, host3, map[uint]*bool{
|
||||
team2Policy1.ID: ptr.Bool(true),
|
||||
team2Policy2.ID: ptr.Bool(true),
|
||||
}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = ds.RecordPolicyQueryExecutions(ctx, host5, map[uint]*bool{
|
||||
team1Policy1.ID: ptr.Bool(false),
|
||||
team1Policy2.ID: ptr.Bool(false),
|
||||
}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
team1Policies, err := ds.GetCalendarPolicies(ctx, team1.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, team1Policies, 1)
|
||||
team2Policies, err := ds.GetCalendarPolicies(ctx, team2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, team2Policies, 2)
|
||||
|
||||
hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hostsTeam1, 2)
|
||||
require.Equal(t, host1.ID, hostsTeam1[0].HostID)
|
||||
require.Equal(t, "foo@example.com", hostsTeam1[0].Email)
|
||||
require.True(t, hostsTeam1[0].Passing)
|
||||
require.Equal(t, "serial1", hostsTeam1[0].HostHardwareSerial)
|
||||
require.Equal(t, "display_name1", hostsTeam1[0].HostDisplayName)
|
||||
require.Equal(t, host5.ID, hostsTeam1[1].HostID)
|
||||
require.Empty(t, hostsTeam1[1].Email)
|
||||
require.False(t, hostsTeam1[1].Passing)
|
||||
require.Equal(t, "serial5", hostsTeam1[1].HostHardwareSerial)
|
||||
require.Equal(t, "display_name5", hostsTeam1[1].HostDisplayName)
|
||||
|
||||
err = ds.AddHostsToTeam(ctx, &team1.ID, []uint{host4.ID})
|
||||
require.NoError(t, err)
|
||||
err = ds.RecordPolicyQueryExecutions(ctx, host4, map[uint]*bool{
|
||||
team1Policy1.ID: ptr.Bool(false),
|
||||
team1Policy2.ID: ptr.Bool(false),
|
||||
}, time.Now(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
hostsTeam1, err = ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team1.ID, []uint{team1Policies[0].ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hostsTeam1, 3)
|
||||
require.Equal(t, host1.ID, hostsTeam1[0].HostID)
|
||||
require.Equal(t, "foo@example.com", hostsTeam1[0].Email)
|
||||
require.True(t, hostsTeam1[0].Passing)
|
||||
require.Equal(t, "serial1", hostsTeam1[0].HostHardwareSerial)
|
||||
require.Equal(t, "display_name1", hostsTeam1[0].HostDisplayName)
|
||||
require.Equal(t, host4.ID, hostsTeam1[1].HostID)
|
||||
require.Equal(t, "foo@example.com", hostsTeam1[1].Email)
|
||||
require.False(t, hostsTeam1[1].Passing)
|
||||
require.Equal(t, "serial4", hostsTeam1[1].HostHardwareSerial)
|
||||
require.Equal(t, "display_name4", hostsTeam1[1].HostDisplayName)
|
||||
require.Equal(t, host5.ID, hostsTeam1[2].HostID)
|
||||
require.Empty(t, hostsTeam1[2].Email)
|
||||
require.False(t, hostsTeam1[2].Passing)
|
||||
require.Equal(t, "serial5", hostsTeam1[2].HostHardwareSerial)
|
||||
require.Equal(t, "display_name5", hostsTeam1[2].HostDisplayName)
|
||||
|
||||
hostsTeam2, err := ds.GetTeamHostsPolicyMemberships(ctx, "example.com", team2.ID, []uint{team2Policies[0].ID, team2Policies[1].ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hostsTeam2, 2)
|
||||
require.Equal(t, host2.ID, hostsTeam2[0].HostID)
|
||||
require.Equal(t, "foo@example.com", hostsTeam2[0].Email)
|
||||
require.False(t, hostsTeam2[0].Passing)
|
||||
require.Equal(t, "serial2", hostsTeam2[0].HostHardwareSerial)
|
||||
require.Equal(t, "display_name2", hostsTeam2[0].HostDisplayName)
|
||||
require.Equal(t, host3.ID, hostsTeam2[1].HostID)
|
||||
require.Equal(t, "zoo@example.com", hostsTeam2[1].Email)
|
||||
require.True(t, hostsTeam2[1].Passing)
|
||||
require.Equal(t, "serial3", hostsTeam2[1].HostHardwareSerial)
|
||||
require.Equal(t, "display_name3", hostsTeam2[1].HostDisplayName)
|
||||
}
|
||||
|
File diff suppressed because one or more lines are too long
@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@ -568,6 +569,15 @@ func (c *AppConfig) Copy() *AppConfig {
|
||||
clone.Integrations.Zendesk[i] = &zd
|
||||
}
|
||||
}
|
||||
if len(c.Integrations.GoogleCalendar) > 0 {
|
||||
clone.Integrations.GoogleCalendar = make([]*GoogleCalendarIntegration, len(c.Integrations.GoogleCalendar))
|
||||
for i, g := range c.Integrations.GoogleCalendar {
|
||||
gCal := *g
|
||||
clone.Integrations.GoogleCalendar[i] = &gCal
|
||||
clone.Integrations.GoogleCalendar[i].ApiKey = make(map[string]string, len(g.ApiKey))
|
||||
maps.Copy(clone.Integrations.GoogleCalendar[i].ApiKey, g.ApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
if c.MDM.MacOSSettings.CustomSettings != nil {
|
||||
clone.MDM.MacOSSettings.CustomSettings = make([]MDMProfileSpec, len(c.MDM.MacOSSettings.CustomSettings))
|
||||
|
62
server/fleet/calendar.go
Normal file
62
server/fleet/calendar.go
Normal file
@ -0,0 +1,62 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
_ "time/tzdata" // embed timezone information in the program
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
)
|
||||
|
||||
type DayEndedError struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e DayEndedError) Error() string {
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
type UserCalendar interface {
|
||||
// Configure configures the connection to a user's calendar. Once configured,
|
||||
// CreateEvent, GetAndUpdateEvent and DeleteEvent reference the user's calendar.
|
||||
Configure(userEmail string) error
|
||||
// CreateEvent creates a new event on the calendar on the given date. DayEndedError is returned if there is no time left on the given date to schedule event.
|
||||
CreateEvent(dateOfEvent time.Time, genBodyFn func(conflict bool) string) (event *CalendarEvent, err error)
|
||||
// GetAndUpdateEvent retrieves the event from the calendar.
|
||||
// If the event has been modified, it returns the updated event.
|
||||
// If the event has been deleted, it schedules a new event with given body callback and returns the new event.
|
||||
GetAndUpdateEvent(event *CalendarEvent, genBodyFn func(conflict bool) string) (updatedEvent *CalendarEvent, updated bool, err error)
|
||||
// DeleteEvent deletes the event with the given ID.
|
||||
DeleteEvent(event *CalendarEvent) error
|
||||
}
|
||||
|
||||
type CalendarWebhookPayload struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
HostID uint `json:"host_id"`
|
||||
HostDisplayName string `json:"host_display_name"`
|
||||
HostSerialNumber string `json:"host_serial_number"`
|
||||
FailingPolicies []PolicyCalendarData `json:"failing_policies,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func FireCalendarWebhook(
|
||||
webhookURL string,
|
||||
hostID uint,
|
||||
hostHardwareSerial string,
|
||||
hostDisplayName string,
|
||||
failingCalendarPolicies []PolicyCalendarData,
|
||||
err string,
|
||||
) error {
|
||||
if err := server.PostJSONWithTimeout(context.Background(), webhookURL, &CalendarWebhookPayload{
|
||||
Timestamp: time.Now(),
|
||||
HostID: hostID,
|
||||
HostDisplayName: hostDisplayName,
|
||||
HostSerialNumber: hostHardwareSerial,
|
||||
FailingPolicies: failingCalendarPolicies,
|
||||
Error: err,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("POST to %q: %w", server.MaskSecretURLParams(webhookURL), server.MaskURLError(err))
|
||||
}
|
||||
return nil
|
||||
}
|
39
server/fleet/calendar_events.go
Normal file
39
server/fleet/calendar_events.go
Normal file
@ -0,0 +1,39 @@
|
||||
package fleet
|
||||
|
||||
import "time"
|
||||
|
||||
type CalendarEvent struct {
|
||||
ID uint `db:"id"`
|
||||
Email string `db:"email"`
|
||||
StartTime time.Time `db:"start_time"`
|
||||
EndTime time.Time `db:"end_time"`
|
||||
Data []byte `db:"event"`
|
||||
|
||||
UpdateCreateTimestamps
|
||||
}
|
||||
|
||||
type CalendarWebhookStatus int
|
||||
|
||||
const (
|
||||
CalendarWebhookStatusNone CalendarWebhookStatus = iota
|
||||
CalendarWebhookStatusPending
|
||||
CalendarWebhookStatusSent
|
||||
)
|
||||
|
||||
type HostCalendarEvent struct {
|
||||
ID uint `db:"id"`
|
||||
HostID uint `db:"host_id"`
|
||||
CalendarEventID uint `db:"calendar_event_id"`
|
||||
WebhookStatus CalendarWebhookStatus `db:"webhook_status"`
|
||||
|
||||
UpdateCreateTimestamps
|
||||
}
|
||||
|
||||
type HostPolicyMembershipData struct {
|
||||
Email string `db:"email"`
|
||||
Passing bool `db:"passing"`
|
||||
|
||||
HostID uint `db:"host_id"`
|
||||
HostDisplayName string `db:"host_display_name"`
|
||||
HostHardwareSerial string `db:"host_hardware_serial"`
|
||||
}
|
@ -21,6 +21,7 @@ const (
|
||||
CronWorkerIntegrations CronScheduleName = "integrations"
|
||||
CronActivitiesStreaming CronScheduleName = "activities_streaming"
|
||||
CronMDMAppleProfileManager CronScheduleName = "mdm_apple_profile_manager"
|
||||
CronCalendar CronScheduleName = "calendar"
|
||||
)
|
||||
|
||||
type CronSchedulesService interface {
|
||||
|
@ -594,6 +594,9 @@ type Datastore interface {
|
||||
|
||||
PolicyQueriesForHost(ctx context.Context, host *Host) (map[string]string, error)
|
||||
|
||||
GetTeamHostsPolicyMemberships(ctx context.Context, domain string, teamID uint, policyIDs []uint) ([]HostPolicyMembershipData, error)
|
||||
GetCalendarPolicies(ctx context.Context, teamID uint) ([]PolicyCalendarData, error)
|
||||
|
||||
// Methods used for async processing of host policy query results.
|
||||
AsyncBatchInsertPolicyMembership(ctx context.Context, batch []PolicyMembershipResult) error
|
||||
AsyncBatchUpdatePolicyTimestamp(ctx context.Context, ids []uint, ts time.Time) error
|
||||
@ -613,6 +616,19 @@ type Datastore interface {
|
||||
// the updated_at timestamp is older than the provided duration
|
||||
DeleteOutOfDateVulnerabilities(ctx context.Context, source VulnerabilitySource, duration time.Duration) error
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Calendar events
|
||||
|
||||
CreateOrUpdateCalendarEvent(ctx context.Context, email string, startTime time.Time, endTime time.Time, data []byte, hostID uint, webhookStatus CalendarWebhookStatus) (*CalendarEvent, error)
|
||||
GetCalendarEvent(ctx context.Context, email string) (*CalendarEvent, error)
|
||||
DeleteCalendarEvent(ctx context.Context, calendarEventID uint) error
|
||||
UpdateCalendarEvent(ctx context.Context, calendarEventID uint, startTime time.Time, endTime time.Time, data []byte) error
|
||||
GetHostCalendarEvent(ctx context.Context, hostID uint) (*HostCalendarEvent, *CalendarEvent, error)
|
||||
GetHostCalendarEventByEmail(ctx context.Context, email string) (*HostCalendarEvent, *CalendarEvent, error)
|
||||
UpdateHostCalendarWebhookStatus(ctx context.Context, hostID uint, status CalendarWebhookStatus) error
|
||||
ListCalendarEvents(ctx context.Context, teamID *uint) ([]*CalendarEvent, error)
|
||||
ListOutOfDateCalendarEvents(ctx context.Context, t time.Time) ([]*CalendarEvent, error)
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Team Policies
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user