diff --git a/api/types/constants.go b/api/types/constants.go index b2f9dbefad063..5343aa6a834e5 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -725,10 +725,10 @@ const ( // DiscoveryAppIgnore specifies if a Kubernetes service should be ignored by discovery service. DiscoveryAppIgnore = TeleportNamespace + "/ignore" - // ReqAnnotationSchedulesLabel is the request annotation key at which schedules are stored for access plugins. - ReqAnnotationSchedulesLabel = "/schedules" - // ReqAnnotationNotifyServicesLabel is the request annotation key at which notify services are stored for access plugins. - ReqAnnotationNotifyServicesLabel = "/notify-services" + // ReqAnnotationApproveSchedulesLabel is the request annotation key at which schedules are stored for access plugins. + ReqAnnotationApproveSchedulesLabel = "/schedules" + // ReqAnnotationNotifySchedulesLabel is the request annotation key at which notify schedules are stored for access plugins. + ReqAnnotationNotifySchedulesLabel = "/notify-services" // CloudAWS identifies that a resource was discovered in AWS. CloudAWS = "AWS" diff --git a/docs/img/enterprise/plugins/opsgenie/add-requester-role.png b/docs/img/enterprise/plugins/opsgenie/add-requester-role.png deleted file mode 100644 index 4d7087ec04b75..0000000000000 Binary files a/docs/img/enterprise/plugins/opsgenie/add-requester-role.png and /dev/null differ diff --git a/docs/pages/access-controls/access-request-plugins/opsgenie.mdx b/docs/pages/access-controls/access-request-plugins/opsgenie.mdx index 773dfa71ec547..3c729c19ecef3 100644 --- a/docs/pages/access-controls/access-request-plugins/opsgenie.mdx +++ b/docs/pages/access-controls/access-request-plugins/opsgenie.mdx @@ -60,8 +60,6 @@ To create a user first navigate to Management -> Access -> Roles Then select 'Create New Role' and create the requester role. -![Add user one](../../../img/enterprise/plugins/opsgenie/add-requester-role.png) - ``` kind: role version: v5 @@ -75,10 +73,13 @@ spec: - approve: 1 deny: 1 annotations: - teleport.dev/schedules: ['teleport-access-request-notifications'] + teleport.dev/notify-services: ['teleport-access-request-notifications'] + teleport.dev/schedules: ['teleport-access-alert-schedules'] ``` -The `teleport.dev/schedules` annotation specifies the schedule the alert will be be created for. +The `teleport.dev/notify-services` annotation specifies the schedules the alert will be created for. +The `teleport.dev/schedules` annotation specifies the schedules the alert will check, and auto approve the +Access Request if the requesting user is on-call. ### Create a user who will request access @@ -121,7 +122,7 @@ As the Teleport user `myuser`, create an Access Request for the `editor` role: In Opsgenie, you will see a new alert containing information about the Access Request in either the default schedule specified when enrolling the plugin, -or in the schedules specified by `teleport.dev/schedules` annotation in the requester's role. +or in the schedules specified by `teleport.dev/notify-services` annotation in the requester's role. ### Resolve the request diff --git a/integrations/access/accessrequest/app.go b/integrations/access/accessrequest/app.go index 94e3ea172c6c3..6dfd78470c460 100644 --- a/integrations/access/accessrequest/app.go +++ b/integrations/access/accessrequest/app.go @@ -352,16 +352,23 @@ func (a *App) getMessageRecipients(ctx context.Context, req types.AccessRequest) recipientSet.Add(common.Recipient{}) return recipientSet.ToSlice() case types.PluginTypeOpsgenie: - if recipients, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationSchedulesLabel]; ok { - for _, recipient := range recipients { - rec, err := a.bot.FetchRecipient(ctx, recipient) - if err != nil { - log.Warning(err) - } - recipientSet.Add(*rec) + // When both notify-services and approve-schedules are present, each is used for their own intended purpose. + recipients := make([]string, 0) + if approveSchedules, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel]; ok { + recipients = approveSchedules + } + if notifySchedules, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationNotifySchedulesLabel]; ok { + recipients = notifySchedules + } + for _, recipient := range recipients { + rec, err := a.bot.FetchRecipient(ctx, recipient) + if err != nil { + log.Warningf("Failed to fetch Opsgenie recipient: %v", err) + continue } - return recipientSet.ToSlice() + recipientSet.Add(*rec) } + return recipientSet.ToSlice() } validEmailSuggReviewers := []string{} diff --git a/integrations/access/accessrequest/app_test.go b/integrations/access/accessrequest/app_test.go new file mode 100644 index 0000000000000..a4ad5520bc224 --- /dev/null +++ b/integrations/access/accessrequest/app_test.go @@ -0,0 +1,118 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package accessrequest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/wrappers" + "github.com/gravitational/teleport/integrations/access/common" +) + +func TestOpsGenieGetMessageRecipients(t *testing.T) { + a := App{pluginType: types.PluginTypeOpsgenie, bot: testBot{}} + ctx := context.Background() + tests := []struct { + name string + annotations map[string][]string + expectedRecipients []common.Recipient + }{ + { + name: "no annotation", + annotations: map[string][]string{}, + expectedRecipients: []common.Recipient{}, + }, + { + name: "just notify-schedules", + annotations: map[string][]string{ + types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: {"foo", "bar"}, + }, + expectedRecipients: []common.Recipient{ + { + Name: "foo", + ID: "foo", + }, + { + Name: "bar", + ID: "bar", + }, + }, + }, + { + name: "just approval-schedules", + annotations: map[string][]string{ + types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel: {"foo", "bar"}, + }, + expectedRecipients: []common.Recipient{ + { + Name: "foo", + ID: "foo", + }, + { + Name: "bar", + ID: "bar", + }, + }, + }, + { + name: "both notify and approval schedules", + annotations: map[string][]string{ + types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: {"foo", "bar"}, + types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel: {"baz", "hello"}, + }, + expectedRecipients: []common.Recipient{ + { + Name: "foo", + ID: "foo", + }, + { + Name: "bar", + ID: "bar", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &types.AccessRequestV3{ + Spec: types.AccessRequestSpecV3{ + SystemAnnotations: wrappers.Traits(tt.annotations), + }, + } + recipients := a.getMessageRecipients(ctx, req) + require.Equal(t, tt.expectedRecipients, recipients) + }) + } + +} + +type testBot struct { + MessagingBot +} + +func (testBot) FetchRecipient(ctx context.Context, recipient string) (*common.Recipient, error) { + return &common.Recipient{ + Name: recipient, + ID: recipient, + }, nil +} diff --git a/integrations/access/opsgenie/app.go b/integrations/access/opsgenie/app.go index eb3df0ab47559..2ade58d2cd0b2 100644 --- a/integrations/access/opsgenie/app.go +++ b/integrations/access/opsgenie/app.go @@ -31,7 +31,6 @@ import ( tp "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/access/common" "github.com/gravitational/teleport/integrations/access/common/teleport" "github.com/gravitational/teleport/integrations/lib" "github.com/gravitational/teleport/integrations/lib/backoff" @@ -45,9 +44,9 @@ const ( // minServerVersion is the minimal teleport version the plugin supports. minServerVersion = "6.1.0" // initTimeout is used to bound execution time of health check and teleport version check. - initTimeout = time.Second * 10 + initTimeout = time.Second * 30 // handlerTimeout is used to bound the execution time of watcher event handler. - handlerTimeout = time.Second * 5 + handlerTimeout = time.Second * 30 // modifyPluginDataBackoffBase is an initial (minimum) backoff value. modifyPluginDataBackoffBase = time.Millisecond // modifyPluginDataBackoffMax is a backoff threshold @@ -141,10 +140,9 @@ func (a *App) init(ctx context.Context) error { defer cancel() var err error - if a.teleport == nil { - if a.teleport, err = common.GetTeleportClient(ctx, a.conf.Teleport); err != nil { - return trace.Wrap(err) - } + a.teleport, err = a.conf.GetTeleportClient(ctx) + if err != nil { + return trace.Wrap(err, "getting teleport client") } if _, err = a.checkTeleportVersion(ctx); err != nil { @@ -155,6 +153,13 @@ func (a *App) init(ctx context.Context) error { if err != nil { return trace.Wrap(err) } + + log := logger.Get(ctx) + log.Debug("Starting API health check...") + if err = a.opsgenie.CheckHealth(ctx); err != nil { + return trace.Wrap(err, "API health check failed") + } + log.Debug("API health check finished ok") return nil } @@ -269,7 +274,7 @@ func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { } func (a *App) getNotifyServiceNames(req types.AccessRequest) ([]string, error) { - services, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationNotifyServicesLabel] + services, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationNotifySchedulesLabel] if !ok { return nil, trace.NotFound("notify services not specified") } @@ -277,7 +282,7 @@ func (a *App) getNotifyServiceNames(req types.AccessRequest) ([]string, error) { } func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) { - services, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationSchedulesLabel] + services, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel] if !ok { return nil, trace.NotFound("on-call schedules not specified") } @@ -294,11 +299,16 @@ func (a *App) tryNotifyService(ctx context.Context, req types.AccessRequest) (bo } reqID := req.GetName() + annotations := types.Labels{} + for k, v := range req.GetSystemAnnotations() { + annotations[k] = v + } reqData := RequestData{ - User: req.GetUser(), - Roles: req.GetRoles(), - Created: req.GetCreationTime(), - RequestReason: req.GetRequestReason(), + User: req.GetUser(), + Roles: req.GetRoles(), + Created: req.GetCreationTime(), + RequestReason: req.GetRequestReason(), + SystemAnnotations: annotations, } // Create plugin data if it didn't exist before. @@ -429,7 +439,7 @@ func (a *App) tryApproveRequest(ctx context.Context, req types.AccessRequest) er if _, err := a.teleport.SubmitAccessReview(ctx, types.AccessReviewSubmission{ RequestID: req.GetName(), Review: types.AccessReview{ - Author: tp.SystemAccessApproverUserName, + Author: a.conf.TeleportUserName, ProposedState: types.RequestState_APPROVED, Reason: fmt.Sprintf("Access requested by user %s who is on call on service(s) %s", tp.SystemAccessApproverUserName, diff --git a/integrations/access/opsgenie/bot.go b/integrations/access/opsgenie/bot.go index 3c63d2881ca6c..fef4f82f42da2 100644 --- a/integrations/access/opsgenie/bot.go +++ b/integrations/access/opsgenie/bot.go @@ -60,13 +60,17 @@ func (b Bot) SendReviewReminders(ctx context.Context, recipients []common.Recipi } // BroadcastAccessRequestMessage creates an alert for the provided recipients (schedules) -func (b *Bot) BroadcastAccessRequestMessage(ctx context.Context, recipients []common.Recipient, reqID string, reqData pd.AccessRequestData) (data accessrequest.SentMessages, err error) { - schedules := []string{} - for _, recipient := range recipients { - schedules = append(schedules, recipient.Name) +func (b *Bot) BroadcastAccessRequestMessage(ctx context.Context, recipientSchedules []common.Recipient, reqID string, reqData pd.AccessRequestData) (data accessrequest.SentMessages, err error) { + notificationSchedules := make([]string, 0, len(recipientSchedules)) + for _, notifySchedule := range recipientSchedules { + notificationSchedules = append(notificationSchedules, notifySchedule.Name) } - if len(recipients) == 0 { - schedules = append(schedules, b.client.DefaultSchedules...) + autoApprovalSchedules := []string{} + if annotationAutoApprovalSchedules, ok := reqData.SystemAnnotations[types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel]; ok { + autoApprovalSchedules = annotationAutoApprovalSchedules + } + if len(autoApprovalSchedules) == 0 { + autoApprovalSchedules = append(autoApprovalSchedules, b.client.DefaultSchedules...) } opsgenieReqData := RequestData{ User: reqData.User, @@ -79,7 +83,8 @@ func (b *Bot) BroadcastAccessRequestMessage(ctx context.Context, recipients []co Reason: reqData.ResolutionReason, }, SystemAnnotations: types.Labels{ - types.TeleportNamespace + types.ReqAnnotationSchedulesLabel: schedules, + types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel: autoApprovalSchedules, + types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: notificationSchedules, }, } opsgenieData, err := b.client.CreateAlert(ctx, reqID, opsgenieReqData) diff --git a/integrations/access/opsgenie/client.go b/integrations/access/opsgenie/client.go index 9e63c3423767b..cc78b65298bbc 100644 --- a/integrations/access/opsgenie/client.go +++ b/integrations/access/opsgenie/client.go @@ -30,17 +30,24 @@ import ( "github.com/aws/aws-sdk-go/aws/defaults" "github.com/go-resty/resty/v2" "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/integrations/access/common" "github.com/gravitational/teleport/integrations/lib" + "github.com/gravitational/teleport/integrations/lib/backoff" "github.com/gravitational/teleport/integrations/lib/logger" ) const ( // alertKeyPrefix is the prefix for Alert's alias field used when creating an Alert. - alertKeyPrefix = "teleport-access-request" - heartbeatName = "teleport-access-heartbeat" + alertKeyPrefix = "teleport-access-request" + heartbeatName = "teleport-access-heartbeat" + ResponderTypeSchedule = "schedule" + ResponderTypeUser = "user" + + ResolveAlertRequestRetryInterval = time.Second * 10 + ResolveAlertRequestRetryTimeout = time.Minute * 2 ) var alertBodyTemplate = template.Must(template.New("alert body").Parse( @@ -135,11 +142,11 @@ func (og Client) CreateAlert(ctx context.Context, reqID string, reqData RequestD Message: fmt.Sprintf("Access request from %s", reqData.User), Alias: fmt.Sprintf("%s/%s", alertKeyPrefix, reqID), Description: bodyDetails, - Responders: og.getResponders(reqData), + Responders: og.getScheduleResponders(reqData), Priority: og.Priority, } - var result AlertResult + var result CreateAlertResult resp, err := og.client.NewRequest(). SetContext(ctx). SetBody(body). @@ -153,20 +160,60 @@ func (og Client) CreateAlert(ctx context.Context, reqID string, reqData RequestD if resp.IsError() { return OpsgenieData{}, errWrapper(resp.StatusCode(), string(resp.Body())) } + + // If this fails, Teleport request approval and auto-approval will still work, + // but incident in Opsgenie won't be auto-closed or updated as the alertID won't be available. + alertRequestResult, err := og.tryGetAlertRequestResult(ctx, result.RequestID) + if err != nil { + return OpsgenieData{}, trace.Wrap(err) + } + return OpsgenieData{ - AlertID: result.Alert.ID, + AlertID: alertRequestResult.Data.AlertID, }, nil } -func (og Client) getResponders(reqData RequestData) []Responder { +func (og Client) tryGetAlertRequestResult(ctx context.Context, reqID string) (GetAlertRequestResult, error) { + backoff := backoff.NewDecorr(ResolveAlertRequestRetryInterval, ResolveAlertRequestRetryTimeout, clockwork.NewRealClock()) + for { + alertRequestResult, err := og.getAlertRequestResult(ctx, reqID) + if err == nil { + logger.Get(ctx).Debugf("Got alert request result: %+v", alertRequestResult) + return alertRequestResult, nil + } + logger.Get(ctx).Debug("Failed to get alert request result:", err) + if err := backoff.Do(ctx); err != nil { + return GetAlertRequestResult{}, trace.Wrap(err) + } + } +} + +func (og Client) getAlertRequestResult(ctx context.Context, reqID string) (GetAlertRequestResult, error) { + var result GetAlertRequestResult + resp, err := og.client.NewRequest(). + SetContext(ctx). + SetResult(&result). + SetPathParams(map[string]string{"requestID": reqID}). + Get("v2/alerts/requests/{requestID}") + if err != nil { + return GetAlertRequestResult{}, trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return GetAlertRequestResult{}, errWrapper(resp.StatusCode(), string(resp.Body())) + } + return result, nil +} + +func (og Client) getScheduleResponders(reqData RequestData) []Responder { schedules := og.DefaultSchedules - if reqSchedules, ok := reqData.SystemAnnotations[types.TeleportNamespace+types.ReqAnnotationSchedulesLabel]; ok { + if reqSchedules, ok := reqData.SystemAnnotations[types.TeleportNamespace+types.ReqAnnotationNotifySchedulesLabel]; ok { schedules = reqSchedules } responders := make([]Responder, 0, len(schedules)) for _, s := range schedules { responders = append(responders, Responder{ - Type: "schedule", + Type: ResponderTypeSchedule, ID: s, }) } @@ -231,12 +278,13 @@ func (og Client) GetOnCall(ctx context.Context, scheduleName string) (Responders SetContext(ctx). SetPathParams(map[string]string{"scheduleName": scheduleName}). SetQueryParams(map[string]string{ + // This is required to lookup schedules by name (as opposed to lookup by ID) "scheduleIdentifierType": "name", // When flat is enabled it returns the email addresses of on-call participants. "flat": "true", }). SetResult(&result). - Post("v2/schedules/{scheduleName}/on-calls") + Get("v2/schedules/{scheduleName}/on-calls") if err != nil { return RespondersResult{}, trace.Wrap(err) } diff --git a/integrations/access/opsgenie/client_test.go b/integrations/access/opsgenie/client_test.go index abe2caf072a4c..04e560bb27423 100644 --- a/integrations/access/opsgenie/client_test.go +++ b/integrations/access/opsgenie/client_test.go @@ -36,6 +36,9 @@ import ( func TestCreateAlert(t *testing.T) { recievedReq := "" testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/v2/alerts" { + return + } bodyBytes, err := io.ReadAll(req.Body) if err != nil { log.Fatal(err) @@ -56,7 +59,7 @@ func TestCreateAlert(t *testing.T) { Roles: []string{"role1", "role2"}, RequestReason: "someReason", SystemAnnotations: types.Labels{ - types.TeleportNamespace + types.ReqAnnotationSchedulesLabel: {"responder@teleport.com"}, + types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: {"responder@teleport.com"}, }, }) assert.NoError(t, err) diff --git a/integrations/access/opsgenie/config.go b/integrations/access/opsgenie/config.go index 1090967a587cf..5d0fec5705dec 100644 --- a/integrations/access/opsgenie/config.go +++ b/integrations/access/opsgenie/config.go @@ -19,6 +19,7 @@ package opsgenie import ( + "context" "net/url" "github.com/gravitational/trace" @@ -26,6 +27,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/integrations/access/common" "github.com/gravitational/teleport/integrations/access/common/auth" + "github.com/gravitational/teleport/integrations/access/common/teleport" ) // Config stores the full configuration for the teleport-opsgenie plugin to run. @@ -38,6 +40,14 @@ type Config struct { // AccessTokenProvider provides a method to get the bearer token // for use when authorizing to a 3rd-party provider API. AccessTokenProvider auth.AccessTokenProvider + + // Teleport is a handle to the client to use when communicating with + // the Teleport auth server. The ServiceNow app will create a gRPC-based + // client on startup if this is not set. + Client teleport.Client + // TeleportUserName is the name of the Teleport user that will act + // as the access request approver. + TeleportUserName string } // CheckAndSetDefaults checks the config struct for any logical errors, and sets default values @@ -73,6 +83,14 @@ func (c *Config) CheckAndSetDefaults() error { return nil } +// GetTeleportClient returns the configured Teleport client. +func (c *Config) GetTeleportClient(ctx context.Context) (teleport.Client, error) { + if c.Client != nil { + return c.Client, nil + } + return c.BaseConfig.GetTeleportClient(ctx) +} + // NewBot initializes the new Opsgenie message generator (OpsgenieBot) func (c *Config) NewBot(clusterName, webProxyAddr string) (common.MessagingBot, error) { webProxyURL, err := url.Parse(webProxyAddr) diff --git a/integrations/access/opsgenie/fake_opsgenie_test.go b/integrations/access/opsgenie/fake_opsgenie_test.go index 87fcf06ac2507..f2dd32b3f7e66 100644 --- a/integrations/access/opsgenie/fake_opsgenie_test.go +++ b/integrations/access/opsgenie/fake_opsgenie_test.go @@ -99,7 +99,21 @@ func NewFakeOpsgenie(concurrency int) *FakeOpsgenie { opsgenie.StoreAlert(alert) opsgenie.newAlerts <- alert - err = json.NewEncoder(rw).Encode(AlertResult{Alert: alert}) + err = json.NewEncoder(rw).Encode(CreateAlertResult{RequestID: alert.ID}) + panicIf(err) + }) + router.GET("/v2/alerts/requests/:requestID", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusCreated) + + requestID := ps.ByName("requestID") + err := json.NewEncoder(rw).Encode(GetAlertRequestResult{ + Data: struct { + AlertID string `json:"alertId"` + }{ + AlertID: requestID, + }, + }) panicIf(err) }) router.POST("/v2/alerts/:alertID/close", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -142,6 +156,38 @@ func NewFakeOpsgenie(concurrency int) *FakeOpsgenie { panicIf(err) }) + router.GET("/v2/schedules/:scheduleName/on-calls", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.Header().Add("Content-Type", "application/json") + scheduleName := ps.ByName("scheduleName") + + // Check if exists + _, ok := opsgenie.GetSchedule(scheduleName) + if !ok { + rw.WriteHeader(http.StatusNotFound) + return + } + + emails := opsgenie.GetOnCallEmailsForSchedule(scheduleName) + + response := RespondersResult{ + Data: struct { + OnCallRecipients []string `json:"onCallRecipients,omitempty"` + }( + struct { + OnCallRecipients []string + }{ + OnCallRecipients: emails, + }, + ), + } + + rw.WriteHeader(http.StatusOK) + err := json.NewEncoder(rw).Encode(response) + panicIf(err) + }) + router.GET("/v2/heartbeats/teleport-access-heartbeat/ping", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { + rw.WriteHeader(http.StatusOK) + }) return opsgenie } @@ -234,8 +280,62 @@ func (s *FakeOpsgenie) CheckNewAlertNote(ctx context.Context) (FakeAlertNote, er } } +// StoreSchedule upserts a schedule. To simplify the implementation, the schedule +// is not given a UUID, we use its name. This is possible because we get the on-call +// list in Client.GetOnCall() by passing the param "scheduleIdentifierType": "name". +// The function also creates a responder for the schedule and returns it. +// The schedule can then be directly notified as a responder, or queried for +// on-call users as a schedule. +func (s *FakeOpsgenie) StoreSchedule(scheduleName string, responders ...Responder) Responder { + key := fmt.Sprintf("schedule-%s", scheduleName) + s.objects.Store(key, responders) + responder := Responder{ + Name: scheduleName, + Type: ResponderTypeSchedule, + } + responder = s.StoreResponder(responder) + return responder +} + +// GetSchedule gets a schedule. +func (s *FakeOpsgenie) GetSchedule(scheduleName string) ([]Responder, bool) { + key := fmt.Sprintf("schedule-%s", scheduleName) + value, ok := s.objects.Load(key) + if !ok { + return nil, false + } + responders, ok := value.([]Responder) + if !ok { + panic("cannot cast schedule object as a responder slice") + } + return responders, true +} + func panicIf(err error) { if err != nil { log.Panicf("%v at %v", err, string(debug.Stack())) } } + +func (s *FakeOpsgenie) GetOnCallEmailsForSchedule(scheduleName string) []string { + var emails []string + responders, ok := s.GetSchedule(scheduleName) + if !ok { + return nil + } + for _, responder := range responders { + switch responder.Type { + case ResponderTypeSchedule: + emails = append(emails, s.GetOnCallEmailsForSchedule(responder.Name)...) + case ResponderTypeUser: + // If the responder is a user, we return its email + emails = append(emails, responder.Name) + default: + // We don't implement "team" and "escalation" responder types because + // we don't test those yet. + } + + } + + return emails +} diff --git a/integrations/access/opsgenie/opsgenie_test.go b/integrations/access/opsgenie/opsgenie_test.go index 5a86d8cb177ef..75471c7f79ce0 100644 --- a/integrations/access/opsgenie/opsgenie_test.go +++ b/integrations/access/opsgenie/opsgenie_test.go @@ -39,7 +39,7 @@ import ( const ( NotifyServiceName = "Teleport Notifications" - NotifyServiceAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifyServicesLabel + NotifyServiceAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel ResponderName1 = "Responder 1" ResponderName2 = "Responder 2" ResponderName3 = "Responder 3" diff --git a/integrations/access/opsgenie/types.go b/integrations/access/opsgenie/types.go index 6aa42993b89d3..daa1a993d8a11 100644 --- a/integrations/access/opsgenie/types.go +++ b/integrations/access/opsgenie/types.go @@ -46,13 +46,20 @@ type AlertBody struct { Priority string `json:"priority,omitempty"` } -// Responder represents an Opsgenie responder +// Responder represents an Opsgenie responder. +// A responder is an entity that receives an alert. +// It can be a user, a team, or a schedule. +// The OpsGenie access plugin interacts with 2 types of responders: +// - it sends notifications to schedule responders +// - for auto-approval it looks up who the responders are for a given +// schedule and approves the request if a responder name matches the +// requester name. type Responder struct { // Name is the name of the responder. Name string `json:"name,omitempty"` // Username is the opsgenie username of the responder. Username string `json:"username,omitempty"` - // Type is the type of responder team/user + // Type is the type of responder team/user/schedule. Type string `json:"type,omitempty"` // ID is the ID of the responder. ID string `json:"id,omitempty"` @@ -69,7 +76,7 @@ type RespondersResult struct { // AlertResult is a wrapper around Alert type AlertResult struct { // Alert contains the actual alert data. - Alert Alert `json:"alert"` + Alert Alert `json:"data"` } // AlertNote represents an Opsgenie alert note @@ -81,3 +88,17 @@ type AlertNote struct { // Note is the alert note. Note string `json:"note"` } + +// CreateAlertResult represents the resulting request information from an Opsgenie create alert request. +type CreateAlertResult struct { + Result string `json:"result"` + Took float64 `json:"took"` + RequestID string `json:"requestId"` +} + +// GetAlertRequestResult represents the response of a completed Opsgenie create alert request. +type GetAlertRequestResult struct { + Data struct { + AlertID string `json:"alertId"` + } `json:"data"` +} diff --git a/integrations/access/servicenow/app.go b/integrations/access/servicenow/app.go index aa87f42c1b9c4..f58a712c7078b 100644 --- a/integrations/access/servicenow/app.go +++ b/integrations/access/servicenow/app.go @@ -316,7 +316,7 @@ func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { } func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) { - services, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationSchedulesLabel] + services, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel] if !ok { return nil, trace.NotFound("on-call schedules not specified") } diff --git a/integrations/access/servicenow/servicenow_test.go b/integrations/access/servicenow/servicenow_test.go index 105e172917053..ab1528c9fe1d9 100644 --- a/integrations/access/servicenow/servicenow_test.go +++ b/integrations/access/servicenow/servicenow_test.go @@ -38,7 +38,7 @@ import ( ) const ( - ScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationSchedulesLabel + ScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel Schedule = "someRotaID" ResponderName1 = "ResponderID1" ResponderName2 = "RespondeID2"