diff --git a/CHANGELOG.md b/CHANGELOG.md index c591bd79aa6a9..9fb2118f716c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 16.0.0 (xx/xx/xx) + +### Breaking changes + +#### Opsgenie plugin annotations + +Opsgenie plugin users, role annotations must now contain +`teleport.dev/notify-services` to receive notification on Opsgenie. +`teleport.dev/schedules` is now the label used to determine auto approval flow. +See [the Opsgenie plugin documentation](docs/pages/access-controls/access-request-plugins/opsgenie.mdx) +for setup instructions. + ## 15.0.0 (xx/xx/24) ### New features diff --git a/api/types/constants.go b/api/types/constants.go index 2a2a95f3e4a20..ba6d2509a94f7 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -727,10 +727,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..3604f928c4e82 100644 --- a/integrations/access/accessrequest/app.go +++ b/integrations/access/accessrequest/app.go @@ -352,16 +352,19 @@ 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) - } + recipients, ok := req.GetSystemAnnotations()[types.TeleportNamespace+types.ReqAnnotationNotifySchedulesLabel] + if !ok { return recipientSet.ToSlice() } + for _, recipient := range recipients { + rec, err := a.bot.FetchRecipient(ctx, recipient) + if err != nil { + log.Warningf("Failed to fetch Opsgenie recipient: %v", err) + continue + } + recipientSet.Add(*rec) + } + return recipientSet.ToSlice() } validEmailSuggReviewers := []string{} diff --git a/integrations/access/opsgenie/app.go b/integrations/access/opsgenie/app.go index e81aac524f031..1a3030a30cf0d 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/testlib/fake_opsgenie.go b/integrations/access/opsgenie/testlib/fake_opsgenie.go index 2d63adf5c06b0..9b5e6252119d1 100644 --- a/integrations/access/opsgenie/testlib/fake_opsgenie.go +++ b/integrations/access/opsgenie/testlib/fake_opsgenie.go @@ -100,7 +100,21 @@ func NewFakeOpsgenie(concurrency int) *FakeOpsgenie { mock.StoreAlert(alert) mock.newAlerts <- alert - err = json.NewEncoder(rw).Encode(opsgenie.AlertResult{Alert: alert}) + err = json.NewEncoder(rw).Encode(opsgenie.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(opsgenie.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) { @@ -143,6 +157,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 := mock.GetSchedule(scheduleName) + if !ok { + rw.WriteHeader(http.StatusNotFound) + return + } + + emails := mock.GetOnCallEmailsForSchedule(scheduleName) + + response := opsgenie.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 mock } @@ -235,8 +281,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 ...opsgenie.Responder) opsgenie.Responder { + key := fmt.Sprintf("schedule-%s", scheduleName) + s.objects.Store(key, responders) + responder := opsgenie.Responder{ + Name: scheduleName, + Type: opsgenie.ResponderTypeSchedule, + } + responder = s.StoreResponder(responder) + return responder +} + +// GetSchedule gets a schedule. +func (s *FakeOpsgenie) GetSchedule(scheduleName string) ([]opsgenie.Responder, bool) { + key := fmt.Sprintf("schedule-%s", scheduleName) + value, ok := s.objects.Load(key) + if !ok { + return nil, false + } + responders, ok := value.([]opsgenie.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 opsgenie.ResponderTypeSchedule: + emails = append(emails, s.GetOnCallEmailsForSchedule(responder.Name)...) + case opsgenie.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/testlib/suite.go b/integrations/access/opsgenie/testlib/suite.go index 183422c0100f7..b41ebe5422f5d 100644 --- a/integrations/access/opsgenie/testlib/suite.go +++ b/integrations/access/opsgenie/testlib/suite.go @@ -28,17 +28,17 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/integrations/access/opsgenie" - "github.com/gravitational/teleport/integrations/access/pagerduty" "github.com/gravitational/teleport/integrations/lib/logger" "github.com/gravitational/teleport/integrations/lib/testing/integration" ) const ( - NotifyServiceName = "Teleport Notifications" - NotifyServiceAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifyServicesLabel - ResponderName1 = "Responder 1" - ResponderName2 = "Responder 2" - ResponderName3 = "Responder 3" + NotifyScheduleName = "Teleport Notifications" + NotifyScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel + ApprovalScheduleName = "Teleport Approval" + ApprovalScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel + ResponderName1 = "Responder 1" + ResponderName2 = "Responder 2" ) // OpsgenieSuite is the OpsGenie access plugin test suite. @@ -52,7 +52,6 @@ type OpsgenieSuite struct { ogNotifyResponder opsgenie.Responder ogResponder1 opsgenie.Responder ogResponder2 opsgenie.Responder - ogResponder3 opsgenie.Responder } // SetupTest starts a fake OpsGenie and generates the plugin configuration. @@ -72,30 +71,24 @@ func (s *OpsgenieSuite) SetupTest() { // This service should be notified for every access request. s.ogNotifyResponder = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ - Name: NotifyServiceName, + Name: NotifyScheduleName, }) s.AnnotateRequesterRoleAccessRequests( ctx, - NotifyServiceAnnotation, - []string{NotifyServiceName}, + NotifyScheduleAnnotation, + []string{NotifyScheduleName}, ) // Responder 1 and 2 are on-call and should be automatically approved. // Responder 3 is not. s.ogResponder1 = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ - Name: ResponderName1, + Name: integration.Requester1UserName, + Type: opsgenie.ResponderTypeUser, }) s.ogResponder2 = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ - Name: ResponderName2, + Name: "Not a Teleport user", + Type: opsgenie.ResponderTypeUser, }) - s.ogResponder3 = s.fakeOpsgenie.StoreResponder(opsgenie.Responder{ - Name: ResponderName3, - }) - s.AnnotateRequesterRoleAccessRequests( - ctx, - pagerduty.ServicesDefaultAnnotation, - []string{ResponderName1, ResponderName2}, - ) var conf opsgenie.Config conf.Teleport = s.TeleportConfig() @@ -382,3 +375,85 @@ func (s *OpsgenieSuite) TestDenialByReview() { require.NoError(t, err) assert.Equal(t, "resolved", alertUpdate.Status) } + +// TestAutoApprovalWhenNotOnCall tests that access requests are not automatically +// approved when the user is not on-call. +func (s *OpsgenieSuite) TestAutoApprovalWhenNotOnCall() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + if !s.TeleportFeatures().AdvancedAccessWorkflows { + t.Skip("Doesn't work in OSS version") + } + + // Test setup: create an on-call schedule with a non-Teleport user in it. + s.fakeOpsgenie.StoreSchedule(ApprovalScheduleName, s.ogResponder2) + s.AnnotateRequesterRoleAccessRequests( + ctx, + ApprovalScheduleAnnotation, + []string{ApprovalScheduleName}, + ) + + s.startApp() + + // Test Execution: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + // Validate the alert has been created in OpsGenie and its ID is stored in + // the plugin_data. + _ = s.checkPluginData(ctx, req.GetName(), func(data opsgenie.PluginData) bool { + return data.AlertID != "" + }) + + _, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Fetch updated access request + req, err = s.Ruler().GetAccessRequest(ctx, req.GetName()) + require.NoError(t, err) + + require.Empty(t, req.GetReviews(), "no review should be submitted automatically") +} + +// TestAutoApprovalWhenOnCall tests that access requests are automatically +// approved when the user is not on-call. +func (s *OpsgenieSuite) TestAutoApprovalWhenOnCall() { + t := s.T() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + if !s.TeleportFeatures().AdvancedAccessWorkflows { + t.Skip("Doesn't work in OSS version") + } + + // Test setup: create an on-call schedule with a non-Teleport user in it. + s.fakeOpsgenie.StoreSchedule(ApprovalScheduleName, s.ogResponder1, s.ogResponder2) + s.AnnotateRequesterRoleAccessRequests( + ctx, + ApprovalScheduleAnnotation, + []string{ApprovalScheduleName}, + ) + + s.startApp() + + // Test Execution: we create an access request and wait for its alert. + req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil) + + // Validate the alert has been created in OpsGenie and its ID is stored in + // the plugin_data. + _ = s.checkPluginData(ctx, req.GetName(), func(data opsgenie.PluginData) bool { + return data.AlertID != "" + }) + + _, err := s.fakeOpsgenie.CheckNewAlert(ctx) + require.NoError(t, err, "no new alerts stored") + + // Fetch updated access request + req, err = s.Ruler().GetAccessRequest(ctx, req.GetName()) + require.NoError(t, err) + + reviews := req.GetReviews() + require.Len(t, reviews, 1, "a review should be submitted automatically") + require.Equal(t, types.RequestState_APPROVED, reviews[0].ProposedState) +} 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 68954fe2a0f66..00f7d9084edfb 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/testlib/suite.go b/integrations/access/servicenow/testlib/suite.go index eeb4fa5ab1a93..125aab357349e 100644 --- a/integrations/access/servicenow/testlib/suite.go +++ b/integrations/access/servicenow/testlib/suite.go @@ -69,7 +69,7 @@ func (s *ServiceNowSuite) SetupTest() { s.AnnotateRequesterRoleAccessRequests( ctx, - types.TeleportNamespace+types.ReqAnnotationSchedulesLabel, + types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel, []string{snowOnCallRotationName}, ) diff --git a/rfd/0109-opsgenie-plugin.md b/rfd/0109-opsgenie-plugin.md index 936aa060904cf..eff6c76401332 100644 --- a/rfd/0109-opsgenie-plugin.md +++ b/rfd/0109-opsgenie-plugin.md @@ -108,8 +108,8 @@ spec: request: roles: [someOtherRole] annotations: - opsgenie_notify_services: ["service1", "service2"] # These are the Opsgenie services alerts will be created under - opsgenie_oncall_schedules: ["service1", "service2"] # These are the Opsgenie schedules checked during auto approval + teleport.dev/notify-services: ["schedule1", "schedule2"] # These are the Opsgenie schedules alerts will be created under + teleport.dev/schedules: ["schedule1", "schedule2"] # These are the Opsgenie schedules checked during auto approval ``` ## Implementation details