diff --git a/api/types/plugin.go b/api/types/plugin.go
index a939bdb7ecf94..0f16a2ec69633 100644
--- a/api/types/plugin.go
+++ b/api/types/plugin.go
@@ -72,6 +72,8 @@ const (
PluginTypeEntraID = "entra-id"
// PluginTypeSCIM indicates a generic SCIM integration
PluginTypeSCIM = "scim"
+ // PluginTypeIncidentio is the incident.io access request plugin
+ PluginTypeIncidentio = "incidentio"
)
// PluginSubkind represents the type of the plugin, e.g., access request, MDM etc.
diff --git a/integrations/access/incidentio/alert_client_test.go b/integrations/access/incidentio/alert_client_test.go
new file mode 100644
index 0000000000000..d91f4724bb71e
--- /dev/null
+++ b/integrations/access/incidentio/alert_client_test.go
@@ -0,0 +1,119 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/gravitational/teleport/api/types"
+)
+
+func TestCreateAlert(t *testing.T) {
+ recievedReq := ""
+ testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ bodyBytes, err := io.ReadAll(req.Body)
+ if err != nil {
+ log.Fatal(err)
+ }
+ recievedReq = string(bodyBytes)
+ }))
+ defer func() { testServer.Close() }()
+
+ c, err := NewAlertClient(ClientConfig{
+ AlertSourceEndpoint: testServer.URL,
+ ClusterName: "someClusterName",
+ })
+ assert.NoError(t, err)
+
+ _, err = c.CreateAlert(context.Background(), "someRequestID", RequestData{
+ User: "someUser",
+ Roles: []string{"role1", "role2"},
+ RequestReason: "someReason",
+ SystemAnnotations: types.Labels{
+ types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: {"responder@example.com", "bb4d9938-c3c2-455d-aaab-727aa701c0d8"},
+ },
+ })
+ assert.NoError(t, err)
+
+ expected := AlertBody{
+ Title: "Access request from someUser",
+ DeduplicationKey: "teleport-access-request/someRequestID",
+ Description: "Access request from someUser",
+ Status: "firing",
+ Metadata: map[string]string{
+ "request_id": "someRequestID",
+ },
+ }
+ var got AlertBody
+ err = json.Unmarshal([]byte(recievedReq), &got)
+ assert.NoError(t, err)
+
+ assert.Equal(t, expected, got)
+}
+
+func TestResolveAlert(t *testing.T) {
+ recievedReq := ""
+ testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ bodyBytes, err := io.ReadAll(req.Body)
+ if err != nil {
+ log.Fatal(err)
+ }
+ recievedReq = string(bodyBytes)
+ }))
+ defer func() { testServer.Close() }()
+
+ c, err := NewAlertClient(ClientConfig{
+ AlertSourceEndpoint: testServer.URL,
+ ClusterName: "someClusterName",
+ })
+ assert.NoError(t, err)
+
+ err = c.ResolveAlert(context.Background(), "someAlertID", Resolution{
+ Tag: ResolvedApproved,
+ Reason: "someReason",
+ })
+
+ assert.NoError(t, err)
+
+ assert.Equal(t, `{"message":"Access request resolved: approved","description":"Access request has been approved","deduplication_key":"teleport-access-request/someAlertID","status":"resolved","metadata":{"request_id":"someAlertID"}}`, recievedReq)
+}
+
+func TestCreateAlertError(t *testing.T) {
+ testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ res.WriteHeader(http.StatusForbidden)
+ }))
+ defer func() { testServer.Close() }()
+
+ c, err := NewAlertClient(ClientConfig{
+ AlertSourceEndpoint: testServer.URL,
+ })
+ assert.NoError(t, err)
+
+ _, err = c.CreateAlert(context.Background(), "someRequestID", RequestData{})
+ assert.True(t, trace.IsAccessDenied(err))
+}
diff --git a/integrations/access/incidentio/api_client_test.go b/integrations/access/incidentio/api_client_test.go
new file mode 100644
index 0000000000000..e6f331360f8bc
--- /dev/null
+++ b/integrations/access/incidentio/api_client_test.go
@@ -0,0 +1,70 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetSchedule(t *testing.T) {
+ testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == "/v2/schedules/someRequestID" {
+ res.WriteHeader(http.StatusOK)
+ } else {
+ res.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+ defer func() { testServer.Close() }()
+
+ c, err := NewAPIClient(ClientConfig{
+ APIEndpoint: testServer.URL,
+ APIKey: "someAPIKey",
+ ClusterName: "someClusterName",
+ })
+ assert.NoError(t, err)
+
+ _, err = c.GetOnCall(context.Background(), "someRequestID")
+ assert.NoError(t, err)
+}
+
+func TestHealthCheck(t *testing.T) {
+ testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ if req.URL.Path == "/v2/schedules" {
+ res.WriteHeader(http.StatusOK)
+ } else {
+ res.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+ defer func() { testServer.Close() }()
+
+ c, err := NewAPIClient(ClientConfig{
+ APIEndpoint: testServer.URL,
+ APIKey: "someAPIKey",
+ ClusterName: "someClusterName",
+ })
+ assert.NoError(t, err)
+
+ err = c.CheckHealth(context.Background())
+ assert.NoError(t, err)
+}
diff --git a/integrations/access/incidentio/app.go b/integrations/access/incidentio/app.go
new file mode 100644
index 0000000000000..9e5727ceefbb1
--- /dev/null
+++ b/integrations/access/incidentio/app.go
@@ -0,0 +1,602 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gravitational/trace"
+ "github.com/jonboulle/clockwork"
+
+ tp "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/api/client/proto"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/access/accessmonitoring"
+ "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"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+ "github.com/gravitational/teleport/integrations/lib/watcherjob"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+const (
+ // pluginName is used to tag incident.io GenericPluginData and as a Delegator in Audit log.
+ pluginName = "incidentio"
+ // minServerVersion is the minimal teleport version the plugin supports.
+ minServerVersion = "16.2.1"
+ // initTimeout is used to bound execution time of health check and teleport version check.
+ initTimeout = time.Second * 30
+ // handlerTimeout is used to bound the execution time of watcher event handler.
+ handlerTimeout = time.Second * 30
+ // modifyPluginDataBackoffBase is an initial (minimum) backoff value.
+ modifyPluginDataBackoffBase = time.Millisecond
+ // modifyPluginDataBackoffMax is a backoff threshold
+ modifyPluginDataBackoffMax = time.Second
+)
+
+// errMissingAnnotation is used for cases where request annotations are not set
+var errMissingAnnotation = errors.New("access request is missing annotations")
+
+// App is a wrapper around the base app to allow for extra functionality.
+type App struct {
+ *lib.Process
+
+ PluginName string
+ teleport teleport.Client
+ alertClient *AlertClient
+ incClient *APIClient
+ mainJob lib.ServiceJob
+ conf Config
+
+ accessMonitoringRules *accessmonitoring.RuleHandler
+}
+
+// NewIncidentApp initializes a new teleport-incidentio app and returns it.
+func NewIncidentApp(ctx context.Context, conf *Config) (*App, error) {
+ incidentApp := &App{
+ PluginName: pluginName,
+ conf: *conf,
+ }
+ teleClient, err := conf.GetTeleportClient(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ incidentApp.accessMonitoringRules = accessmonitoring.NewRuleHandler(accessmonitoring.RuleHandlerConfig{
+ Client: teleClient,
+ PluginType: string(conf.BaseConfig.PluginType),
+ FetchRecipientCallback: createScheduleRecipient,
+ })
+ incidentApp.mainJob = lib.NewServiceJob(incidentApp.run)
+ return incidentApp, nil
+}
+
+// Run initializes and runs a watcher and a callback server
+func (a *App) Run(ctx context.Context) error {
+ // Initialize the process.
+ a.Process = lib.NewProcess(ctx)
+ a.SpawnCriticalJob(a.mainJob)
+ <-a.Process.Done()
+ return a.Err()
+}
+
+// Err returns the error app finished with.
+func (a *App) Err() error {
+ return trace.Wrap(a.mainJob.Err())
+}
+
+// WaitReady waits for http and watcher service to start up.
+func (a *App) WaitReady(ctx context.Context) (bool, error) {
+ return a.mainJob.WaitReady(ctx)
+}
+
+func (a *App) run(ctx context.Context) error {
+ var err error
+
+ log := logger.Get(ctx)
+ log.Infof("Starting Teleport Access incident.io Plugin")
+
+ if err = a.init(ctx); err != nil {
+ return trace.Wrap(err)
+ }
+
+ watcherJob, err := watcherjob.NewJob(
+ a.teleport,
+ watcherjob.Config{
+ Watch: types.Watch{Kinds: []types.WatchKind{
+ {Kind: types.KindAccessRequest},
+ {Kind: types.KindAccessMonitoringRule},
+ }},
+ EventFuncTimeout: handlerTimeout,
+ },
+ a.onWatcherEvent,
+ )
+ log.Info("Starting watcher job")
+
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ a.SpawnCriticalJob(watcherJob)
+ ok, err := watcherJob.WaitReady(ctx)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ if err := a.accessMonitoringRules.InitAccessMonitoringRulesCache(ctx); err != nil {
+ return trace.Wrap(err)
+ }
+
+ a.mainJob.SetReady(ok)
+ if ok {
+ log.Info("Plugin is ready")
+ } else {
+ log.Error("Plugin is not ready")
+ }
+
+ <-watcherJob.Done()
+
+ return trace.Wrap(watcherJob.Err())
+}
+
+func (a *App) init(ctx context.Context) error {
+ ctx, cancel := context.WithTimeout(ctx, initTimeout)
+ defer cancel()
+
+ var err error
+ a.teleport, err = a.conf.GetTeleportClient(ctx)
+ if err != nil {
+ return trace.Wrap(err, "getting teleport client")
+ }
+
+ if _, err = a.checkTeleportVersion(ctx); err != nil {
+ return trace.Wrap(err)
+ }
+
+ a.alertClient, err = NewAlertClient(a.conf.ClientConfig)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ a.incClient, err = NewAPIClient(a.conf.ClientConfig)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ log := logger.Get(ctx)
+ log.Debug("Starting API health check...")
+ if err = a.incClient.CheckHealth(ctx); err != nil {
+ return trace.Wrap(err, "API health check failed")
+ }
+ log.Debug("API health check finished ok")
+ return nil
+}
+
+func (a *App) checkTeleportVersion(ctx context.Context) (proto.PingResponse, error) {
+ log := logger.Get(ctx)
+ log.Debug("Checking Teleport server version")
+
+ pong, err := a.teleport.Ping(ctx)
+ if err != nil {
+ if trace.IsNotImplemented(err) {
+ return pong, trace.Wrap(err, "server version must be at least %s", minServerVersion)
+ }
+ log.Error("Unable to get Teleport server version")
+ return pong, trace.Wrap(err)
+ }
+ err = utils.CheckMinVersion(pong.ServerVersion, minServerVersion)
+ return pong, trace.Wrap(err)
+}
+
+// onWatcherEvent is called for every cluster Event. It will call the handlers
+// for access request and access monitoring rule events.
+func (a *App) onWatcherEvent(ctx context.Context, event types.Event) error {
+ switch event.Resource.GetKind() {
+ case types.KindAccessMonitoringRule:
+ return trace.Wrap(a.accessMonitoringRules.HandleAccessMonitoringRule(ctx, event))
+ case types.KindAccessRequest:
+ return trace.Wrap(a.handleAcessRequest(ctx, event))
+ }
+ return trace.BadParameter("unexpected kind %s", event.Resource.GetKind())
+}
+
+func (a *App) handleAcessRequest(ctx context.Context, event types.Event) error {
+ if kind := event.Resource.GetKind(); kind != types.KindAccessRequest {
+ return trace.Errorf("unexpected kind %s", kind)
+ }
+ op := event.Type
+ reqID := event.Resource.GetName()
+ ctx, _ = logger.WithField(ctx, "request_id", reqID)
+
+ switch op {
+ case types.OpPut:
+ ctx, _ = logger.WithField(ctx, "request_op", "put")
+ req, ok := event.Resource.(types.AccessRequest)
+ if !ok {
+ return trace.Errorf("unexpected resource type %T", event.Resource)
+ }
+ ctx, log := logger.WithField(ctx, "request_state", req.GetState().String())
+
+ var err error
+ switch {
+ case req.GetState().IsPending():
+ err = a.onPendingRequest(ctx, req)
+ case req.GetState().IsResolved():
+ err = a.onResolvedRequest(ctx, req)
+ default:
+ log.WithField("event", event).Warn("Unknown request state")
+ return nil
+ }
+
+ if err != nil {
+ log.WithError(err).Error("Failed to process request")
+ return trace.Wrap(err)
+ }
+
+ return nil
+ case types.OpDelete:
+ ctx, log := logger.WithField(ctx, "request_op", "delete")
+
+ if err := a.onDeletedRequest(ctx, reqID); err != nil {
+ log.WithError(err).Error("Failed to process deleted request")
+ return trace.Wrap(err)
+ }
+ return nil
+ default:
+ return trace.BadParameter("unexpected event operation %s", op)
+ }
+}
+
+func (a *App) onPendingRequest(ctx context.Context, req types.AccessRequest) error {
+ // First, try to create a notification alert.
+ isNew, notifyErr := a.tryNotifyService(ctx, req)
+
+ // To minimize the count of auto-approval tries, let's only attempt it only when we have just created an alert.
+ // But if there's an error, we can't really know if the alert is new or not so lets just try.
+ if !isNew && notifyErr == nil {
+ return nil
+ }
+ // Don't show the error if the annotation is just missing.
+ if errors.Is(trace.Unwrap(notifyErr), errMissingAnnotation) {
+ notifyErr = nil
+ }
+
+ // Then, try to approve the request if user is currently on-call.
+ approveErr := trace.Wrap(a.tryApproveRequest(ctx, req))
+ return trace.NewAggregate(notifyErr, approveErr)
+}
+
+func (a *App) onResolvedRequest(ctx context.Context, req types.AccessRequest) error {
+ var notifyErr error
+
+ resolution := Resolution{Reason: req.GetResolveReason()}
+ switch req.GetState() {
+ case types.RequestState_APPROVED:
+ resolution.Tag = ResolvedApproved
+ case types.RequestState_DENIED:
+ resolution.Tag = ResolvedDenied
+ case types.RequestState_PROMOTED:
+ resolution.Tag = ResolvedPromoted
+ }
+ err := trace.Wrap(a.resolveAlert(ctx, req.GetName(), resolution))
+ return trace.NewAggregate(notifyErr, err)
+}
+
+func (a *App) onDeletedRequest(ctx context.Context, reqID string) error {
+ return a.resolveAlert(ctx, reqID, Resolution{Tag: ResolvedExpired})
+}
+
+// getNotifySchedulesAndTeams get schedules and teams to notify from both
+// annotations: /notify-services and /teams, returns an error if both are empty.
+func (a *App) getNotifySchedulesAndTeams(ctx context.Context, req types.AccessRequest) (schedules []string, teams []string, err error) {
+ log := logger.Get(ctx)
+
+ scheduleAnnotationKey := types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel
+ schedules, err = common.GetServiceNamesFromAnnotations(req, scheduleAnnotationKey)
+ if err != nil {
+ log.Debugf("No schedules to notify in %s", scheduleAnnotationKey)
+ }
+
+ if len(schedules) == 0 && len(teams) == 0 {
+ return nil, nil, trace.NotFound("no schedules or teams to notify")
+ }
+
+ return schedules, teams, nil
+}
+
+func (a *App) getOnCallServiceNames(req types.AccessRequest) ([]string, error) {
+ annotationKey := types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel
+ return common.GetServiceNamesFromAnnotations(req, annotationKey)
+}
+
+func (a *App) tryNotifyService(ctx context.Context, req types.AccessRequest) (bool, error) {
+ log := logger.Get(ctx)
+
+ recipientSchedules, _, err := a.getMessageRecipients(ctx, req)
+ if err != nil {
+ log.Debugf("Skipping the notification: %s", err)
+ return false, trace.Wrap(errMissingAnnotation)
+ }
+
+ reqID := req.GetName()
+ annotations := types.Labels{}
+ for k, v := range req.GetSystemAnnotations() {
+ annotations[k] = v
+ }
+
+ if len(recipientSchedules) != 0 {
+ schedules := make([]string, 0, len(recipientSchedules))
+ for _, s := range recipientSchedules {
+ schedules = append(schedules, s.Name)
+ }
+ annotations[types.TeleportNamespace+types.ReqAnnotationNotifySchedulesLabel] = schedules
+ }
+
+ reqData := RequestData{
+ User: req.GetUser(),
+ Roles: req.GetRoles(),
+ Created: req.GetCreationTime(),
+ RequestReason: req.GetRequestReason(),
+ SystemAnnotations: annotations,
+ }
+
+ // Create plugin data if it didn't exist before.
+ isNew, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) {
+ if existing != nil {
+ return PluginData{}, false
+ }
+ return PluginData{RequestData: reqData}, true
+ })
+ if err != nil {
+ return isNew, trace.Wrap(err, "updating plugin data")
+ }
+
+ if isNew {
+ if err = a.createAlert(ctx, reqID, reqData); err != nil {
+ return isNew, trace.Wrap(err, "creating incidentio alert")
+ }
+ }
+ return isNew, nil
+}
+
+func (a *App) getMessageRecipients(ctx context.Context, req types.AccessRequest) ([]common.Recipient, []common.Recipient, error) {
+ recipientSetSchedules := common.NewRecipientSet()
+ recipientSchedules := a.accessMonitoringRules.RecipientsFromAccessMonitoringRules(ctx, req)
+ recipientSchedules.ForEach(func(r common.Recipient) {
+ recipientSetSchedules.Add(r)
+ })
+ // Access Monitoring Rules recipients does not have a way to handle separate recipient types currently.
+ // Recipients from Access Monitoring Rules will be schedules only currently.
+ if recipientSetSchedules.Len() != 0 {
+ return recipientSetSchedules.ToSlice(), nil, nil
+ }
+ rawSchedules, rawTeams, err := a.getNotifySchedulesAndTeams(ctx, req)
+ if err != nil {
+ return nil, nil, trace.Wrap(err)
+ }
+ for _, rawSchedule := range rawSchedules {
+ recipientSetSchedules.Add(common.Recipient{
+ Name: rawSchedule,
+ ID: rawSchedule,
+ Kind: common.RecipientKindSchedule,
+ })
+ }
+
+ recipientSetTeams := common.NewRecipientSet()
+ for _, rawTeam := range rawTeams {
+ recipientSetTeams.Add(common.Recipient{
+ Name: rawTeam,
+ ID: rawTeam,
+ Kind: common.RecipientKindTeam,
+ })
+ }
+ return recipientSetSchedules.ToSlice(), nil, nil
+}
+
+// createAlert posts an alert with request information.
+func (a *App) createAlert(ctx context.Context, reqID string, reqData RequestData) error {
+ data, err := a.alertClient.CreateAlert(ctx, reqID, reqData)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ ctx, log := logger.WithField(ctx, "incident_deduplication_key", data.DeduplicationKey)
+ log.Info("Successfully created incident.io alert")
+
+ _, err = a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) {
+ var pluginData PluginData
+ if existing != nil {
+ pluginData = *existing
+ } else {
+ // It must be impossible but lets handle it just in case.
+ pluginData = PluginData{RequestData: reqData}
+ }
+ pluginData.IncidentAlertData = data
+ return pluginData, true
+ })
+ return trace.Wrap(err)
+}
+
+// tryApproveRequest attempts to submit an approval if the requesting user is on-call in one of the services provided in request annotation.
+func (a *App) tryApproveRequest(ctx context.Context, req types.AccessRequest) error {
+ log := logger.Get(ctx)
+
+ serviceNames, err := a.getOnCallServiceNames(req)
+ if err != nil {
+ logger.Get(ctx).Debugf("Skipping the approval: %s", err)
+ return nil
+ }
+
+ onCallUsers := []string{}
+ for _, scheduleName := range serviceNames {
+ respondersResult, err := a.incClient.GetOnCall(ctx, scheduleName)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ for _, shift := range respondersResult.Schedule.CurrentShifts {
+ if shift.User != nil {
+ onCallUsers = append(onCallUsers, shift.User.Email)
+ }
+ }
+
+ }
+
+ userIsOnCall := false
+ for _, user := range onCallUsers {
+ if req.GetUser() == user {
+ userIsOnCall = true
+ }
+ }
+ if userIsOnCall {
+ if _, err := a.teleport.SubmitAccessReview(ctx, types.AccessReviewSubmission{
+ RequestID: req.GetName(),
+ Review: types.AccessReview{
+ 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,
+ strings.Join(serviceNames, ","),
+ ),
+ Created: time.Now(),
+ },
+ }); err != nil {
+ if strings.HasSuffix(err.Error(), "has already reviewed this request") {
+ log.Debug("Already reviewed the request")
+ return nil
+ }
+ return trace.Wrap(err, "submitting access request")
+ }
+
+ }
+ log.Info("Successfully submitted a request approval")
+ return nil
+}
+
+// resolveAlert resolves the notification alert created by plugin if the alert exists.
+func (a *App) resolveAlert(ctx context.Context, reqID string, resolution Resolution) error {
+ var alertID string
+
+ // Save request resolution info in plugin data.
+ ok, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) {
+ // If plugin data is empty or missing alertID, we cannot do anything.
+ if existing == nil {
+ return PluginData{}, false
+ }
+ if alertID = existing.DeduplicationKey; alertID == "" {
+ return PluginData{}, false
+ }
+
+ // If resolution field is not empty then we already resolved the alert before. In this case we just quit.
+ if existing.RequestData.Resolution.Tag != Unresolved {
+ return PluginData{}, false
+ }
+
+ // Mark alert as resolved.
+ pluginData := *existing
+ pluginData.Resolution = resolution
+ return pluginData, true
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if !ok {
+ logger.Get(ctx).Debugf("Failed to resolve the alert: plugin data is missing")
+ return nil
+ }
+
+ ctx, log := logger.WithField(ctx, "incidentio_deduplication_key", alertID)
+ if err := a.alertClient.ResolveAlert(ctx, alertID, resolution); err != nil {
+ return trace.Wrap(err)
+ }
+ log.Info("Successfully resolved the alert")
+
+ return nil
+}
+
+// modifyPluginData performs a compare-and-swap update of access request's plugin data.
+// Callback function parameter is nil if plugin data hasn't been created yet.
+// Otherwise, callback function parameter is a pointer to current plugin data contents.
+// Callback function return value is an updated plugin data contents plus the boolean flag
+// indicating whether it should be written or not.
+// Note that callback function fn might be called more than once due to retry mechanism baked in
+// so make sure that the function is "pure" i.e. it doesn't interact with the outside world:
+// it doesn't perform any sort of I/O operations so even things like Go channels must be avoided.
+// Indeed, this limitation is not that ultimate at least if you know what you're doing.
+func (a *App) modifyPluginData(ctx context.Context, reqID string, fn func(data *PluginData) (PluginData, bool)) (bool, error) {
+ backoff := backoff.NewDecorr(modifyPluginDataBackoffBase, modifyPluginDataBackoffMax, clockwork.NewRealClock())
+ for {
+ oldData, err := a.getPluginData(ctx, reqID)
+ if err != nil && !trace.IsNotFound(err) {
+ return false, trace.Wrap(err)
+ }
+ newData, ok := fn(oldData)
+ if !ok {
+ return false, nil
+ }
+ var expectData PluginData
+ if oldData != nil {
+ expectData = *oldData
+ }
+ err = trace.Wrap(a.updatePluginData(ctx, reqID, newData, expectData))
+ if err == nil {
+ return true, nil
+ }
+ if !trace.IsCompareFailed(err) {
+ return false, trace.Wrap(err)
+ }
+ if err := backoff.Do(ctx); err != nil {
+ return false, trace.Wrap(err)
+ }
+ }
+}
+
+// getPluginData loads a plugin data for a given access request. It returns nil if it's not found.
+func (a *App) getPluginData(ctx context.Context, reqID string) (*PluginData, error) {
+ dataMaps, err := a.teleport.GetPluginData(ctx, types.PluginDataFilter{
+ Kind: types.KindAccessRequest,
+ Resource: reqID,
+ Plugin: pluginName,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if len(dataMaps) == 0 {
+ return nil, trace.NotFound("plugin data not found")
+ }
+ entry := dataMaps[0].Entries()[pluginName]
+ if entry == nil {
+ return nil, trace.NotFound("plugin data entry not found")
+ }
+ data := DecodePluginData(entry.Data)
+ return &data, nil
+}
+
+// updatePluginData updates an existing plugin data or sets a new one if it didn't exist.
+func (a *App) updatePluginData(ctx context.Context, reqID string, data PluginData, expectData PluginData) error {
+ return a.teleport.UpdatePluginData(ctx, types.PluginDataUpdateParams{
+ Kind: types.KindAccessRequest,
+ Resource: reqID,
+ Plugin: pluginName,
+ Set: EncodePluginData(data),
+ Expect: EncodePluginData(expectData),
+ })
+}
diff --git a/integrations/access/incidentio/bot.go b/integrations/access/incidentio/bot.go
new file mode 100644
index 0000000000000..80c658899dae4
--- /dev/null
+++ b/integrations/access/incidentio/bot.go
@@ -0,0 +1,141 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "context"
+ "net/url"
+ "time"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/types/accesslist"
+ "github.com/gravitational/teleport/integrations/access/accessrequest"
+ "github.com/gravitational/teleport/integrations/access/common"
+ pd "github.com/gravitational/teleport/integrations/lib/plugindata"
+)
+
+// Bot is a incident.io client that works with AccessRequest.
+// It's responsible for formatting and incident.io alerts when an
+// action occurs with an access request: a new request popped up, or a
+// request is processed/updated.
+type Bot struct {
+ apiClient *APIClient
+ alertClient *AlertClient
+ clusterName string
+ webProxyURL *url.URL
+}
+
+// SupportedApps are the apps supported by this bot.
+func (b *Bot) SupportedApps() []common.App {
+ return []common.App{
+ accessrequest.NewApp(b),
+ }
+}
+
+// CheckHealth checks if the bot can connect to its messaging service
+func (b *Bot) CheckHealth(ctx context.Context) error {
+ return trace.Wrap(b.apiClient.CheckHealth(ctx))
+}
+
+// SendReviewReminders will send a review reminder that an access list needs to be reviewed.
+func (b Bot) SendReviewReminders(ctx context.Context, recipients []common.Recipient, accessLists []*accesslist.AccessList) error {
+ return trace.NotImplemented("access list review reminder is not yet implemented")
+}
+
+// BroadcastAccessRequestMessage creates an alert for the provided recipients (schedules)
+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)
+ }
+ autoApprovalSchedules := []string{}
+ if annotationAutoApprovalSchedules, ok := reqData.SystemAnnotations[types.TeleportNamespace+types.ReqAnnotationApproveSchedulesLabel]; ok {
+ autoApprovalSchedules = annotationAutoApprovalSchedules
+ }
+ if len(autoApprovalSchedules) == 0 {
+ autoApprovalSchedules = append(autoApprovalSchedules, b.apiClient.DefaultSchedules...)
+ }
+ incidentReqData := RequestData{
+ User: reqData.User,
+ Roles: reqData.Roles,
+ Created: time.Now(),
+ RequestReason: reqData.RequestReason,
+ ReviewsCount: reqData.ReviewsCount,
+ Resolution: Resolution{
+ Tag: ResolutionTag(reqData.ResolutionTag),
+ Reason: reqData.ResolutionReason,
+ },
+ SystemAnnotations: types.Labels{
+ types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel: autoApprovalSchedules,
+ types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel: notificationSchedules,
+ },
+ }
+ incidentAlertData, err := b.alertClient.CreateAlert(ctx, reqID, incidentReqData)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ data = accessrequest.SentMessages{{
+ MessageID: incidentAlertData.DeduplicationKey,
+ }}
+
+ return data, nil
+
+}
+
+// PostReviewReply posts an alert note.
+func (b *Bot) PostReviewReply(ctx context.Context, _ string, alertID string, review types.AccessReview) error {
+ return trace.NotImplemented("post review reply not implemented for plugin")
+}
+
+// NotifyUser will send users a direct message with the access request status
+func (b Bot) NotifyUser(ctx context.Context, reqID string, reqData pd.AccessRequestData) error {
+ return trace.NotImplemented("notify user not implemented for plugin")
+}
+
+// UpdateMessages add notes to the alert containing updates to status.
+// This will also resolve alerts based on the resolution tag.
+func (b *Bot) UpdateMessages(ctx context.Context, reqID string, data pd.AccessRequestData, alertData accessrequest.SentMessages, reviews []types.AccessReview) error {
+ var errs []error
+ for _, alert := range alertData {
+ resolution := Resolution{
+ Tag: ResolutionTag(data.ResolutionTag),
+ Reason: data.ResolutionReason,
+ }
+ err := b.alertClient.ResolveAlert(ctx, alert.MessageID, resolution)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return trace.NewAggregate(errs...)
+}
+
+// FetchRecipient returns the recipient for the given raw recipient.
+func (b *Bot) FetchRecipient(ctx context.Context, name string) (*common.Recipient, error) {
+ return createScheduleRecipient(ctx, name)
+}
+
+func createScheduleRecipient(ctx context.Context, name string) (*common.Recipient, error) {
+ return &common.Recipient{
+ Name: name,
+ ID: name,
+ Kind: common.RecipientKindSchedule,
+ }, nil
+}
diff --git a/integrations/access/incidentio/client.go b/integrations/access/incidentio/client.go
new file mode 100644
index 0000000000000..15d90b4f4f187
--- /dev/null
+++ b/integrations/access/incidentio/client.go
@@ -0,0 +1,233 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/aws/aws-sdk-go/aws/defaults"
+ "github.com/go-resty/resty/v2"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/access/common"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+ "github.com/gravitational/trace"
+)
+
+const (
+ // alertKeyPrefix is the prefix for Alert's alias field used when creating an Alert.
+ alertKeyPrefix = "teleport-access-request"
+)
+
+// AlertClient is a wrapper around resty.Client.
+type AlertClient struct {
+ ClientConfig
+
+ client *resty.Client
+}
+
+type APIClient struct {
+ ClientConfig
+
+ client *resty.Client
+}
+
+// ClientConfig is the config for the incident.io client.
+type ClientConfig struct {
+ // AccessToken is the token for the incident.io Alert Source
+ AccessToken string `toml:"access_token"`
+ // APIKey is the API key for the incident.io API
+ APIKey string `toml:"api_key"`
+ // AlertSourceEndpoint is the endpoint for the incident.io Alert Source
+ AlertSourceEndpoint string `toml:"alert_source_endpoint"`
+ // APIEndpoint is the endpoint for the incident.io API
+ // api url of the form https://api.incident.io/v2/ with optional trailing '/'
+ APIEndpoint string `toml:"api_endpoint"`
+ // DefaultSchedules are the default on-call schedules to check for auto approval
+ DefaultSchedules []string `toml:"default_schedules"`
+
+ // WebProxyURL is the Teleport address used when building the bodies of the alerts
+ // allowing links to the access requests to be built
+ WebProxyURL *url.URL `toml:"web_proxy_url"`
+ // ClusterName is the name of the Teleport cluster
+ ClusterName string `toml:"cluster_name"`
+
+ // StatusSink receives any status updates from the plugin for
+ // further processing. Status updates will be ignored if not set.
+ StatusSink common.StatusSink
+}
+
+func (cfg *ClientConfig) CheckAndSetDefaults() error {
+ if cfg.AccessToken == "" {
+ return trace.BadParameter("missing required value AccessToken")
+ }
+ if cfg.AlertSourceEndpoint == "" {
+ return trace.BadParameter("missing required value AlertSourceEndpoint")
+ }
+ if cfg.APIKey == "" {
+ return trace.BadParameter("missing required value APIKey")
+ }
+ if cfg.APIEndpoint == "" {
+ return trace.BadParameter("missing required value APIEndpoint")
+ }
+ return nil
+}
+
+// NewAlertClient creates a new incident.io client for sending alerts.
+func NewAlertClient(conf ClientConfig) (*AlertClient, error) {
+ client := resty.NewWithClient(defaults.Config().HTTPClient)
+ client.SetHeader("Authorization", "Bearer "+conf.AccessToken)
+ return &AlertClient{
+ client: client,
+ ClientConfig: conf,
+ }, nil
+}
+
+// NewAPIClient creates a new incident.io client for interacting with the incident.io API.
+func NewAPIClient(conf ClientConfig) (*APIClient, error) {
+ client := resty.NewWithClient(defaults.Config().HTTPClient)
+ client.SetHeader("Authorization", "Bearer "+conf.APIKey)
+ client.SetBaseURL(conf.APIEndpoint)
+ return &APIClient{
+ client: client,
+ ClientConfig: conf,
+ }, nil
+}
+
+func errWrapper(statusCode int, body string) error {
+ switch statusCode {
+ case http.StatusForbidden:
+ return trace.AccessDenied("incident.io API access denied: status code %v: %q", statusCode, body)
+ case http.StatusRequestTimeout:
+ return trace.ConnectionProblem(trace.Errorf("status code %v: %q", statusCode, body),
+ "connecting to incident.io API")
+ }
+ return trace.Errorf("connecting to incident.io API status code %v: %q", statusCode, body)
+}
+
+// CreateAlert creates an incidentio alert.
+func (inc AlertClient) CreateAlert(ctx context.Context, reqID string, reqData RequestData) (IncidentAlertData, error) {
+ body := AlertBody{
+ Title: fmt.Sprintf("Access request from %s", reqData.User),
+ DeduplicationKey: fmt.Sprintf("%s/%s", alertKeyPrefix, reqID),
+ Description: fmt.Sprintf("Access request from %s", reqData.User),
+ Status: "firing",
+ Metadata: map[string]string{
+ "request_id": reqID,
+ },
+ }
+
+ var result AlertBody
+ resp, err := inc.client.NewRequest().
+ SetContext(ctx).
+ SetBody(body).
+ SetResult(&result).
+ Post(inc.AlertSourceEndpoint)
+ if err != nil {
+ return IncidentAlertData{}, trace.Wrap(err)
+ }
+ defer resp.RawResponse.Body.Close()
+ if resp.IsError() {
+ return IncidentAlertData{}, errWrapper(resp.StatusCode(), string(resp.Body()))
+ }
+
+ return IncidentAlertData{
+ DeduplicationKey: result.DeduplicationKey,
+ }, nil
+}
+
+// ResolveAlert resolves an alert and posts a note with resolution details.
+func (inc AlertClient) ResolveAlert(ctx context.Context, alertID string, resolution Resolution) error {
+ alertBody := &AlertBody{
+ Status: "resolved",
+ Title: fmt.Sprintf("Access request resolved: %s", resolution.Tag),
+ Description: fmt.Sprintf("Access request has been %s", resolution.Tag),
+ DeduplicationKey: fmt.Sprintf("%s/%s", alertKeyPrefix, alertID),
+ Metadata: map[string]string{
+ "request_id": alertID,
+ },
+ }
+
+ resp, err := inc.client.NewRequest().
+ SetContext(ctx).
+ SetBody(alertBody).
+ Post(inc.AlertSourceEndpoint)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer resp.RawResponse.Body.Close()
+ if resp.IsError() {
+ return errWrapper(resp.StatusCode(), string(resp.Body()))
+ }
+ return nil
+}
+
+// GetOnCall returns the list of responders on-call for a schedule.
+func (inc APIClient) GetOnCall(ctx context.Context, scheduleID string) (GetScheduleResponse, error) {
+ var result GetScheduleResponse
+ resp, err := inc.client.NewRequest().
+ SetContext(ctx).
+ SetPathParams(map[string]string{"scheduleID": scheduleID}).
+ SetResult(&result).
+ Get("/v2/schedules/{scheduleID}")
+ if err != nil {
+ return GetScheduleResponse{}, trace.Wrap(err)
+ }
+ defer resp.RawResponse.Body.Close()
+ if resp.IsError() {
+ return GetScheduleResponse{}, errWrapper(resp.StatusCode(), string(resp.Body()))
+ }
+ return result, nil
+}
+
+// CheckHealth pings incident.io.
+func (inc APIClient) CheckHealth(ctx context.Context) error {
+ // The heartbeat pings will respond even if the heartbeat does not exist.
+ resp, err := inc.client.NewRequest().
+ SetContext(ctx).
+ Get("v2/schedules")
+
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer resp.RawResponse.Body.Close()
+
+ if inc.StatusSink != nil {
+ var code types.PluginStatusCode
+ switch {
+ case resp.StatusCode() == http.StatusUnauthorized:
+ code = types.PluginStatusCode_UNAUTHORIZED
+ case resp.StatusCode() >= 200 && resp.StatusCode() < 400:
+ code = types.PluginStatusCode_RUNNING
+ default:
+ code = types.PluginStatusCode_OTHER_ERROR
+ }
+ if err := inc.StatusSink.Emit(ctx, &types.PluginStatusV1{Code: code}); err != nil {
+ logger.Get(resp.Request.Context()).WithError(err).
+ WithField("code", resp.StatusCode()).Errorf("Error while emitting servicenow plugin status: %v", err)
+ }
+ }
+
+ if resp.IsError() {
+ return errWrapper(resp.StatusCode(), string(resp.Body()))
+ }
+ return nil
+}
diff --git a/integrations/access/incidentio/cmd/teleport-incidentio/install b/integrations/access/incidentio/cmd/teleport-incidentio/install
new file mode 100755
index 0000000000000..7fef902373aa6
--- /dev/null
+++ b/integrations/access/incidentio/cmd/teleport-incidentio/install
@@ -0,0 +1,19 @@
+#!/bin/sh
+
+#
+# the directory where Teleport binaries will be located
+#
+BINDIR=/usr/local/bin
+
+#
+# the directory where Teleport Plugins store their data files
+# and certificates
+#
+DATADIR=/var/lib/teleport/plugins/incidentio
+
+[ ! $(id -u) != "0" ] || { echo "ERROR: You must be root"; exit 1; }
+cd $(dirname $0)
+mkdir -p $BINDIR $DATADIR || exit 1
+cp -f teleport-incidentio $BINDIR/ || exit 1
+
+echo "Teleport incident.io binaries have been copied to $BINDIR"
diff --git a/integrations/access/incidentio/cmd/teleport-incidentio/main.go b/integrations/access/incidentio/cmd/teleport-incidentio/main.go
new file mode 100644
index 0000000000000..c92a3ae9cae5d
--- /dev/null
+++ b/integrations/access/incidentio/cmd/teleport-incidentio/main.go
@@ -0,0 +1,98 @@
+// Copyright 2023 Gravitational, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "context"
+ "os"
+ "time"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/teleport/integrations/access/incidentio"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/lib"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+)
+
+var (
+ appName = "teleport-incidentio"
+ gracefulShutdownTimeout = 15 * time.Second
+ configPath = "/etc/teleport-incidentio.toml"
+)
+
+func main() {
+ logger.Init()
+ app := kingpin.New(appName, "Teleport incident.io plugin")
+
+ app.Command("version", "Prints teleport-incidentio version and exits")
+
+ startCmd := app.Command("start", "Starts Teleport incident.io plugin")
+ startConfigPath := startCmd.Flag("config", "TOML config file path").
+ Short('c').
+ Default(configPath).
+ String()
+ debug := startCmd.Flag("debug", "Enable verbose logging to stderr").
+ Short('d').
+ Bool()
+
+ selectedCmd, err := app.Parse(os.Args[1:])
+ if err != nil {
+ lib.Bail(err)
+ }
+
+ switch selectedCmd {
+
+ case "version":
+ lib.PrintVersion(app.Name, teleport.Version, teleport.Gitref)
+ case "start":
+ if err := run(*startConfigPath, *debug); err != nil {
+ lib.Bail(err)
+ } else {
+ logger.Standard().Info("Successfully shut down")
+ }
+ }
+
+}
+
+func run(configPath string, debug bool) error {
+ conf, err := incidentio.LoadConfig(configPath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ logConfig := conf.Log
+ if debug {
+ logConfig.Severity = "debug"
+ }
+ if err = logger.Setup(logConfig); err != nil {
+ return err
+ }
+ if debug {
+ logger.Standard().Debugf("DEBUG logging enabled")
+ }
+
+ app, err := incidentio.NewIncidentApp(context.Background(), conf)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ go lib.ServeSignals(app, gracefulShutdownTimeout)
+
+ return trace.Wrap(
+ app.Run(context.Background()),
+ )
+}
diff --git a/integrations/access/incidentio/config.go b/integrations/access/incidentio/config.go
new file mode 100644
index 0000000000000..8767b0c3ea461
--- /dev/null
+++ b/integrations/access/incidentio/config.go
@@ -0,0 +1,120 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "context"
+ "net/url"
+
+ "github.com/gravitational/trace"
+ "github.com/pelletier/go-toml"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/access/common"
+ "github.com/gravitational/teleport/integrations/access/common/teleport"
+)
+
+// Config stores the full configuration for the teleport-incidentio plugin to run.
+type Config struct {
+ common.BaseConfig
+ // Incident contains the incident.io specific configuration.
+ Incident common.GenericAPIConfig
+ // ClientConfig contains the config for the incident.io client.
+ ClientConfig ClientConfig `toml:"client_config"`
+
+ // 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
+}
+
+// LoadConfig reads the config file, initializes a new Config struct object, and returns it.
+// Optionally returns an error if the file is not readable, or if file format is invalid.
+func LoadConfig(filepath string) (*Config, error) {
+ t, err := toml.LoadFile(filepath)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ conf := &Config{}
+ if err := t.Unmarshal(conf); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := conf.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return conf, nil
+}
+
+// CheckAndSetDefaults checks the config struct for any logical errors, and sets default values
+// if some values are missing.
+// If critical values are missing and we can't set defaults for them, this will return an error.
+func (c *Config) CheckAndSetDefaults() error {
+ if err := c.Teleport.CheckAndSetDefaults(); err != nil {
+ return trace.Wrap(err)
+ }
+ if err := c.ClientConfig.CheckAndSetDefaults(); err != nil {
+ return trace.Wrap(err)
+ }
+
+ if c.Log.Output == "" {
+ c.Log.Output = "stderr"
+ }
+ if c.Log.Severity == "" {
+ c.Log.Severity = "info"
+ }
+
+ c.PluginType = types.PluginTypeIncidentio
+ 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 incident.io message generator (Incidentbot)
+func (c *Config) NewBot(clusterName, webProxyAddr string) (common.MessagingBot, error) {
+ webProxyURL, err := url.Parse(webProxyAddr)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ c.ClientConfig.WebProxyURL = webProxyURL
+ c.ClientConfig.ClusterName = clusterName
+ apiClient, err := NewAPIClient(c.ClientConfig)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ alertClient, err := NewAlertClient(c.ClientConfig)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &Bot{
+ apiClient: apiClient,
+ alertClient: alertClient,
+ clusterName: clusterName,
+ webProxyURL: webProxyURL,
+ }, nil
+}
diff --git a/integrations/access/incidentio/config_test.go b/integrations/access/incidentio/config_test.go
new file mode 100644
index 0000000000000..7528047fa2cf8
--- /dev/null
+++ b/integrations/access/incidentio/config_test.go
@@ -0,0 +1,83 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/integrations/access/common"
+)
+
+func newMinimalValidConfig() Config {
+ return Config{
+ Incident: common.GenericAPIConfig{
+ Token: "someToken",
+ },
+ ClientConfig: ClientConfig{
+ APIKey: "someAPIKey",
+ APIEndpoint: "someEnpoint",
+ WebProxyURL: &url.URL{},
+ },
+ }
+}
+
+func TestInvalidConfigFailsCheck(t *testing.T) {
+ t.Run("Token", func(t *testing.T) {
+ cfg := newMinimalValidConfig()
+ cfg.Incident.Token = ""
+ require.Error(t, cfg.CheckAndSetDefaults())
+ })
+ t.Run("API Key", func(t *testing.T) {
+ cfg := newMinimalValidConfig()
+ cfg.ClientConfig.APIKey = ""
+ require.Error(t, cfg.CheckAndSetDefaults())
+ })
+ t.Run("API endpoint", func(t *testing.T) {
+ cfg := newMinimalValidConfig()
+ cfg.ClientConfig.APIEndpoint = ""
+ require.Error(t, cfg.CheckAndSetDefaults())
+ })
+ t.Run("Web proxy url", func(t *testing.T) {
+ cfg := newMinimalValidConfig()
+ cfg.ClientConfig.WebProxyURL = nil
+ require.Error(t, cfg.CheckAndSetDefaults())
+ })
+
+}
+
+func TestConfigDefaults(t *testing.T) {
+ cfg := Config{
+ Incident: common.GenericAPIConfig{
+ Token: "someToken",
+ },
+ ClientConfig: ClientConfig{
+ APIKey: "someAPIKey",
+ APIEndpoint: "someEnpoint",
+ AccessToken: "someAccessToken",
+ AlertSourceEndpoint: "someAlertSourceEndpoint",
+ WebProxyURL: &url.URL{},
+ },
+ }
+ require.NoError(t, cfg.CheckAndSetDefaults())
+ require.Equal(t, "stderr", cfg.Log.Output)
+ require.Equal(t, "info", cfg.Log.Severity)
+}
diff --git a/integrations/access/incidentio/plugindata.go b/integrations/access/incidentio/plugindata.go
new file mode 100644
index 0000000000000..1edecb0444cb3
--- /dev/null
+++ b/integrations/access/incidentio/plugindata.go
@@ -0,0 +1,120 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/gravitational/teleport/api/types"
+)
+
+// PluginData is a data associated with access request that we store in Teleport using UpdatePluginData API.
+type PluginData struct {
+ RequestData
+ IncidentAlertData
+}
+
+// Resolution stores the resolution (approved, denied or expired) and its reason.
+type Resolution struct {
+ Tag ResolutionTag
+ Reason string
+}
+
+// ResolutionTag represents if and in which state an access request was resolved.
+type ResolutionTag string
+
+// Unresolved is added to alerts that are yet to be resolved.
+const Unresolved = ResolutionTag("")
+
+// ResolvedApproved is added to alerts that are approved.
+const ResolvedApproved = ResolutionTag("approved")
+
+// ResolvedDenied is added to alerts that are denied.
+const ResolvedDenied = ResolutionTag("denied")
+
+// ResolvedExpired is added to alerts that are expired.
+const ResolvedExpired = ResolutionTag("expired")
+
+// ResolvedPromoted is added to alerts that are promoted to an access list.
+const ResolvedPromoted = ResolutionTag("promoted")
+
+// RequestData stores a slice of some request fields in a convenient format.
+type RequestData struct {
+ User string
+ Roles []string
+ Created time.Time
+ RequestReason string
+ ReviewsCount int
+ Resolution Resolution
+ SystemAnnotations types.Labels
+}
+
+// IncidentAlertData stores the deduplication key
+type IncidentAlertData struct {
+ DeduplicationKey string
+}
+
+// DecodePluginData deserializes a string map to PluginData struct.
+func DecodePluginData(dataMap map[string]string) (data PluginData) {
+ data.User = dataMap["user"]
+ if str := dataMap["roles"]; str != "" {
+ data.Roles = strings.Split(str, ",")
+ }
+ if str := dataMap["created"]; str != "" {
+ var created int64
+ fmt.Sscanf(dataMap["created"], "%d", &created)
+ data.Created = time.Unix(created, 0)
+ }
+ data.RequestReason = dataMap["request_reason"]
+ if str := dataMap["reviews_count"]; str != "" {
+ fmt.Sscanf(str, "%d", &data.ReviewsCount)
+ }
+ data.Resolution.Tag = ResolutionTag(dataMap["resolution"])
+ data.Resolution.Reason = dataMap["resolve_reason"]
+ data.DeduplicationKey = dataMap["deduplication_key"]
+ return
+}
+
+// EncodePluginData serializes a PluginData struct into a string map.
+func EncodePluginData(data PluginData) map[string]string {
+ result := make(map[string]string)
+ result["deduplication_key"] = data.DeduplicationKey
+ result["user"] = data.User
+ result["roles"] = strings.Join(data.Roles, ",")
+
+ var createdStr string
+ if !data.Created.IsZero() {
+ createdStr = fmt.Sprintf("%d", data.Created.Unix())
+ }
+ result["created"] = createdStr
+
+ result["request_reason"] = data.RequestReason
+
+ var reviewsCountStr string
+ if data.ReviewsCount != 0 {
+ reviewsCountStr = fmt.Sprintf("%d", data.ReviewsCount)
+ }
+ result["reviews_count"] = reviewsCountStr
+
+ result["resolution"] = string(data.Resolution.Tag)
+ result["resolve_reason"] = data.Resolution.Reason
+ return result
+}
diff --git a/integrations/access/incidentio/testlib/fake_incident.go b/integrations/access/incidentio/testlib/fake_incident.go
new file mode 100644
index 0000000000000..20f366efe33ea
--- /dev/null
+++ b/integrations/access/incidentio/testlib/fake_incident.go
@@ -0,0 +1,225 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 testlib
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "runtime/debug"
+ "sync"
+ "time"
+
+ "github.com/gravitational/teleport/integrations/access/incidentio"
+ "github.com/gravitational/trace"
+ "github.com/julienschmidt/httprouter"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/gravitational/teleport/integrations/lib/stringset"
+)
+
+type FakeIncident struct {
+ srv *httptest.Server
+
+ objects sync.Map
+ // Alerts
+ alertIDCounter uint64
+ newAlerts chan incidentio.AlertBody
+ alertUpdates chan incidentio.AlertBody
+}
+
+type QueryValues url.Values
+
+func (q QueryValues) GetAsSet(name string) stringset.StringSet {
+ values := q[name]
+ result := stringset.NewWithCap(len(values))
+ for _, v := range values {
+ if v != "" {
+ result[v] = struct{}{}
+ }
+ }
+ if len(result) == 0 {
+ return nil
+ }
+ return result
+}
+
+func NewFakeIncident(concurrency int) *FakeIncident {
+ router := httprouter.New()
+
+ mock := &FakeIncident{
+ newAlerts: make(chan incidentio.AlertBody, concurrency),
+ alertUpdates: make(chan incidentio.AlertBody, concurrency),
+ srv: httptest.NewServer(router),
+ }
+
+ router.GET("/v2/schedules/:scheduleID", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.Header().Add("Content-Type", "application/json")
+ scheduleID := ps.ByName("scheduleID")
+
+ // Check if exists
+ _, ok := mock.GetSchedule(scheduleID)
+ if !ok {
+ rw.WriteHeader(http.StatusNotFound)
+ return
+ }
+
+ emails := mock.GetOnCallEmailsForSchedule(scheduleID)
+
+ response := incidentio.ScheduleResult{
+ Annotations: map[string]string{},
+ Config: incidentio.ScheduleConfig{
+ Rotations: nil,
+ },
+ CreatedAt: time.Time{},
+ CurrentShifts: []incidentio.CurrentShift{
+ {
+ EndAt: time.Time{},
+ EntryID: "someEntryID",
+ Fingerprint: "someFingerprint",
+ LayerID: "someLayerID",
+ RotationID: "someRotationID",
+ StartAt: time.Time{},
+ User: &incidentio.User{
+ Email: emails[0],
+ },
+ },
+ },
+ HolidaysPublicConfig: incidentio.HolidaysPublicConfig{
+ CountryCodes: nil,
+ },
+ ID: "someSchedule",
+ Name: "Schedule",
+ Timezone: "UTC",
+ UpdatedAt: time.Time{},
+ }
+
+ rw.WriteHeader(http.StatusOK)
+ err := json.NewEncoder(rw).Encode(response)
+ panicIf(err)
+ })
+ router.GET("/v2/schedules", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.WriteHeader(http.StatusOK)
+ })
+ router.POST("/v2/alert_events/http/someRequestID", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.Header().Add("Content-Type", "application/json")
+ rw.WriteHeader(http.StatusCreated)
+
+ var alert incidentio.AlertBody
+ err := json.NewDecoder(r.Body).Decode(&alert)
+ panicIf(err)
+
+ if alert.Status == "firing" {
+ mock.newAlerts <- alert
+ } else if alert.Status == "resolved" {
+ mock.alertUpdates <- alert
+ } else {
+ panic("unsupported alert status")
+ }
+ mock.StoreAlert(alert)
+
+ err = json.NewEncoder(rw).Encode(alert)
+ panicIf(err)
+ })
+ return mock
+}
+
+func (s *FakeIncident) URL() string {
+ return s.srv.URL
+}
+
+func (s *FakeIncident) Close() {
+ s.srv.Close()
+ close(s.newAlerts)
+ close(s.alertUpdates)
+}
+
+func (s *FakeIncident) GetAlert(id string) (incidentio.AlertBody, bool) {
+ if obj, ok := s.objects.Load(id); ok {
+ alert, ok := obj.(incidentio.AlertBody)
+ return alert, ok
+ }
+ return incidentio.AlertBody{}, false
+}
+
+func (s *FakeIncident) StoreAlert(alert incidentio.AlertBody) incidentio.AlertBody {
+ s.objects.Store(alert.DeduplicationKey, alert)
+ return alert
+}
+
+func (s *FakeIncident) CheckNewAlert(ctx context.Context) (incidentio.AlertBody, error) {
+ select {
+ case alert := <-s.newAlerts:
+ return alert, nil
+ case <-ctx.Done():
+ return incidentio.AlertBody{}, trace.Wrap(ctx.Err())
+ }
+}
+
+func (s *FakeIncident) CheckAlertUpdate(ctx context.Context) (incidentio.AlertBody, error) {
+ select {
+ case alert := <-s.alertUpdates:
+ return alert, nil
+ case <-ctx.Done():
+ return incidentio.AlertBody{}, trace.Wrap(ctx.Err())
+ }
+}
+
+func (s *FakeIncident) StoreSchedule(scheduleID string, schedule incidentio.ScheduleResult) incidentio.ScheduleResult {
+ key := fmt.Sprintf("schedule-%s", scheduleID)
+ s.objects.Store(key, schedule)
+ return schedule
+}
+
+func (s *FakeIncident) GetSchedule(scheduleID string) (*incidentio.ScheduleResult, bool) {
+ key := fmt.Sprintf("schedule-%s", scheduleID)
+ value, ok := s.objects.Load(key)
+ if !ok {
+ return nil, false
+ }
+ schedule, ok := value.(incidentio.ScheduleResult)
+ if !ok {
+ panic("cannot cast to schedule")
+ }
+ return &schedule, true
+}
+
+func panicIf(err error) {
+ if err != nil {
+ log.Panicf("%v at %v", err, string(debug.Stack()))
+ }
+}
+
+func (s *FakeIncident) GetOnCallEmailsForSchedule(scheduleID string) []string {
+ var emails []string
+ schedule, ok := s.GetSchedule(scheduleID)
+ if !ok {
+ return nil
+ }
+ for _, shift := range schedule.CurrentShifts {
+ if shift.User != nil {
+ emails = append(emails, shift.User.Email)
+ }
+ }
+
+ return emails
+}
diff --git a/integrations/access/incidentio/testlib/helpers.go b/integrations/access/incidentio/testlib/helpers.go
new file mode 100644
index 0000000000000..596ffbc9b7344
--- /dev/null
+++ b/integrations/access/incidentio/testlib/helpers.go
@@ -0,0 +1,39 @@
+/*
+ * 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 testlib
+
+import (
+ "context"
+
+ "github.com/gravitational/teleport/integrations/access/incidentio"
+ "github.com/stretchr/testify/require"
+)
+
+func (s *IncidentBaseSuite) checkPluginData(ctx context.Context, reqID string, cond func(incidentio.PluginData) bool) incidentio.PluginData {
+ t := s.T()
+ t.Helper()
+
+ for {
+ rawData, err := s.Ruler().PollAccessRequestPluginData(ctx, "incidentio", reqID)
+ require.NoError(t, err)
+ if data := incidentio.DecodePluginData(rawData); cond(data) {
+ return data
+ }
+ }
+}
diff --git a/integrations/access/incidentio/testlib/oss_integration_test.go b/integrations/access/incidentio/testlib/oss_integration_test.go
new file mode 100644
index 0000000000000..83508cbf79e78
--- /dev/null
+++ b/integrations/access/incidentio/testlib/oss_integration_test.go
@@ -0,0 +1,38 @@
+/*
+ * 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 testlib
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/gravitational/teleport/integrations/lib/testing/integration"
+)
+
+func TestIncidentPluginOSS(t *testing.T) {
+ incidentioSuite := &IncidentSuiteOSS{
+ IncidentBaseSuite: IncidentBaseSuite{
+ AccessRequestSuite: &integration.AccessRequestSuite{
+ AuthHelper: &integration.MinimalAuthHelper{},
+ },
+ },
+ }
+ suite.Run(t, incidentioSuite)
+}
diff --git a/integrations/access/incidentio/testlib/suite.go b/integrations/access/incidentio/testlib/suite.go
new file mode 100644
index 0000000000000..cfb90076702b7
--- /dev/null
+++ b/integrations/access/incidentio/testlib/suite.go
@@ -0,0 +1,357 @@
+/*
+ * 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 testlib
+
+import (
+ "context"
+ "runtime"
+ "time"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/access/incidentio"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+ "github.com/gravitational/teleport/integrations/lib/testing/integration"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ NotifyScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationNotifySchedulesLabel
+ ApprovalScheduleName = "Teleport Approval"
+ ApprovalScheduleAnnotation = types.TeleportNamespace + types.ReqAnnotationApproveSchedulesLabel
+)
+
+// IncidentBaseSuite is the incident.io access plugin test suite.
+// It implements the testify.TestingSuite interface.
+type IncidentBaseSuite struct {
+ *integration.AccessRequestSuite
+ appConfig incidentio.Config
+ raceNumber int
+ fakeIncident *FakeIncident
+
+ incSchedule incidentio.ScheduleResult
+}
+
+// SetupTest starts a fake incident.io and generates the plugin configuration.
+// It also configures the role notifications for incident.io notifications and
+// automatic approval.
+// It is run for each test.
+func (s *IncidentBaseSuite) SetupTest() {
+ t := s.T()
+ ctx := context.Background()
+
+ err := logger.Setup(logger.Config{Severity: "debug"})
+ require.NoError(t, err)
+ s.raceNumber = 2 * runtime.GOMAXPROCS(0)
+
+ s.fakeIncident = NewFakeIncident(s.raceNumber)
+ t.Cleanup(s.fakeIncident.Close)
+
+ // This service should be notified for every access request.
+ s.incSchedule = s.fakeIncident.StoreSchedule("aScheduleID", incidentio.ScheduleResult{
+ Annotations: nil,
+ Config: incidentio.ScheduleConfig{
+ Rotations: nil,
+ },
+ CreatedAt: time.Time{},
+ CurrentShifts: nil,
+ HolidaysPublicConfig: incidentio.HolidaysPublicConfig{
+ CountryCodes: nil,
+ },
+ ID: "aScheduleID",
+ Name: "Teleport Notifications One",
+ Timezone: "",
+ UpdatedAt: time.Time{},
+ })
+
+ s.AnnotateRequesterRoleAccessRequests(
+ ctx,
+ NotifyScheduleAnnotation,
+ []string{"aScheduleID"},
+ )
+
+ var conf incidentio.Config
+ conf.Teleport = s.TeleportConfig()
+ conf.ClientConfig.APIEndpoint = s.fakeIncident.URL()
+ conf.ClientConfig.AlertSourceEndpoint = s.fakeIncident.URL() + "/v2/alert_events/http/someRequestID"
+ conf.PluginType = types.PluginTypeIncidentio
+
+ s.appConfig = conf
+}
+
+// startApp starts the incident.io plugin, waits for it to become ready and returns.
+func (s *IncidentBaseSuite) startApp() {
+ s.T().Helper()
+ t := s.T()
+
+ app, err := incidentio.NewIncidentApp(context.Background(), &s.appConfig)
+ require.NoError(t, err)
+ integration.RunAndWaitReady(t, app)
+}
+
+// IncidentSuiteOSS contains all tests that support running against a Teleport
+// OSS Server.
+type IncidentSuiteOSS struct {
+ IncidentBaseSuite
+}
+
+// IncidentSuiteEnterprise contains all tests that require a Teleport Enterprise
+// to run.
+type IncidentSuiteEnterprise struct {
+ IncidentBaseSuite
+}
+
+// SetupTest overrides IncidentBaseSuite.SetupTest to check the Teleport features
+// before each test.
+func (s *IncidentSuiteEnterprise) SetupTest() {
+ t := s.T()
+ s.RequireAdvancedWorkflow(t)
+ s.IncidentBaseSuite.SetupTest()
+}
+
+// TestAlertCreationForSchedules validates that an alert is created to the service
+// specified in the role's annotation using /notify-services annotation
+func (s *IncidentSuiteOSS) TestAlertCreationForSchedules() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test execution: create an access request
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil)
+
+ // Validate the alert has been created in incident.io and its ID is stored in
+ // the plugin_data.
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool {
+ return data.DeduplicationKey != ""
+ })
+
+ alert, err := s.fakeIncident.CheckNewAlert(ctx)
+
+ require.NoError(t, err, "no new alerts stored")
+
+ assert.Equal(t, alert.DeduplicationKey, pluginData.DeduplicationKey)
+}
+
+// TestApproval tests that when a request is approved, its corresponding alert
+// is updated to reflect the new request state and a note is added to the alert.
+func (s *IncidentSuiteOSS) TestApproval() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its alert.
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil)
+
+ _, err := s.fakeIncident.CheckNewAlert(ctx)
+ require.NoError(t, err, "no new alerts stored")
+
+ // Test execution: we approve the request
+ err = s.Ruler().ApproveAccessRequest(ctx, req.GetName(), "okay")
+ require.NoError(t, err)
+
+ // Validating the plugin resolved the alert.
+ alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", alertUpdate.Status)
+}
+
+// TestDenial tests that when a request is denied, its corresponding alert
+// is updated to reflect the new request state and a note is added to the alert.
+func (s *IncidentSuiteOSS) TestDenial() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its alert.
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil)
+
+ _, err := s.fakeIncident.CheckNewAlert(ctx)
+ require.NoError(t, err, "no new alerts stored")
+
+ // Test execution: we deny the request
+ err = s.Ruler().DenyAccessRequest(ctx, req.GetName(), "not okay")
+ require.NoError(t, err)
+
+ // Validating the plugin resolved the alert.
+ alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", alertUpdate.Status)
+}
+
+// TestApprovalByReview tests that the alert is annotated and resolved after the
+// access request approval threshold is reached.
+func (s *IncidentSuiteEnterprise) TestApprovalByReview() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its alert.
+ req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil)
+
+ _, err := s.fakeIncident.CheckNewAlert(ctx)
+ require.NoError(t, err, "no new alerts stored")
+
+ // Test execution: we submit two reviews
+ err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer1UserName,
+ ProposedState: types.RequestState_APPROVED,
+ Created: time.Now(),
+ Reason: "okay",
+ })
+ require.NoError(t, err)
+
+ err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer2UserName,
+ ProposedState: types.RequestState_APPROVED,
+ Created: time.Now(),
+ Reason: "finally okay",
+ })
+ require.NoError(t, err)
+
+ // Validate the alert got resolved.
+ data := s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool {
+ return data.ReviewsCount == 2 && data.Resolution.Tag != incidentio.Unresolved
+ })
+ assert.Equal(t, incidentio.Resolution{Tag: incidentio.ResolvedApproved, Reason: "finally okay"}, data.Resolution)
+
+ alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", alertUpdate.Status)
+}
+
+// TestDenialByReview tests that the alert is annotated and resolved after the
+// access request denial threshold is reached.
+func (s *IncidentSuiteEnterprise) TestDenialByReview() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its alert.
+ req := s.CreateAccessRequest(ctx, integration.Requester1UserName, nil)
+
+ _, err := s.fakeIncident.CheckNewAlert(ctx)
+ require.NoError(t, err, "no new alerts stored")
+
+ // Test execution: we submit two reviews
+ err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer1UserName,
+ ProposedState: types.RequestState_DENIED,
+ Created: time.Now(),
+ Reason: "not okay",
+ })
+ require.NoError(t, err)
+
+ err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer2UserName,
+ ProposedState: types.RequestState_DENIED,
+ Created: time.Now(),
+ Reason: "finally not okay",
+ })
+ require.NoError(t, err)
+
+ // Validate the alert got resolved.
+ data := s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool {
+ return data.ReviewsCount == 2 && data.Resolution.Tag != incidentio.Unresolved
+ })
+ assert.Equal(t, incidentio.Resolution{Tag: incidentio.ResolvedDenied, Reason: "finally not okay"}, data.Resolution)
+
+ alertUpdate, err := s.fakeIncident.CheckAlertUpdate(ctx)
+ 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 *IncidentSuiteEnterprise) TestAutoApprovalWhenNotOnCall() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ // Test setup: create an on-call schedule with a non-Teleport user in it.
+ s.fakeIncident.StoreSchedule(ApprovalScheduleName, s.incSchedule)
+ 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)
+
+ _ = s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool {
+ return data.DeduplicationKey != ""
+ })
+
+ _, err := s.fakeIncident.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 *IncidentSuiteEnterprise) TestAutoApprovalWhenOnCall() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ // Test setup: create an on-call schedule with a non-Teleport user in it.
+ s.fakeIncident.StoreSchedule(ApprovalScheduleName, s.incSchedule)
+ 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)
+
+ _ = s.checkPluginData(ctx, req.GetName(), func(data incidentio.PluginData) bool {
+ return data.DeduplicationKey != ""
+ })
+
+ _, err := s.fakeIncident.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/incidentio/types.go b/integrations/access/incidentio/types.go
new file mode 100644
index 0000000000000..c4c84fcb2974c
--- /dev/null
+++ b/integrations/access/incidentio/types.go
@@ -0,0 +1,104 @@
+/*
+ * Teleport
+ * Copyright (C) 2023 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 incidentio
+
+import "time"
+
+// AlertBody represents an incident.io Alert
+type AlertBody struct {
+ // Title is the title of the alert.
+ Title string `json:"message,omitempty"`
+ // Description field of the alert.
+ Description string `json:"description,omitempty"`
+ // DeduplicationKey is the key to use for deduplication.
+ DeduplicationKey string `json:"deduplication_key,omitempty"`
+ // Status is the status of the alert.
+ Status string `json:"status,omitempty"`
+ // Metadata is a map of key-value pairs to use as custom properties of the alert.
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+type GetScheduleResponse struct {
+ Schedule ScheduleResult `json:"schedule"`
+}
+
+type ScheduleResult struct {
+ Annotations map[string]string `json:"annotations"`
+ Config ScheduleConfig `json:"config"`
+ CreatedAt time.Time `json:"created_at"`
+ CurrentShifts []CurrentShift `json:"current_shifts"`
+ HolidaysPublicConfig HolidaysPublicConfig `json:"holidays_public_config"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Timezone string `json:"timezone"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type ScheduleConfig struct {
+ Rotations []Rotation `json:"rotations"`
+}
+
+type Rotation struct {
+ EffectiveFrom time.Time `json:"effective_from"`
+ HandoverStartAt time.Time `json:"handover_start_at"`
+ Handovers []Handover `json:"handovers"`
+ ID string `json:"id"`
+ Layers []Layer `json:"layers"`
+ Name string `json:"name"`
+ Users []User `json:"users"`
+ WorkingInterval []WorkingInterval `json:"working_interval"`
+}
+
+type Handover struct {
+ Interval int `json:"interval"`
+ IntervalType string `json:"interval_type"`
+}
+
+type Layer struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type User struct {
+ Email string `json:"email"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ SlackUserID string `json:"slack_user_id"`
+}
+
+type WorkingInterval struct {
+ EndTime string `json:"end_time"`
+ StartTime string `json:"start_time"`
+ Weekday string `json:"weekday"`
+}
+
+type CurrentShift struct {
+ EndAt time.Time `json:"end_at"`
+ EntryID string `json:"entry_id"`
+ Fingerprint string `json:"fingerprint"`
+ LayerID string `json:"layer_id"`
+ RotationID string `json:"rotation_id"`
+ StartAt time.Time `json:"start_at"`
+ User *User `json:"user"`
+}
+
+type HolidaysPublicConfig struct {
+ CountryCodes []string `json:"country_codes"`
+}